diff --git a/components/icon-rail-nav/README.md b/components/icon-rail-nav/README.md index 9e6624e..7b3e409 100644 --- a/components/icon-rail-nav/README.md +++ b/components/icon-rail-nav/README.md @@ -4,8 +4,6 @@ A distinctive navigation component for Retool apps, inspired by Supabase's dashb ![Icon Rail Nav](./cover.png) ---- - ## Why this component? Most nav components force a tradeoff: compact (just icons, labels hidden) or verbose (takes up a ton of horizontal space). Icon Rail Nav gives you both — a tight **48px icon rail** that's always visible, a **contextual panel** that shows sub-items or descriptions for whatever's selected, and a **hover-reveal** that slides the full labels out from behind the rail when users need them. @@ -21,7 +19,7 @@ The result: screen-efficient nav with zero navigation guesswork. - **Sub-items** for deep navigation hierarchies - **Badges, sections, and descriptions** per item - **Dark and light themes** with custom accent colors -- **Custom icons** — built-in set, raw SVG strings, or image URLs +- **Custom icons** — built-in set, image URLs, or data URLs - **Page and app navigation** via model state and event handlers - **Developer help panel** with schema reference, icon picker, and copy-paste starter JSON (hideable before shipping) - **Parse error surfacing** with helpful error messages for invalid JSON @@ -31,7 +29,7 @@ The result: screen-efficient nav with zero navigation guesswork. ## Quickstart 1. Add the Icon Rail Nav component to your Retool app -2. Set the `ItemsJSON` property in the model panel — use the starter template below +2. Set the `Menu Items JSON` property in the model panel — use the starter template below 3. Wire the `Navigate` event handler → `Go to page` → `{{ iconRailNav1.model.activePage }}` 4. Done! Click an item to navigate. @@ -70,19 +68,25 @@ The result: screen-efficient nav with zero navigation guesswork. | Property | Type | Default | Description | |---|---|---|---| -| `ItemsJSON` | string | `""` | JSON array of nav items (see schema below) | -| `BottomItemsJSON` | string | `""` | JSON array of items pinned to the bottom of the rail (e.g. Settings) | +| `menuEditorVisibility` | `"show"`/`"hide"` | `"hide"` | Shows or hides the visual menu item editor. | +| `helpVisibility` | `"show"`/`"hide"` | `"show"` | Shows or hides the setup help drawer. | +| `itemsJson` | string | `""` | Menu Items JSON: JSON array of nav items (see schema below) | +| `bottomItemsJson` | string | `""` | JSON array of items pinned to the bottom of the rail (e.g. Settings) | | `activeItem` | string | `"item-1"` | The id of the currently selected item | | `activeSubItem` | string | `""` | The id of the currently selected sub-item | | `activePage` | string | `""` | The `page` property of the last-clicked item/sub-item — use in navigate handler | | `activeApp` | string | `""` | The `app` property of the last-clicked item/sub-item | -| `projectName` | string | `"production-db"` | Label in the contextual panel header | +| `projectName` | string | `"Main Menu"` | Dynamic/bindable label in the contextual panel header | | `projectStatus` | string | `"online"` | Status dot color: `"online"` (green), `"offline"` (gray), `"paused"` (amber) | | `theme` | string | `"dark"` | `"dark"` or `"light"` — controls the contextual panel only (rail is always dark) | | `railBg` | string | `""` | Custom rail background color (hex) | | `panelBg` | string | `""` | Custom contextual panel background color (hex) | | `accentColor` | string | `"#3ecf8e"` | Highlight color for active items, badges, and accents | -| `ShowHelp` | boolean | `true` | Shows the `?` help button for developers. Turn OFF before shipping to end users. | +| `padding` | number | `0` | Outer padding in pixels. Use a negative value to compensate for Retool wrapper inset. | +| `menuItems` | array | `[]` | Hidden visual editor output or directly bound array of nav item objects. Takes priority over `itemsJson` when non-empty. | +| `pageOptions` | array | `[]` | Optional page dropdown choices for the visual editor. Use strings or `{ label, value }` objects. | +| `appOptions` | array | `[]` | Optional app dropdown choices for the visual editor. Use strings or `{ label, value }` objects. | +| `menuJsonDraft` | string | `""` | Hidden JSON output for optional `saveMenu` handlers. | --- @@ -92,7 +96,7 @@ The result: screen-efficient nav with zero navigation guesswork. { id: string; // required, unique label: string; // required, display text - icon: string; // built-in key, SVG string, or image URL + icon: string; // built-in key, image URL, or data URL page?: string; // Retool page name to navigate to app?: string; // Retool app name to open badge?: string; // small pill text, e.g. "New" or "Beta" @@ -118,8 +122,7 @@ The result: screen-efficient nav with zero navigation guesswork. `table` • `code` • `database` • `lock` • `storage` • `function` • `realtime` • `clock` • `info` • `logs` • `chart` • `settings` -For custom icons, use: -- **Raw SVG:** `"..."` +For custom icons, use a hosted image URL or a data URL: - **URL:** `"https://example.com/icon.png"` (must be CORS-enabled) - **Data URL:** `"data:image/png;base64,iVBORw0KGgo..."` (always works) @@ -132,6 +135,7 @@ For custom icons, use: | `itemClick` | A top-level rail item was clicked | | `subItemClick` | A sub-item was clicked in the contextual panel | | `navigate` | An item or sub-item with a `page`/`app` was clicked — wire this to `Go to page` action | +| `saveMenu` | The visual editor's Save event button was clicked | --- @@ -162,6 +166,13 @@ For app navigation: Inspired by the Supabase dashboard sidebar. Built for the Retool custom component contest. +## Contest submission checklist + +- Exported component lives at `src/components/IconRailNav` +- `src/index.tsx` exports only `IconRailNav` +- Run `npm run typecheck` and `npm test` before submitting +- Add a final `cover.png` screenshot/GIF under 2MB for the gallery PR + ## License MIT diff --git a/components/icon-rail-nav/cover.png b/components/icon-rail-nav/cover.png index 467c867..a7a4a12 100644 Binary files a/components/icon-rail-nav/cover.png and b/components/icon-rail-nav/cover.png differ diff --git a/components/icon-rail-nav/metadata.json b/components/icon-rail-nav/metadata.json index 7886e73..923acc0 100644 --- a/components/icon-rail-nav/metadata.json +++ b/components/icon-rail-nav/metadata.json @@ -2,6 +2,6 @@ "id": "icon-rail-nav", "title": "Icon Rail Nav", "author": "@rgreen", - "shortDescription": "A screen-efficient navigation component with a compact icon rail, contextual sub-item panel, and slide-reveal labels on hover. Inspired by Supabase.", - "tags": ["Navigation", "Sidebar", "Layout", "UI"] + "shortDescription": "A space-saving navigation with a 48px icon rail, contextual sub-item panel, and slide-reveal labels on hover.", + "tags": ["Navigation", "UI Components", "Custom"] } diff --git a/components/icon-rail-nav/package.json b/components/icon-rail-nav/package.json new file mode 100644 index 0000000..fac80ea --- /dev/null +++ b/components/icon-rail-nav/package.json @@ -0,0 +1,64 @@ +{ + "name": "icon-rail-nav", + "version": "1.0.0", + "description": "A collapsible icon rail navigation sidebar for Retool.", + "license": "MIT", + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tryretool/custom-component-support": "^1.0.0", + "react-dom": "^18.2.0", + "react": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.0.0", + "vitest": "^4.0.17" + }, + "retoolCustomComponentLibraryConfig": { + "name": "RodGreenLibrary", + "label": "RodGreen Library", + "description": "My Retool Experiments", + "entryPoint": "src/index.tsx", + "outputPath": "dist", + "components": [ + { + "id": "IconRailNav", + "name": "Icon Rail Nav", + "description": "Icon rail + contextual panel nav. Hover reveals item labels. Click shows sub-items or description in the right panel.", + "model": { + "menuEditorVisibility": "hide", + "helpVisibility": "show", + "itemsJson": "[{\"id\":\"item-1\",\"label\":\"Item 1\",\"icon\":\"table\",\"description\":\"Description for Item 1.\"},{\"id\":\"item-2\",\"label\":\"Item 2\",\"icon\":\"code\",\"description\":\"Description for Item 2.\"},{\"id\":\"item-3\",\"label\":\"Item 3\",\"icon\":\"database\",\"subItems\":[{\"id\":\"item-3-a\",\"label\":\"Sub Item A\"},{\"id\":\"item-3-b\",\"label\":\"Sub Item B\"},{\"id\":\"item-3-c\",\"label\":\"Sub Item C\"}]},{\"id\":\"item-4\",\"label\":\"Item 4\",\"icon\":\"chart\",\"badge\":\"New\",\"description\":\"Description for Item 4.\"}]", + "projectName": "Main Menu", + "projectStatus": "online", + "activeItem": "item-1", + "activeSubItem": "", + "activePage": "", + "activeApp": "", + "menuJsonDraft": "", + "bottomItemsJson": "[{\"id\":\"settings\",\"label\":\"Settings\",\"icon\":\"settings\",\"description\":\"Configure your project settings.\"}]", + "theme": "dark", + "railBg": "", + "panelBg": "", + "accentColor": "#3ecf8e", + "menuItems": [], + "pageOptions": [], + "appOptions": [], + "padding": 0 + }, + "events": [ + { "label": "Item clicked", "name": "itemClick" }, + { "label": "Sub-item clicked", "name": "subItemClick" }, + { "label": "Navigate", "name": "navigate" }, + { "label": "Save menu", "name": "saveMenu" } + ] + } + ] + } +} diff --git a/components/icon-rail-nav/src/components/IconRailNav/IconRailNav.tsx b/components/icon-rail-nav/src/components/IconRailNav/IconRailNav.tsx new file mode 100644 index 0000000..f6da459 --- /dev/null +++ b/components/icon-rail-nav/src/components/IconRailNav/IconRailNav.tsx @@ -0,0 +1,1076 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Retool } from "@tryretool/custom-component-support"; +import { parseNavItems, toSerializableNavItems, type NavItem, type SubItem } from "./utils"; + +// ─── Types ──────────────────────────────────────────────────────────────────── +// Each item accepts: +// id — unique identifier (required) +// label — title shown next to the icon when expanded +// icon — built-in key ("table","code","database",...) | image URL | data URL +// page — Retool page name to navigate to on click (optional) +// app — Retool app name to open on click (optional) +// color — override icon/text color (optional) +// badge — small pill shown after the label (optional) +// section — divider label shown above the item (optional) +// subItems — array of child items shown in the right contextual panel + +const DEFAULT_ITEMS: NavItem[] = [ + { id: "item-1", label: "Item 1", icon: "table", description: "Description for Item 1." }, + { id: "item-2", label: "Item 2", icon: "code", description: "Description for Item 2." }, + { id: "item-3", label: "Item 3", icon: "database", subItems: [ + { id: "item-3-a", label: "Sub Item A" }, + { id: "item-3-b", label: "Sub Item B" }, + { id: "item-3-c", label: "Sub Item C" }, + ]}, + { id: "item-4", label: "Item 4", icon: "chart", badge: "New", description: "Description for Item 4." }, +]; +const DEFAULT_BOTTOM: NavItem[] = [ + { id: "settings", label: "Settings", icon: "settings", description: "Configure your project settings." }, +]; + +const BUILTIN: Record = { + table: , + code: , + database: , + lock: , + storage: , + function: , + realtime: , + clock: , + info: , + logs: , + chart: , + settings: , + arrow: , +}; + +function resolveIcon(icon: string | undefined | null): JSX.Element { + if (!icon || typeof icon !== "string") return BUILTIN["info"]; + if (BUILTIN[icon]) return BUILTIN[icon]; + const trimmed = icon.trim(); + if (trimmed.startsWith("http") || trimmed.startsWith("/") || trimmed.startsWith("data:")) { + return ( + { + // Fallback: replace with the default info icon if the image fails to load + const target = e.currentTarget as HTMLImageElement; + target.style.display = "none"; + }} + /> + ); + } + return BUILTIN["info"]; +} + +// Shared dimensions — keep icon rows and slide rows identical so they align +const RAIL_W = 48; +const SLIDE_W = 160; +const PANEL_W = 172; +const ROW_HEIGHT = 36; // consistent vertical spacing for icons and labels +const ROW_MARGIN_Y = 1; // matches between rail and slide rows +const VISIBILITY_MEMORY_KEY = "IconRailNav.visibility"; + +export const IconRailNav: React.FC = () => { + Retool.useComponentSettings({ defaultWidth: 220, defaultHeight: 420 }); + + const [menuEditorVisibility, setMenuEditorVisibility] = Retool.useStateEnumeration({ + name: "menuEditorVisibility", + label: "Edit menu items", + description: "Show or hide the visual editor for top-level menu items and sub-items.", + enumDefinition: ["show", "hide"], + enumLabels: { show: "Show", hide: "Hide" }, + initialValue: "hide", + inspector: "segmented", + }); + const [helpVisibility, setHelpVisibility] = Retool.useStateEnumeration({ + name: "helpVisibility", + label: "Show help", + description: "Show or hide the setup help drawer.", + enumDefinition: ["show", "hide"], + enumLabels: { show: "Show", hide: "Hide" }, + initialValue: "show", + inspector: "segmented", + }); + const [itemsJson, setItemsJson] = Retool.useStateString({ + name: "itemsJson", + label: "Menu Items JSON", + description: "JSON array of menu item objects. The visual editor updates this for copy/paste and advanced bindings.", + initialValue: "", + }); + const [menuItems, setMenuItems] = Retool.useStateArray({ + name: "menuItems", + label: "Menu items", + description: "Visual menu item data. Use the editor to update this, or bind an array of nav item objects.", + initialValue: [], + inspector: "hidden", + }); + const [pageOptions] = Retool.useStateArray({ + name: "pageOptions", + label: "Page options", + description: "Optional dropdown options for page fields. Use strings or { label, value } objects.", + initialValue: [], + }); + const [appOptions] = Retool.useStateArray({ + name: "appOptions", + label: "App options", + description: "Optional dropdown options for app fields. Use strings or { label, value } objects.", + initialValue: [], + }); + + const [projectName] = Retool.useStateString({ + name: "projectName", + label: "Project name", + description: "Header text shown at the top of the contextual panel. Bind this to dynamic Retool data if needed.", + initialValue: "Main Menu", + }); + const [projectStatus] = Retool.useStateString({ name: "projectStatus", initialValue: "online" }); + const [activeItem, setActiveItem] = Retool.useStateString({ name: "activeItem", initialValue: "item-1" }); + const [activeSubItem, setActiveSubItem] = Retool.useStateString({ name: "activeSubItem", initialValue: "" }); + const [activePage, setActivePage] = Retool.useStateString({ name: "activePage", initialValue: "" }); + const [activeApp, setActiveApp] = Retool.useStateString({ name: "activeApp", initialValue: "" }); + + const [, setMenuJsonDraft] = Retool.useStateString({ + name: "menuJsonDraft", + label: "Menu JSON draft", + description: "Latest menu editor output as JSON for optional save handlers.", + initialValue: "", + inspector: "hidden", + }); + const [bottomItemsJson] = Retool.useStateString({ name: "bottomItemsJson", initialValue: "" }); + + const [theme] = Retool.useStateString({ name: "theme", initialValue: "dark" }); + const [railBg] = Retool.useStateString({ name: "railBg", initialValue: "" }); + const [panelBg] = Retool.useStateString({ name: "panelBg", initialValue: "" }); + const [accentColor] = Retool.useStateString({ name: "accentColor", initialValue: "#3ecf8e" }); + const [padding] = Retool.useStateNumber({ + name: "padding", + label: "Padding", + description: "Outer padding in pixels. Use a negative value to compensate for Retool wrapper inset.", + initialValue: 0, + }); + + const onItemClick = Retool.useEventCallback({ name: "itemClick" }); + const onSubItemClick = Retool.useEventCallback({ name: "subItemClick" }); + const onNavigate = Retool.useEventCallback({ name: "navigate" }); + const onSaveMenu = Retool.useEventCallback({ name: "saveMenu" }); + + const [hovered, setHovered] = useState(false); + const [copied, setCopied] = useState(false); + const [expandedMenuItemKeys, setExpandedMenuItemKeys] = useState(["menu-item-0"]); + const previousVisibilityRef = useRef<{ menuEditorVisibility: string; helpVisibility: string } | null>(null); + const editorScrollRef = useRef(null); + + const isDark = theme !== "light"; + const accent = accentColor || "#3ecf8e"; + const showMenuEditor = menuEditorVisibility === "show"; + const showHelp = helpVisibility === "show"; + const displayProjectName = projectName?.trim() || "Main Menu"; + const outerPadding = Number.isFinite(padding) ? padding : 0; + + useEffect(() => { + let previous = previousVisibilityRef.current; + if (!previous) { + try { + const stored = window.localStorage.getItem(VISIBILITY_MEMORY_KEY); + previous = stored ? JSON.parse(stored) : null; + } catch (_) { + previous = null; + } + } + + if (menuEditorVisibility === "show" && previous?.menuEditorVisibility !== "show" && helpVisibility === "show") { + setHelpVisibility("hide"); + } else if (helpVisibility === "show" && previous?.helpVisibility !== "show" && menuEditorVisibility === "show") { + setMenuEditorVisibility("hide"); + } else if (menuEditorVisibility === "show" && helpVisibility === "show") { + setHelpVisibility("hide"); + } + + const current = { menuEditorVisibility, helpVisibility }; + previousVisibilityRef.current = current; + try { + window.localStorage.setItem(VISIBILITY_MEMORY_KEY, JSON.stringify(current)); + } catch (_) {} + }, [helpVisibility, menuEditorVisibility, setHelpVisibility, setMenuEditorVisibility]); + + // Rail (top layer) — always dark + const railBgColor = railBg || (isDark ? "#1c1c1c" : "#18181b"); + const railDivider = "rgba(255,255,255,0.08)"; + + // Slide panel (bottom layer) + const slideBg = isDark ? "#252525" : "#222222"; + const slideText = "rgba(255,255,255,0.65)"; + const slideActive = "#ffffff"; + const slideHoverBg= "rgba(255,255,255,0.07)"; + const slideActiveBg="rgba(255,255,255,0.12)"; + const slideDivider= "rgba(255,255,255,0.08)"; + + // Contextual right panel + const panelColor = panelBg || (isDark ? "#262626" : "#f8f8f7"); + const panelText = isDark ? "rgba(255,255,255,0.70)" : "rgba(0,0,0,0.60)"; + const panelActive = isDark ? "#ffffff" : "#000000"; + const panelHoverBg= isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)"; + const panelActiveBg=isDark ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.07)"; + const panelDivider= isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; + const mutedText = isDark ? "rgba(255,255,255,0.30)" : "rgba(0,0,0,0.35)"; + + const hasMenuItems = Array.isArray(menuItems) && menuItems.length > 0; + const { + items: navItems, + error: itemsParseError, + } = parseNavItems(hasMenuItems ? menuItems : itemsJson, DEFAULT_ITEMS); + + const { + items: bottomItems, + error: bottomParseError, + } = parseNavItems(bottomItemsJson, DEFAULT_BOTTOM); + + const allItems = [...navItems, ...bottomItems]; + const selectedItem = allItems.find(i => i.id === activeItem) ?? navItems[0]; + const dotColor = projectStatus === "offline" ? "#555" : projectStatus === "paused" ? "#f59e0b" : accent; + + const handleItemClick = (item: NavItem) => { + setActiveItem(item.id); + setActiveSubItem(""); + setActivePage(item.page || ""); + setActiveApp(item.app || ""); + // Defer event firing so model state updates flush first + requestAnimationFrame(() => { + onItemClick(); + if (item.page || item.app) onNavigate(); + }); + }; + + const handleSubItemClick = (sub: SubItem) => { + setActiveSubItem(sub.id); + setActivePage(sub.page || ""); + setActiveApp(sub.app || ""); + requestAnimationFrame(() => { + onSubItemClick(); + if (sub.page || sub.app) onNavigate(); + }); + }; + + const activateOnKeyboard = (event: React.KeyboardEvent, action: () => void) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + action(); + }; + + const commitNavItems = (items: NavItem[]) => { + const serializableItems = toSerializableNavItems(items); + const menuJson = JSON.stringify(serializableItems, null, 2); + setMenuItems(serializableItems as any); + setItemsJson(menuJson); + setMenuJsonDraft(menuJson); + }; + + const handleSaveMenu = () => { + const menuJson = JSON.stringify(toSerializableNavItems(navItems), null, 2); + setMenuJsonDraft(menuJson); + onSaveMenu(); + }; + + const updateItem = (index: number, updates: Partial) => { + commitNavItems(navItems.map((item, i) => i === index ? { ...item, ...updates } : item)); + }; + + const addItem = () => { + const nextNumber = navItems.length + 1; + const nextId = `item-${nextNumber}`; + commitNavItems([ + ...navItems, + { + id: nextId, + label: `Item ${nextNumber}`, + icon: "info", + description: `Description for Item ${nextNumber}.`, + }, + ]); + setExpandedMenuItemKeys([`menu-item-${navItems.length}`]); + }; + + const deleteItem = (index: number) => { + setExpandedMenuItemKeys((keys) => keys.filter((key) => key !== `menu-item-${index}`)); + commitNavItems(navItems.filter((_, i) => i !== index)); + }; + + const toggleMenuItemExpanded = (key: string) => { + setExpandedMenuItemKeys((keys) => + keys.includes(key) ? keys.filter((itemKey) => itemKey !== key) : [...keys, key], + ); + }; + + const moveItem = (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= navItems.length) return; + const next = [...navItems]; + [next[index], next[target]] = [next[target], next[index]]; + commitNavItems(next); + }; + + const updateSubItem = (itemIndex: number, subIndex: number, updates: Partial) => { + const item = navItems[itemIndex]; + const subItems = [...(item.subItems ?? [])]; + subItems[subIndex] = { ...subItems[subIndex], ...updates }; + updateItem(itemIndex, { subItems }); + }; + + const addSubItem = (itemIndex: number) => { + const item = navItems[itemIndex]; + const subItems = item.subItems ?? []; + const nextNumber = subItems.length + 1; + updateItem(itemIndex, { + subItems: [ + ...subItems, + { id: `${item.id}-sub-${nextNumber}`, label: `Sub Item ${nextNumber}` }, + ], + }); + }; + + const deleteSubItem = (itemIndex: number, subIndex: number) => { + const item = navItems[itemIndex]; + updateItem(itemIndex, { subItems: (item.subItems ?? []).filter((_, i) => i !== subIndex) }); + }; + + const textInput = ( + label: string, + value: string | undefined, + onChange: (value: string) => void, + placeholder = "", + ) => ( + + ); + + const normalizeOptions = (options: unknown[]) => options + .map((option) => { + if (typeof option === "string") return { label: option, value: option }; + if (option && typeof option === "object" && "value" in option) { + const value = String(option.value ?? ""); + const label = "label" in option && option.label != null ? String(option.label) : value; + return value ? { label, value } : null; + } + return null; + }) + .filter((option): option is { label: string; value: string } => option != null); + + const pageSelectOptions = normalizeOptions(pageOptions); + const appSelectOptions = normalizeOptions(appOptions); + + const routeInput = ( + label: string, + value: string | undefined, + onChange: (value: string) => void, + options: Array<{ label: string; value: string }>, + placeholder: string, + ) => { + if (options.length === 0) return textInput(label, value, onChange, placeholder); + + return ( + + ); + }; + + const css = ` + html, + body, + #root, + body > div { + width: 100% !important; + height: 100% !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; + } + * { box-sizing: border-box; } + .rail-icon { color: rgba(255,255,255,0.55); transition: color 0.12s, filter 0.12s; } + .rail-icon img { + /* Desaturate + dim image icons to match built-in SVG appearance */ + filter: grayscale(1) brightness(1.5) opacity(0.7); + transition: filter 0.12s; + } + .rail-item:hover .rail-icon { color: #ffffff; } + .rail-item:hover .rail-icon img { + /* Make image icons fully white-ish on hover */ + filter: grayscale(1) brightness(3) opacity(1); + } + .rail-item.active .rail-icon { color: ${accent}; } + .rail-item.active .rail-icon img { + /* Don't tint active icons — show them full color */ + filter: none; + } + .slide-item:hover { background: ${slideHoverBg} !important; color: ${slideActive} !important; } + .sub-item:hover { background: ${panelHoverBg} !important; color: ${panelActive} !important; } + .menu-editor-input, + .menu-editor-field input, + .menu-editor-field select { + width: 100%; + min-width: 0; + height: 28px; + border: 1px solid ${panelDivider}; + border-radius: 5px; + background: ${isDark ? "rgba(0,0,0,0.22)" : "rgba(255,255,255,0.8)"}; + color: ${panelActive}; + padding: 0 8px; + font: inherit; + outline: none; + } + .menu-editor-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + color: ${mutedText}; + font-size: 10px; + font-weight: 600; + } + .menu-editor-field input:focus, + .menu-editor-field select:focus { + border-color: ${accent}; + box-shadow: 0 0 0 2px ${accent}22; + } + .menu-editor-btn { + border: 1px solid ${panelDivider}; + border-radius: 5px; + background: ${isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)"}; + color: ${panelActive}; + min-height: 28px; + padding: 0 8px; + font: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; + } + .menu-editor-btn:hover { + background: ${panelHoverBg}; + } + .menu-editor-btn.primary { + background: ${accent}; + border-color: ${accent}; + color: #0b1712; + } + .menu-editor-btn.danger { + color: #f87171; + } + .menu-editor-btn:disabled { + cursor: default; + opacity: 0.35; + } + .menu-editor-toolbar { + flex-shrink: 0; + padding: 8px 10px; + border-bottom: 1px solid ${panelDivider}; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .menu-editor-toolbar-copy { + flex: 1 1 120px; + min-width: 92px; + } + .menu-editor-toolbar .menu-editor-btn { + flex: 1 1 78px; + min-width: 72px; + } + .menu-editor-card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-bottom: 1px solid ${panelDivider}; + flex-wrap: wrap; + } + .menu-editor-card-title { + flex: 1 1 96px; + min-width: 0; + } + .menu-editor-card-header .menu-editor-btn { + flex: 1 1 58px; + min-width: 56px; + } + .menu-editor-card-toggle { + flex: 1 1 70px !important; + min-width: 68px !important; + } + .menu-editor-fields { + padding: 8px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + gap: 8px; + } + .menu-editor-sub-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); + gap: 6px; + align-items: end; + } + .menu-editor-sub-row .menu-editor-btn { + min-width: 72px; + } + `; + + // Slide-panel row — matches rail-row height and margin exactly + const renderSlideItem = (item: NavItem) => { + const isActive = activeItem === item.id; + return ( + + {item.section && ( +
+ {item.section} +
+ )} +
handleItemClick(item)} + onKeyDown={(event) => activateOnKeyboard(event, () => handleItemClick(item))} + > + {item.label} + {item.badge && ( + + {item.badge} + + )} +
+
+ ); + }; + + // Rail row (icon only) — top layer + const renderRailItem = (item: NavItem) => { + const isActive = activeItem === item.id; + return ( + + {item.section && ( +
+ )} +
handleItemClick(item)} + onKeyDown={(event) => activateOnKeyboard(event, () => handleItemClick(item))} + > + {isActive && ( +
+ )} +
+ {resolveIcon(item.icon)} +
+
+ + ); + }; + + const renderPanel = () => { + if (!selectedItem) return null; + const hasSubItems = selectedItem.subItems && selectedItem.subItems.length > 0; + return ( +
+
+
{selectedItem.label}
+ {!hasSubItems && selectedItem.description && ( +
{selectedItem.description}
+ )} +
+
+ {hasSubItems ? selectedItem.subItems!.map(sub => { + const isActiveSub = activeSubItem === sub.id; + return ( +
handleSubItemClick(sub)} + onKeyDown={(event) => activateOnKeyboard(event, () => handleSubItemClick(sub))} + role="button" + tabIndex={0} + aria-current={isActiveSub ? "page" : undefined} + > + {isActiveSub &&
} + {sub.label} +
+ ); + }) : ( +
+
handleItemClick(selectedItem)} + onKeyDown={(event) => activateOnKeyboard(event, () => handleItemClick(selectedItem))} + role="button" + tabIndex={0} + > + {BUILTIN.arrow} Open {selectedItem.label} +
+
+ )} +
+
+ ); + }; + + // ── Starter JSON template ────────────────────────────────────────────── + const STARTER_JSON = JSON.stringify([ + { + id: "home", + label: "Home", + icon: "table", + page: "Dashboard" + }, + { + id: "users", + label: "Users", + icon: "lock", + subItems: [ + { id: "users-active", label: "Active", page: "ActiveUsers" }, + { id: "users-pending", label: "Pending", page: "PendingUsers" } + ] + }, + { + id: "reports", + label: "Reports", + icon: "chart", + badge: "New", + description: "View analytics and reports" + } + ], null, 2); + + const copyStarter = () => { + // navigator.clipboard often blocked in iframes — use a fallback textarea + try { + const ta = document.createElement("textarea"); + ta.value = STARTER_JSON; + ta.style.position = "fixed"; + ta.style.top = "-1000px"; + ta.style.left = "-1000px"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { document.execCommand("copy"); } catch (_) {} + document.body.removeChild(ta); + // Also try modern API as a bonus + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(STARTER_JSON).catch(() => {}); + } + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (_) {} + }; + + const copyMenuJson = () => { + const menuJson = JSON.stringify(toSerializableNavItems(navItems), null, 2); + try { + const ta = document.createElement("textarea"); + ta.value = menuJson; + ta.style.position = "fixed"; + ta.style.top = "-1000px"; + ta.style.left = "-1000px"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { document.execCommand("copy"); } catch (_) {} + document.body.removeChild(ta); + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(menuJson).catch(() => {}); + } + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (_) {} + }; + + const handleEditorWheel = (event: React.WheelEvent) => { + const target = editorScrollRef.current; + if (!target) return; + + target.scrollTop += event.deltaY; + event.preventDefault(); + event.stopPropagation(); + }; + + const renderMenuEditor = () => ( +
+
+
+
+
+ Menu Items +
+
+ Visual editor writes to menuItems and itemsJson +
+
+ + + +
+ +
+ {navItems.map((item, index) => { + const cardKey = `menu-item-${index}`; + const isExpanded = expandedMenuItemKeys.includes(cardKey); + + return ( +
+
+
+ {resolveIcon(item.icon)} +
+ + + + + +
+ + {isExpanded && ( + <> +
+ {textInput("Label", item.label, (value) => updateItem(index, { label: value }), "Home")} + {textInput("ID", item.id, (value) => updateItem(index, { id: value }), "home")} + + {routeInput("Page", item.page, (value) => updateItem(index, { page: value || undefined }), pageSelectOptions, "Dashboard")} + {routeInput("App", item.app, (value) => updateItem(index, { app: value || undefined }), appSelectOptions, "Admin")} + {textInput("Badge", item.badge, (value) => updateItem(index, { badge: value || undefined }), "New")} + {textInput("Section", item.section, (value) => updateItem(index, { section: value || undefined }), "Admin")} + {textInput("Description", item.description, (value) => updateItem(index, { description: value || undefined }), "Shown in panel")} +
+ +
+
+
Sub-items
+ +
+ {(item.subItems ?? []).length === 0 ? ( +
No sub-items
+ ) : ( +
+ {(item.subItems ?? []).map((sub, subIndex) => ( +
+ {textInput("Label", sub.label, (value) => updateSubItem(index, subIndex, { label: value }), "Active")} + {textInput("ID", sub.id, (value) => updateSubItem(index, subIndex, { id: value }), `${item.id}-active`)} + {routeInput("Page", sub.page, (value) => updateSubItem(index, subIndex, { page: value || undefined }), pageSelectOptions, "ActiveUsers")} + {routeInput("App", sub.app, (value) => updateSubItem(index, subIndex, { app: value || undefined }), appSelectOptions, "Admin")} + +
+ ))} +
+ )} +
+ + )} +
+ ); + })} +
+
+ ); + + return ( + <> + +
+ {showMenuEditor ? renderMenuEditor() : ( + <> + + {/* Parse error banner */} + {(itemsParseError || bottomParseError) && ( +
+
+ {itemsParseError ? "Items JSON error:" : "Bottom Items JSON error:"} +
+
+ {itemsParseError || bottomParseError} +
+
+ )} + + {/* Main nav row */} +
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* BOTTOM LAYER — label slide panel, sits behind rail */} +
+ {/* Main labels — identical height/margin to rail items */} +
+ {navItems.map(i => renderSlideItem(i))} +
+
+ {bottomItems.map(i => renderSlideItem(i))} +
+
+ + {/* TOP LAYER — icon rail, always on top */} +
+
+ {navItems.map(i => renderRailItem(i))} +
+
+ {bottomItems.map(i => renderRailItem(i))} +
+
+
+ + {/* ── Contextual Panel ── */} +
+
+
+ {displayProjectName} +
+
{renderPanel()}
+ + {/* Help drawer — controlled from the inspector. */} + {showHelp && ( +
+
+
+ Items JSON Help +
+
+ Paste JSON into the Menu Items JSON field in the model panel. +
+
+ +
+ {/* Schema */} +
Item fields
+
+
id required, unique
+
label required, display text
+
icon built-in key or image URL
+
page Retool page name
+
app Retool app name
+
badge pill text, e.g. "New"
+
color hex color override
+
description shown in panel
+
subItems nested array
+
+ + {/* Icons */} +
Built-in icons
+
+ {Object.keys(BUILTIN).filter(k => k !== "arrow").map(key => ( +
+ {BUILTIN[key]} + {key} +
+ ))} +
+ + {/* Starter template */} +
+ Starter template + +
+
+                  {STARTER_JSON}
+                
+
+ + {/* Close button */} +
+ +
+
+ )} +
+ +
+ + )} +
+ + ); +}; diff --git a/components/icon-rail-nav/src/components/IconRailNav/README.md b/components/icon-rail-nav/src/components/IconRailNav/README.md new file mode 100644 index 0000000..9fca68d --- /dev/null +++ b/components/icon-rail-nav/src/components/IconRailNav/README.md @@ -0,0 +1,89 @@ +# Icon Rail Nav + +A compact Retool navigation component with an always-visible icon rail, a contextual detail panel, and hover-revealed labels. + +## Setup + +1. Add `IconRailNav` to your Retool app. +2. Set `Menu Items JSON` to a JSON array of nav items. +3. Add a `navigate` event handler. +4. Configure the handler to go to `{{ iconRailNav1.model.activePage }}`. + +## Starter JSON + +```json +[ + { + "id": "home", + "label": "Home", + "icon": "table", + "page": "Dashboard" + }, + { + "id": "users", + "label": "Users", + "icon": "lock", + "subItems": [ + { "id": "users-active", "label": "Active", "page": "ActiveUsers" }, + { "id": "users-pending", "label": "Pending", "page": "PendingUsers" } + ] + }, + { + "id": "reports", + "label": "Reports", + "icon": "chart", + "badge": "New", + "description": "View analytics and reports" + } +] +``` + +## Model Properties + +| Property | Type | Description | +|---|---|---| +| `menuEditorVisibility` | `"show"`/`"hide"` | Shows or hides the visual editor for top-level menu items and sub-items | +| `helpVisibility` | `"show"`/`"hide"` | Shows or hides the setup help drawer | +| `itemsJson` | string | Menu Items JSON: JSON array of top-level nav items | +| `bottomItemsJson` | string | JSON array of items pinned to the bottom | +| `activeItem` | string | Last selected top-level item id | +| `activeSubItem` | string | Last selected sub-item id | +| `activePage` | string | Page target from the selected item/sub-item | +| `activeApp` | string | App target from the selected item/sub-item | +| `projectName` | string | Dynamic/bindable header label in the contextual panel | +| `projectStatus` | string | `online`, `offline`, or `paused` | +| `theme` | string | `dark` or `light` | +| `accentColor` | string | Active item and badge color | +| `padding` | number | Outer padding in pixels. Negative values can compensate for Retool wrapper inset. | +| `menuItems` | array | Hidden visual editor output or directly bound menu item array | +| `pageOptions` | array | Optional page dropdown choices for the visual editor | +| `appOptions` | array | Optional app dropdown choices for the visual editor | +| `menuJsonDraft` | string | Hidden JSON output for optional save handlers | + +## Item Schema + +```ts +type NavItem = { + id: string; + label: string; + icon: string; + page?: string; + app?: string; + badge?: string; + color?: string; + section?: string; + description?: string; + subItems?: SubItem[]; +}; + +type SubItem = { + id: string; + label: string; + page?: string; + app?: string; +}; +``` + +Built-in icon keys: `table`, `code`, `database`, `lock`, `storage`, `function`, `realtime`, `clock`, `info`, `logs`, `chart`, `settings`. + +Custom icons can be image URLs or data URLs. diff --git a/components/icon-rail-nav/src/components/IconRailNav/index.tsx b/components/icon-rail-nav/src/components/IconRailNav/index.tsx new file mode 100644 index 0000000..a8db06f --- /dev/null +++ b/components/icon-rail-nav/src/components/IconRailNav/index.tsx @@ -0,0 +1,2 @@ +export { IconRailNav } from "./IconRailNav"; +export type { NavItem, SubItem } from "./utils"; diff --git a/components/icon-rail-nav/src/components/IconRailNav/utils.test.ts b/components/icon-rail-nav/src/components/IconRailNav/utils.test.ts new file mode 100644 index 0000000..bbbb4e9 --- /dev/null +++ b/components/icon-rail-nav/src/components/IconRailNav/utils.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { parseNavItems, validateNavItems, type NavItem } from "./utils"; + +const defaults: NavItem[] = [ + { id: "default", label: "Default", icon: "info" }, +]; + +describe("parseNavItems", () => { + it("uses defaults for blank input", () => { + expect(parseNavItems("", defaults)).toEqual({ + items: defaults, + error: null, + usingDefaults: true, + }); + }); + + it("parses valid JSON strings", () => { + const result = parseNavItems( + '[{"id":"home","label":"Home","icon":"table","page":"Dashboard"}]', + defaults, + ); + + expect(result.error).toBeNull(); + expect(result.usingDefaults).toBe(false); + expect(result.items).toEqual([ + { id: "home", label: "Home", icon: "table", page: "Dashboard" }, + ]); + }); + + it("accepts arrays that Retool passes through directly", () => { + const raw = [{ id: "reports", label: "Reports", icon: "chart" }]; + + expect(parseNavItems(raw, defaults)).toEqual({ + items: raw, + error: null, + usingDefaults: false, + }); + }); + + it("returns defaults and an error for malformed JSON", () => { + const result = parseNavItems("[", defaults); + + expect(result.items).toBe(defaults); + expect(result.error).toBeTruthy(); + expect(result.usingDefaults).toBe(true); + }); + + it("returns defaults and an error for invalid item shape", () => { + const result = parseNavItems('[{"id":"home"}]', defaults); + + expect(result.items).toBe(defaults); + expect(result.error).toBe('Item 0 is missing a string "label"'); + }); +}); + +describe("validateNavItems", () => { + it("validates nested sub-items", () => { + const error = validateNavItems([ + { + id: "users", + label: "Users", + icon: "lock", + subItems: [{ id: "users-active" }], + }, + ]); + + expect(error).toBe('Item 0 subItem 0 is missing a string "label"'); + }); +}); diff --git a/components/icon-rail-nav/src/components/IconRailNav/utils.ts b/components/icon-rail-nav/src/components/IconRailNav/utils.ts new file mode 100644 index 0000000..b7ecf4f --- /dev/null +++ b/components/icon-rail-nav/src/components/IconRailNav/utils.ts @@ -0,0 +1,122 @@ +export type SubItem = { + id: string; + label: string; + page?: string; + app?: string; +}; + +export type NavItem = { + id: string; + label: string; + description?: string; + icon: string; + color?: string; + badge?: string; + page?: string; + app?: string; + section?: string; + subItems?: SubItem[]; +}; + +type ParseResult = { + items: NavItem[]; + error: string | null; + usingDefaults: boolean; +}; + +function isBlankInput(raw: unknown): boolean { + return raw == null || (typeof raw === "string" && raw.trim() === ""); +} + +function resolveJsonInput(raw: unknown): unknown { + if (raw == null) return null; + if (Array.isArray(raw)) return raw; + if (typeof raw === "object") return raw; + if (typeof raw !== "string") return null; + + const trimmed = raw.trim(); + if (!trimmed) return null; + + return JSON.parse(trimmed); +} + +function validateSubItems(subItems: unknown, itemIndex: number): string | null { + if (subItems == null) return null; + if (!Array.isArray(subItems)) return `Item ${itemIndex} has non-array "subItems"`; + + for (let i = 0; i < subItems.length; i++) { + const sub = subItems[i]; + if (!sub || typeof sub !== "object") return `Item ${itemIndex} subItem ${i} is not an object`; + if (!("id" in sub) || typeof sub.id !== "string") return `Item ${itemIndex} subItem ${i} is missing a string "id"`; + if (!("label" in sub) || typeof sub.label !== "string") return `Item ${itemIndex} subItem ${i} is missing a string "label"`; + } + + return null; +} + +export function validateNavItems(items: unknown): string | null { + if (!Array.isArray(items)) return "Expected an array of items"; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item || typeof item !== "object") return `Item ${i} is not an object`; + if (!("id" in item) || typeof item.id !== "string") return `Item ${i} is missing a string "id"`; + if (!("label" in item) || typeof item.label !== "string") return `Item ${i} is missing a string "label"`; + if ("icon" in item && item.icon != null && typeof item.icon !== "string") return `Item ${i} has non-string "icon"`; + + const subItemError = validateSubItems("subItems" in item ? item.subItems : undefined, i); + if (subItemError) return subItemError; + } + + return null; +} + +export function parseNavItems(raw: unknown, defaults: NavItem[]): ParseResult { + const usingDefaults = isBlankInput(raw); + + try { + const parsed = resolveJsonInput(raw); + if (parsed === null) return { items: defaults, error: null, usingDefaults: true }; + + const validationError = validateNavItems(parsed); + if (validationError) throw new Error(validationError); + + return { items: parsed as NavItem[], error: null, usingDefaults }; + } catch (error) { + return { + items: defaults, + error: error instanceof Error ? error.message : "Invalid JSON", + usingDefaults: true, + }; + } +} + +export function toSerializableNavItems(items: NavItem[]): NavItem[] { + return items.map((item) => { + const next: NavItem = { + id: item.id, + label: item.label, + icon: item.icon || "info", + }; + + if (item.description) next.description = item.description; + if (item.color) next.color = item.color; + if (item.badge) next.badge = item.badge; + if (item.page) next.page = item.page; + if (item.app) next.app = item.app; + if (item.section) next.section = item.section; + if (item.subItems && item.subItems.length > 0) { + next.subItems = item.subItems.map((sub) => { + const nextSub: SubItem = { + id: sub.id, + label: sub.label, + }; + if (sub.page) nextSub.page = sub.page; + if (sub.app) nextSub.app = sub.app; + return nextSub; + }); + } + + return next; + }); +} diff --git a/components/icon-rail-nav/src/index.tsx b/components/icon-rail-nav/src/index.tsx index 900f302..355f7a1 100644 --- a/components/icon-rail-nav/src/index.tsx +++ b/components/icon-rail-nav/src/index.tsx @@ -1,652 +1 @@ -import React, { useState } from "react"; -import { Retool } from "@tryretool/custom-component-support"; - -// ─── Types ──────────────────────────────────────────────────────────────────── -// Each item accepts: -// id — unique identifier (required) -// label — title shown next to the icon when expanded -// icon — built-in key ("table","code","database",...) | ... string | image URL -// page — Retool page name to navigate to on click (optional) -// app — Retool app name to open on click (optional) -// color — override icon/text color (optional) -// badge — small pill shown after the label (optional) -// section — divider label shown above the item (optional) -// subItems — array of child items shown in the right contextual panel - -type SubItem = { id: string; label: string; page?: string; app?: string; }; -type NavItem = { - id: string; label: string; description?: string; icon: string; - color?: string; badge?: string; page?: string; app?: string; - section?: string; subItems?: SubItem[]; -}; - -const DEFAULT_ITEMS: NavItem[] = [ - { id: "item-1", label: "Item 1", icon: "table", description: "Description for Item 1." }, - { id: "item-2", label: "Item 2", icon: "code", description: "Description for Item 2." }, - { id: "item-3", label: "Item 3", icon: "database", subItems: [ - { id: "item-3-a", label: "Sub Item A" }, - { id: "item-3-b", label: "Sub Item B" }, - { id: "item-3-c", label: "Sub Item C" }, - ]}, -]; -const DEFAULT_BOTTOM: NavItem[] = [ - { id: "settings", label: "Settings", icon: "settings", description: "Configure your project settings." }, -]; - -const BUILTIN: Record = { - table: , - code: , - database: , - lock: , - storage: , - function: , - realtime: , - clock: , - info: , - logs: , - chart: , - settings: , - arrow: , -}; - -function resolveIcon(icon: string | undefined | null): JSX.Element { - if (!icon || typeof icon !== "string") return BUILTIN["info"]; - if (BUILTIN[icon]) return BUILTIN[icon]; - const trimmed = icon.trim(); - if (trimmed.startsWith("; - if (trimmed.startsWith("http") || trimmed.startsWith("/") || trimmed.startsWith("data:")) { - return ( - { - // Fallback: replace with the default info icon if the image fails to load - const target = e.currentTarget as HTMLImageElement; - target.style.display = "none"; - }} - /> - ); - } - return BUILTIN["info"]; -} - -// Shared dimensions — keep icon rows and slide rows identical so they align -const RAIL_W = 48; -const SLIDE_W = 160; -const PANEL_W = 172; -const TOTAL_W = RAIL_W + PANEL_W; -const ROW_HEIGHT = 36; // consistent vertical spacing for icons and labels -const ROW_MARGIN_Y = 1; // matches between rail and slide rows - -export const IconRailNav: React.FC = () => { - const [projectName] = Retool.useStateString({ name: "projectName", initialValue: "production-db" }); - const [projectStatus] = Retool.useStateString({ name: "projectStatus", initialValue: "online" }); - const [activeItem, setActiveItem] = Retool.useStateString({ name: "activeItem", initialValue: "item-1" }); - const [activeSubItem, setActiveSubItem] = Retool.useStateString({ name: "activeSubItem", initialValue: "" }); - const [activePage, setActivePage] = Retool.useStateString({ name: "activePage", initialValue: "" }); - const [activeApp, setActiveApp] = Retool.useStateString({ name: "activeApp", initialValue: "" }); - - const [itemsJson] = Retool.useStateString({ name: "ItemsJSON", initialValue: "" }); - const [bottomItemsJson] = Retool.useStateString({ name: "BottomItemsJSON", initialValue: "" }); - - const [theme] = Retool.useStateString({ name: "theme", initialValue: "dark" }); - const [railBg] = Retool.useStateString({ name: "railBg", initialValue: "" }); - const [panelBg] = Retool.useStateString({ name: "panelBg", initialValue: "" }); - const [accentColor] = Retool.useStateString({ name: "accentColor", initialValue: "#3ecf8e" }); - - // ShowHelp: developer-only toggle. Turn ON during setup, OFF before shipping. - const [showHelp] = Retool.useStateBoolean({ - name: "ShowHelp", - initialValue: true, - inspector: "checkbox", - label: "Show help panel (hide before shipping)", - }); - - const onItemClick = Retool.useEventCallback({ name: "itemClick" }); - const onSubItemClick = Retool.useEventCallback({ name: "subItemClick" }); - const onNavigate = Retool.useEventCallback({ name: "navigate" }); - - const [hovered, setHovered] = useState(false); - const [helpOpen, setHelpOpen] = useState(false); - const [copied, setCopied] = useState(false); - - const isDark = theme !== "light"; - const accent = accentColor || "#3ecf8e"; - - // Rail (top layer) — always dark - const railBgColor = railBg || (isDark ? "#1c1c1c" : "#18181b"); - const railDivider = "rgba(255,255,255,0.08)"; - - // Slide panel (bottom layer) - const slideBg = isDark ? "#252525" : "#222222"; - const slideText = "rgba(255,255,255,0.65)"; - const slideActive = "#ffffff"; - const slideHoverBg= "rgba(255,255,255,0.07)"; - const slideActiveBg="rgba(255,255,255,0.12)"; - const slideDivider= "rgba(255,255,255,0.08)"; - const slideMuted = "rgba(255,255,255,0.28)"; - - // Contextual right panel - const panelColor = panelBg || (isDark ? "#262626" : "#f8f8f7"); - const panelText = isDark ? "rgba(255,255,255,0.70)" : "rgba(0,0,0,0.60)"; - const panelActive = isDark ? "#ffffff" : "#000000"; - const panelHoverBg= isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)"; - const panelActiveBg=isDark ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.07)"; - const panelDivider= isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; - const mutedText = isDark ? "rgba(255,255,255,0.30)" : "rgba(0,0,0,0.35)"; - - let navItems = DEFAULT_ITEMS; - let bottomItems = DEFAULT_BOTTOM; - let itemsParseError: string | null = null; - let bottomParseError: string | null = null; - - const validateItems = (arr: any[]): string | null => { - for (let i = 0; i < arr.length; i++) { - const it = arr[i]; - if (!it || typeof it !== "object") return `Item ${i} is not an object`; - if (!it.id || typeof it.id !== "string") return `Item ${i} is missing a string "id"`; - if (!it.label || typeof it.label !== "string") return `Item ${i} is missing a string "label"`; - } - return null; - }; - - // Coerce to a usable value: accept string, array, or nullish - const resolveJsonInput = (raw: any): any => { - if (raw == null) return null; - if (Array.isArray(raw)) return raw; // already an array, use directly - if (typeof raw === "object") return raw; // some object, let parser decide - if (typeof raw !== "string") return null; - const trimmed = raw.trim(); - if (!trimmed) return null; - try { return JSON.parse(trimmed); } catch (_) { throw _; } - }; - - try { - const parsed = resolveJsonInput(itemsJson); - if (parsed !== null) { - if (!Array.isArray(parsed)) throw new Error("Expected an array of items"); - const validationError = validateItems(parsed); - if (validationError) throw new Error(validationError); - navItems = parsed; - } - } catch (e: any) { - itemsParseError = e?.message || "Invalid JSON"; - } - - try { - const parsed = resolveJsonInput(bottomItemsJson); - if (parsed !== null) { - if (!Array.isArray(parsed)) throw new Error("Expected an array of items"); - const validationError = validateItems(parsed); - if (validationError) throw new Error(validationError); - bottomItems = parsed; - } - } catch (e: any) { - bottomParseError = e?.message || "Invalid JSON"; - } - - const usingDefaults = !itemsJson || typeof itemsJson !== "string" || !itemsJson.trim(); - - const allItems = [...navItems, ...bottomItems]; - const selectedItem = allItems.find(i => i.id === activeItem) ?? navItems[0]; - const dotColor = projectStatus === "offline" ? "#555" : projectStatus === "paused" ? "#f59e0b" : accent; - - const handleItemClick = (item: NavItem) => { - setActiveItem(item.id); - setActiveSubItem(""); - setActivePage(item.page || ""); - setActiveApp(item.app || ""); - // Defer event firing so model state updates flush first - requestAnimationFrame(() => { - onItemClick(); - if (item.page || item.app) onNavigate(); - }); - }; - - const handleSubItemClick = (sub: SubItem) => { - setActiveSubItem(sub.id); - setActivePage(sub.page || ""); - setActiveApp(sub.app || ""); - requestAnimationFrame(() => { - onSubItemClick(); - if (sub.page || sub.app) onNavigate(); - }); - }; - - const css = ` - * { box-sizing: border-box; } - .rail-icon { color: rgba(255,255,255,0.55); transition: color 0.12s, filter 0.12s; } - .rail-icon img { - /* Desaturate + dim image icons to match built-in SVG appearance */ - filter: grayscale(1) brightness(1.5) opacity(0.7); - transition: filter 0.12s; - } - .rail-item:hover .rail-icon { color: #ffffff; } - .rail-item:hover .rail-icon img { - /* Make image icons fully white-ish on hover */ - filter: grayscale(1) brightness(3) opacity(1); - } - .rail-item.active .rail-icon { color: ${accent}; } - .rail-item.active .rail-icon img { - /* Don't tint active icons — show them full color */ - filter: none; - } - .slide-item:hover { background: ${slideHoverBg} !important; color: ${slideActive} !important; } - .sub-item:hover { background: ${panelHoverBg} !important; color: ${panelActive} !important; } - `; - - // Slide-panel row — matches rail-row height and margin exactly - const renderSlideItem = (item: NavItem, pinned = false) => { - const isActive = activeItem === item.id; - return ( -
handleItemClick(item)} - > - {item.label} - {item.badge && ( - - {item.badge} - - )} -
- ); - }; - - // Rail row (icon only) — top layer - const renderRailItem = (item: NavItem) => { - const isActive = activeItem === item.id; - return ( -
handleItemClick(item)} - > - {isActive && ( -
- )} -
- {resolveIcon(item.icon)} -
-
- ); - }; - - const renderPanel = () => { - if (!selectedItem) return null; - const hasSubItems = selectedItem.subItems && selectedItem.subItems.length > 0; - return ( -
-
-
{selectedItem.label}
- {!hasSubItems && selectedItem.description && ( -
{selectedItem.description}
- )} -
-
- {hasSubItems ? selectedItem.subItems!.map(sub => { - const isActiveSub = activeSubItem === sub.id; - return ( -
handleSubItemClick(sub)} - > - {isActiveSub &&
} - {sub.label} -
- ); - }) : ( -
-
handleItemClick(selectedItem)} - > - {BUILTIN.arrow} Open {selectedItem.label} -
-
- )} -
-
- ); - }; - - // ── Starter JSON template ────────────────────────────────────────────── - const STARTER_JSON = JSON.stringify([ - { - id: "home", - label: "Home", - icon: "table", - page: "Dashboard" - }, - { - id: "users", - label: "Users", - icon: "lock", - subItems: [ - { id: "users-active", label: "Active", page: "ActiveUsers" }, - { id: "users-pending", label: "Pending", page: "PendingUsers" } - ] - }, - { - id: "reports", - label: "Reports", - icon: "chart", - badge: "New", - description: "View analytics and reports" - } - ], null, 2); - - const copyStarter = () => { - // navigator.clipboard often blocked in iframes — use a fallback textarea - try { - const ta = document.createElement("textarea"); - ta.value = STARTER_JSON; - ta.style.position = "fixed"; - ta.style.top = "-1000px"; - ta.style.left = "-1000px"; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - try { document.execCommand("copy"); } catch (_) {} - document.body.removeChild(ta); - // Also try modern API as a bonus - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(STARTER_JSON).catch(() => {}); - } - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch (_) {} - }; - - return ( - <> - -
- - {/* Parse error banner */} - {(itemsParseError || bottomParseError) && ( -
-
- {itemsParseError ? "Items JSON error:" : "Bottom Items JSON error:"} -
-
- {itemsParseError || bottomParseError} -
- {showHelp && ( - - )} -
- )} - - {/* Main nav row */} -
-
setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {/* BOTTOM LAYER — label slide panel, sits behind rail */} -
- {/* Main labels — identical height/margin to rail items */} -
- {navItems.map(i => renderSlideItem(i))} -
-
- {bottomItems.map(i => renderSlideItem(i, true))} -
-
- - {/* TOP LAYER — icon rail, always on top */} -
-
- {navItems.map(i => renderRailItem(i))} -
-
- {bottomItems.map(i => renderRailItem(i))} -
-
-
- - {/* ── Contextual Panel ── */} -
-
-
- {projectName} - {showHelp && ( - - )} -
-
{renderPanel()}
- - {/* Help drawer — slides over the panel when ? is clicked. Visible only when ShowHelp is true. */} - {showHelp && helpOpen && ( -
-
-
- Items JSON Help -
-
- Paste JSON into the ItemsJSON field in the model panel. -
-
- -
- {/* Schema */} -
Item fields
-
-
id required, unique
-
label required, display text
-
icon built-in key, SVG, or URL
-
page Retool page name
-
app Retool app name
-
badge pill text, e.g. "New"
-
color hex color override
-
description shown in panel
-
subItems nested array
-
- - {/* Icons */} -
Built-in icons
-
- {Object.keys(BUILTIN).filter(k => k !== "arrow").map(key => ( -
- {BUILTIN[key]} - {key} -
- ))} -
- - {/* Starter template */} -
- Starter template - -
-
-                  {STARTER_JSON}
-                
-
- - {/* Close button */} -
- -
-
- )} -
- -
-
- - ); -}; +export { IconRailNav } from "./components/IconRailNav"; \ No newline at end of file diff --git a/components/icon-rail-nav/tsconfig.json b/components/icon-rail-nav/tsconfig.json new file mode 100644 index 0000000..55be51b --- /dev/null +++ b/components/icon-rail-nav/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["**/*.tsx", "**/*.ts"] +}