Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/console/src/components/layouts/dashboard/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { GLOBAL_BANNER_HEIGHT_VAR, TOP_BANNER_HEIGHT_VAR } from '@/constants/lay
import { DashboardContentOffsetProvider } from '@/providers/DashboardContentOffsetContext'
import { DocsHelpTopicProvider } from '@/components/shared/docs-help/docs-help-context'
import { DocsHelpTab } from '@/components/shared/docs-help/docs-help-tab'
import { PINNED_PANEL_WIDTH_VAR } from '@repo/ui/info-slide-out'

export interface DashboardLayoutProps {
children?: React.ReactNode
Expand Down Expand Up @@ -131,10 +132,10 @@ export function DashboardLayout({ children, error }: DashboardLayoutProps) {
isOrganizationSelected={isOrganizationSelected}
/>
<div
className="flex flex-col overflow-hidden transition-[margin-left] duration-200"
className="flex flex-col overflow-hidden transition-[margin-left,margin-right] duration-200"
style={{
marginLeft: contentMarginLeft,
marginRight: '8px',
marginRight: `calc(8px + var(${PINNED_PANEL_WIDTH_VAR}, 0px))`,
marginTop: `var(${TOP_BANNER_HEIGHT_VAR}, 0px)`,
height: `calc(100vh - var(${TOP_BANNER_HEIGHT_VAR}, 0px))`,
}}
Expand Down

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storage logic should be moved to /src/lib/storage . exampels are there already

Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ type DocsHelpDrawerState = {
open: boolean
setOpen: (open: boolean) => void
modal: boolean
pinned: boolean
setPinned: (pinned: boolean) => void
// a one-off topic pushed by an in-page action, cleared when the drawer closes
ephemeralTopic: DocsHelpTopic | null
setEphemeralTopic: (topic: DocsHelpTopic | null) => void
Expand All @@ -20,24 +22,54 @@ const DocsHelpDrawerContext = createContext<DocsHelpDrawerState>({
open: false,
setOpen: () => {},
modal: false,
pinned: false,
setPinned: () => {},
ephemeralTopic: null,
setEphemeralTopic: () => {},
})

const PINNED_STORAGE_KEY = 'docs-help-pinned'

const modalLayerIsOpen = () => typeof document !== 'undefined' && (document.body.style.pointerEvents === 'none' || document.body.hasAttribute('data-scroll-locked'))

export const DocsHelpTopicProvider = ({ children }: { children: ReactNode }) => {
const [topic, setTopic] = useState<DocsHelpTopic | null>(null)
const [open, setOpen] = useState(false)
const [modal, setModal] = useState(false)
const [pinned, setPinned] = useState(false)
const [ephemeralTopic, setEphemeralTopic] = useState<DocsHelpTopic | null>(null)

const openDrawer = useCallback((next: boolean) => {
if (next) setModal(modalLayerIsOpen())
setOpen(next)
const pinPanel = useCallback((next: boolean) => {
setPinned(next)
if (next) setModal(false)
try {
localStorage.setItem(PINNED_STORAGE_KEY, String(next))
} catch {
return
}
}, [])

const drawer = useMemo(() => ({ open, setOpen: openDrawer, modal, ephemeralTopic, setEphemeralTopic }), [open, openDrawer, modal, ephemeralTopic])
useEffect(() => {
try {
if (localStorage.getItem(PINNED_STORAGE_KEY) !== 'true') return
} catch {
return
}
setPinned(true)
setModal(false)
setOpen(true)
}, [])

const openDrawer = useCallback(
(next: boolean) => {
if (!next && pinned) pinPanel(false)
if (next) setModal(pinned ? false : modalLayerIsOpen())
setOpen(next)
},
[pinned, pinPanel],
)

const drawer = useMemo(() => ({ open, setOpen: openDrawer, modal, pinned, setPinned: pinPanel, ephemeralTopic, setEphemeralTopic }), [open, openDrawer, modal, pinned, pinPanel, ephemeralTopic])

return (
<DocsHelpTopicSetterContext value={setTopic}>
Expand All @@ -52,8 +84,8 @@ export const useDocsHelpTopic = () => use(DocsHelpTopicContext)

// open/close the global docs drawer from anywhere, e.g. an in-page docs link
export function useDocsHelpDrawer() {
const { open, setOpen, modal } = use(DocsHelpDrawerContext)
return { open, setOpen, modal }
const { open, setOpen, modal, pinned, setPinned } = use(DocsHelpDrawerContext)
return { open, setOpen, modal, pinned, setPinned }
}

// open the docs drawer on a specific topic (one-off, cleared on close)
Expand Down
26 changes: 22 additions & 4 deletions apps/console/src/components/shared/docs-help/docs-help-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { docsHelpEnabled } from '@repo/dally/ai'
import { DocsHelpContent } from './docs-help-content'
import { docsHelpQuery } from './docs-help-query'
import { useDocsHelpDrawer, useDocsHelpEphemeralTopic, useDocsHelpTopic, type DocsHelpTopic } from './docs-help-context'
import type { DocsSection } from '@/types/docs-help'

const INTROS = {
dashboard: 'This is your Compliance Home dashboard. Use it to get a snapshot of your compliance posture and quickly access your most important work.',
Expand Down Expand Up @@ -105,7 +106,7 @@ const DocsTabButton = ({ onClick, label, className }: { onClick: () => void; lab

export const DocsHelpTab = () => {
// open state lives in context so in-page links can open the drawer too
const { open, setOpen, modal } = useDocsHelpDrawer()
const { open, setOpen, modal, pinned, setPinned } = useDocsHelpDrawer()
const { ephemeralTopic, setEphemeralTopic } = useDocsHelpEphemeralTopic()
const [showClosedTab, setShowClosedTab] = useState(true)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
Expand All @@ -117,8 +118,9 @@ export const DocsHelpTab = () => {
if (!open) setEphemeralTopic(null)
}, [open, setEphemeralTopic])
useEffect(() => {
if (pinned) return
setEphemeralTopic(null)
}, [pathname, setEphemeralTopic])
}, [pathname, pinned, setEphemeralTopic])

const [body, setBody] = useState<HTMLElement | null>(null)
useEffect(() => setBody(document.body), [])
Expand All @@ -135,7 +137,20 @@ export const DocsHelpTab = () => {

useEffect(() => () => clearTimeout(closeTimerRef.current ?? undefined), [])

const topic = useMemo(() => ephemeralTopic ?? override ?? topicForPath(pathname ?? '/'), [ephemeralTopic, override, pathname])
const routeTopic = useMemo(() => override ?? topicForPath(pathname ?? '/'), [override, pathname])
const routeSection: DocsSection = pathname?.startsWith('/developers') ? 'developers' : 'platform'

const [frozen, setFrozen] = useState<{ topic: DocsHelpTopic; section: DocsSection } | null>(null)
useEffect(() => {
if (!pinned) {
setFrozen(null)
return
}
setFrozen((current) => current ?? { topic: routeTopic, section: routeSection })
}, [pinned, routeTopic, routeSection])

const topic = ephemeralTopic ?? (pinned ? (frozen?.topic ?? routeTopic) : routeTopic)
const section = pinned ? (frozen?.section ?? routeSection) : routeSection

if (!docsHelpEnabled) return null

Expand All @@ -151,6 +166,9 @@ export const DocsHelpTab = () => {
// be scrollable; the backdrop is transparent so it never dims what's behind
modal={modal}
overlayClassName="bg-transparent"
pinnable
pinned={pinned}
onPinnedChange={setPinned}
icon={<BookText size={20} className="self-center" />}
trigger={(openPanel) =>
showClosedTab && body
Expand All @@ -164,7 +182,7 @@ export const DocsHelpTab = () => {
}
edgeHandle={<DocsTabButton onClick={() => setOpen(false)} label="Close docs help" className={TAB_CLASSES} />}
>
<DocsHelpContent key={topic.query} query={topic.query} prefer={topic.prefer} intro={topic.intro} section={pathname?.startsWith('/developers') ? 'developers' : 'platform'} enabled={open} />
<DocsHelpContent key={topic.query} query={topic.query} prefer={topic.prefer} intro={topic.intro} section={section} enabled={open} />
</InfoSlideOut>
)
}
71 changes: 66 additions & 5 deletions packages/ui/src/info-slide-out/info-slide-out.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
'use client'

import * as React from 'react'
import { ExternalLink, InfoIcon, PanelRightClose } from 'lucide-react'
import { ExternalLink, InfoIcon, Pin, PinOff, PanelRightClose } from 'lucide-react'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../sheet/sheet'

export const PINNED_PANEL_WIDTH_VAR = '--pinned-panel-width'

type InfoSlideOutProps = {
title: string
subtitle?: React.ReactNode
Expand All @@ -23,8 +25,26 @@ type InfoSlideOutProps = {
overlayClassName?: string
open?: boolean
onOpenChange?: (open: boolean) => void
pinnable?: boolean
pinned?: boolean
onPinnedChange?: (pinned: boolean) => void
pinnedWidthVar?: string
}

const PinToggle = ({ pinned, onToggle }: { pinned: boolean; onToggle: () => void }) => (
<button
type="button"
onClick={onToggle}
aria-pressed={pinned}
aria-label={pinned ? 'Unpin panel' : 'Keep panel open'}
title={pinned ? 'Unpin panel' : 'Keep panel open while you navigate'}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted"
>
{pinned ? <PinOff size={14} /> : <Pin size={14} />}
<span>{pinned ? 'Unpin' : 'Keep open'}</span>
</button>
)

export function InfoSlideOut({
title,
subtitle,
Expand All @@ -41,6 +61,10 @@ export function InfoSlideOut({
overlayClassName,
open: controlledOpen,
onOpenChange,
pinnable = false,
pinned: controlledPinned,
onPinnedChange,
pinnedWidthVar = PINNED_PANEL_WIDTH_VAR,
}: InfoSlideOutProps) {
const [internalOpen, setInternalOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
Expand All @@ -52,8 +76,37 @@ export function InfoSlideOut({
const handleOpen = () => setOpen(true)
const handleClose = () => setOpen(false)

const [internalPinned, setInternalPinned] = React.useState(false)
const pinIsControlled = controlledPinned !== undefined
const pinned = pinnable && (pinIsControlled ? !!controlledPinned : internalPinned)
const togglePinned = () => {
const next = !pinned
if (!pinIsControlled) setInternalPinned(next)
onPinnedChange?.(next)
}

const [panelWidth, setPanelWidth] = React.useState<string | undefined>(undefined)
const ownsWidthVar = React.useRef(false)
React.useEffect(() => {
const root = document.documentElement
const release = () => {
if (!ownsWidthVar.current) return
ownsWidthVar.current = false
root.style.removeProperty(pinnedWidthVar)
}
if (!pinned || !open || !panelWidth) {
release()
return
}
ownsWidthVar.current = true
root.style.setProperty(pinnedWidthVar, panelWidth)
return release
}, [pinned, open, panelWidth, pinnedWidthVar])

const keepOpen = pinned ? (event: { preventDefault: () => void }) => event.preventDefault() : undefined

return (
<Sheet open={open} onOpenChange={setOpen} modal={modal}>
<Sheet open={open} onOpenChange={setOpen} modal={modal && !pinned}>
{trigger
? trigger(handleOpen)
: !isControlled && (
Expand All @@ -67,14 +120,22 @@ export function InfoSlideOut({
minWidth={380}
resizable={resizable}
edge={edgeHandle}
overlay={overlay}
overlay={overlay && !pinned}
overlayClassName={overlayClassName}
onWidthChange={setPanelWidth}
onInteractOutside={keepOpen}
onEscapeKeyDown={keepOpen}
onClick={(e) => e.stopPropagation()}
header={
<SheetHeader>
{!hideClose && (
{(!hideClose || pinnable) && (
<div className="flex items-center justify-between pb-1">
<PanelRightClose aria-label="Close info panel" size={16} className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors" onClick={() => setOpen(false)} />
{hideClose ? (
<span />
) : (
<PanelRightClose aria-label="Close info panel" size={16} className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors" onClick={() => setOpen(false)} />
)}
{pinnable && <PinToggle pinned={pinned} onToggle={togglePinned} />}
</div>
)}
<div className="flex items-stretch gap-2">
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/sheet/sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type TSheetContentProps = {
/** render the dimming backdrop; disable for non-modal drawers that leave the page interactive */
overlay?: boolean
overlayClassName?: string
onWidthChange?: (width: string | undefined) => void
}

function SheetContent({
Expand All @@ -54,6 +55,7 @@ function SheetContent({
edge,
overlay = true,
overlayClassName,
onWidthChange,
ref,
onInteractOutside,
...props
Expand Down Expand Up @@ -93,6 +95,13 @@ function SheetContent({
}
}, [resizable, side, minWidth])

const widthChangeRef = React.useRef(onWidthChange)
widthChangeRef.current = onWidthChange
React.useEffect(() => {
widthChangeRef.current?.(width)
return () => widthChangeRef.current?.(undefined)
}, [width])

const onMouseDown = () => {
if (resizable) {
isResizing.current = true
Expand Down