Skip to content
Merged
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
14 changes: 12 additions & 2 deletions packages/junior-dashboard/e2e/conversations-event-log.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ test("inspects all reporting events and searches full event data", async ({
await expect(localTime).toHaveAttribute("aria-pressed", "true");
await expect(localTime).toHaveText("ET");
await expect(entry.getByText("07:54:09.000", { exact: true })).toBeVisible();
await expect(panel.locator("header time")).toContainText("07:54:09.000");
await expect(panel.locator("header time")).toContainText("07:54:09");
await page.reload();
await expect(localTime).toHaveAttribute("aria-pressed", "true");
await expect(entry.getByText("07:54:09.000", { exact: true })).toBeVisible();
Expand All @@ -166,13 +166,23 @@ test("inspects all reporting events and searches full event data", async ({
await secondToggle.click();
await expect(localTime).toHaveAttribute("aria-pressed", "false");
await expect(entry.getByText("11:54:09.000", { exact: true })).toBeVisible();
// UTC mode reads like local mode. The header leaves out milliseconds, so
// it fits beside the actions. The tooltip and ISO value keep them.
await expect(panel.locator("header time")).toHaveText(
"Aug 7, 2026, 11:54:09 UTC",
);
await expect(panel.locator("header time")).toHaveAttribute(
"title",
"Aug 7, 2026, 11:54:09.000 UTC",
);
await expect(panel.locator("header time")).toHaveAttribute(
"datetime",
report.events.find((event: { seq: number }) => event.seq === 3).createdAt,
);
await secondTab.close();
await page.bringToFront();
await localTime.click();
await expect(panel.locator("header time")).toContainText("07:54:09.000");
await expect(panel.locator("header time")).toContainText("07:54:09");
await panel.getByText("Raw JSON", { exact: true }).click();
const rawJson = JSON.stringify(
report.events.find((event: { seq: number }) => event.seq === 3),
Expand Down
25 changes: 12 additions & 13 deletions packages/junior-dashboard/src/client/components/DetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,19 @@ export function DetailsPanel(props: {
className="grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] border-l border-dashboard-border bg-dashboard-bg-elevated"
>
<header className="flex items-center justify-between gap-3 border-b border-dashboard-border py-2 pl-5 pr-3">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0">{props.title}</div>
{props.actions ? (
<div className="flex items-center gap-0.5">{props.actions}</div>
) : null}
<div className="min-w-0 flex-1">{props.title}</div>
{/* Keep actions beside close, so they do not move with the title width. */}
<div className="flex shrink-0 items-center gap-0.5">
{props.actions}
<Button
ref={closeRef}
aria-label={closeLabel}
onClick={onClose}
size="icon"
>
<X aria-hidden="true" size={15} />
</Button>
</div>
<Button
ref={closeRef}
aria-label={closeLabel}
onClick={onClose}
size="icon"
>
<X aria-hidden="true" size={15} />
</Button>
</header>
<div className="min-h-0 overflow-y-auto overscroll-contain">
{props.children}
Expand Down
69 changes: 46 additions & 23 deletions packages/junior-dashboard/src/client/components/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import { X } from "lucide-react";
import { useRef, type ReactNode } from "react";

import { cn } from "../styles";
import { Sheet, SheetContent, SheetHeader } from "./ui/sheet";
import { Button } from "./ui/button";
import {
Drawer as DrawerRoot,
DrawerClose,
DrawerContent as DrawerPopup,
} from "./ui/drawer";
import { useSheet } from "./ui/useSheet";

type DrawerProps = {
actions?: ReactNode;
children: ReactNode;
closeLabel: string;
/** Remove the body inset, so content such as the event log meets the edges. */
flush?: boolean;
header: ReactNode;
onClose(): void;
openKey: string;
width?: "default" | "wide" | "narrow";
} & ({ titleId: string; label?: never } | { label: string; titleId?: never });

/** Render a side drawer with shared modal focus and dismissal behavior. */
/**
* Render a side drawer with shared modal focus and dismissal behavior.
* A drawer opened from inside another drawer stacks in front of it.
*/
export function Drawer(props: DrawerProps) {
return <DrawerContent key={props.openKey} {...props} />;
}
Expand All @@ -23,37 +34,49 @@ function DrawerContent(props: DrawerProps) {
const closeRef = useRef<HTMLButtonElement>(null);
const sheet = useSheet(props.onClose, closeRef);
return (
<Sheet {...sheet.rootProps}>
<SheetContent
<DrawerRoot {...sheet.rootProps} swipeDirection="right">
<DrawerPopup
{...sheet.contentProps}
aria-label={props.label}
aria-labelledby={props.titleId}
side="right"
closeButtonProps={{
ref: closeRef,
"aria-label": props.closeLabel,
className:
"right-4 top-[max(0.75rem,env(safe-area-inset-top))] md:right-5",
}}
data-dashboard-modal=""
className={cn(
"grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] gap-0 outline-none data-[side=right]:w-full data-[side=right]:sm:max-w-none",
"data-[swipe-axis=x]:[--drawer-content-width:100%] data-[swipe-axis=x]:sm:[--drawer-content-width:100%]",
props.width === "wide"
? "data-[side=right]:md:w-[min(760px,94vw)]"
? "data-[swipe-axis=x]:md:[--drawer-content-width:min(760px,94vw)]"
: props.width === "narrow"
? "data-[side=right]:md:w-[min(440px,94vw)]"
: "data-[side=right]:md:w-[min(560px,94vw)]",
? "data-[swipe-axis=x]:md:[--drawer-content-width:min(440px,94vw)]"
: "data-[swipe-axis=x]:md:[--drawer-content-width:min(560px,94vw)]",
)}
>
<SheetHeader className="flex-row items-start gap-3 border-b border-dashboard-border bg-dashboard-bg-elevated px-4 py-3 pr-14 pt-[max(0.75rem,env(safe-area-inset-top))] md:pl-5 md:pr-14">
<header className="flex shrink-0 items-start gap-3 border-b border-dashboard-border bg-dashboard-bg-elevated px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] md:px-5">
<div className="min-w-0 flex-1">{props.header}</div>
{props.actions ? (
<div className="flex items-start gap-1.5">{props.actions}</div>
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
) : null}
</SheetHeader>
<div className="min-h-0 overflow-auto px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-5">
<div className="flex shrink-0 items-start gap-1.5">
{props.actions}
<DrawerClose
render={
<Button
ref={closeRef}
aria-label={props.closeLabel}
size="icon"
/>
}
>
<X aria-hidden="true" size={15} strokeWidth={2.25} />
</DrawerClose>
</div>
</header>
<div
className={cn(
"min-h-0 flex-1 overflow-auto",
props.flush
? "pb-[env(safe-area-inset-bottom)]"
: "px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-5",
)}
>
{props.children}
</div>
</SheetContent>
</Sheet>
</DrawerPopup>
</DrawerRoot>
);
}
10 changes: 8 additions & 2 deletions packages/junior-dashboard/src/client/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ file when every caller needs the same style or variant. App components compose

| App component | shadcn source |
| ------------------------------ | ----------------- |
| `Drawer`, `BottomSheet` | `ui/sheet` |
| `Drawer` | `ui/drawer` |
| `BottomSheet` | `ui/sheet` |
| `layout/DashboardHeader` (nav) | `ui/sheet` |
| `ProfileMenu` (desktop) | `ui/popover` |
| `ActorAvatar` | `ui/avatar` |
Expand Down Expand Up @@ -149,7 +150,12 @@ Shared class strings live in `src/client/styles.ts`.
child. Use `ui/accordion` for a set of sections with headings.
- **Details.** `DetailsPanel` docks an aside at 1280px and above, and uses a
`BottomSheet` below that width. It owns Escape and the scroll frame for
docked panels.
docked panels. Docked actions sit beside the close control, so their
position does not change with the title width.
- **Drawer.** `Drawer` is a right-side `ui/drawer`. A `Drawer` opened from
inside another `Drawer` stacks in front of it. The parent scales back,
shows a thin edge behind the child, and hides its content. Set `flush` when
the content owns its insets and sticky headers, such as the event log.
- **Identity.** `ObjectIcon` owns the native Ticket, Code change, Automation,
Deployment, and Item icons. Do not replace it with a provider logo or a
status-only icon.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@dashboard/lib/utils";

import { Separator } from "@dashboard/components/ui/separator";

const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);

/** Join related buttons into one control and name it for assistive technology. */
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}

/** Draw a divider between buttons in a group. */
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-dashboard-border data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className,
)}
{...props}
/>
);
}

export { ButtonGroup, ButtonGroupSeparator };
140 changes: 140 additions & 0 deletions packages/junior-dashboard/src/client/components/ui/drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
import { cn } from "@dashboard/lib/utils";

type DrawerContextProps = {
modal: DrawerPrimitive.Root.Props["modal"];
swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>;
};

const DrawerContext = React.createContext<DrawerContextProps | null>(null);

function useDrawer() {
const context = React.useContext(DrawerContext);

if (!context) {
throw new Error("useDrawer must be used within a Drawer.");
}

return context;
}

/**
* Own the open state, swipe dismissal, and nesting for a shadcn drawer.
* A drawer rendered inside another drawer stacks in front of it.
*/
function Drawer({
modal = true,
swipeDirection = "down",
...props
}: DrawerPrimitive.Root.Props) {
const contextValue = React.useMemo(
() => ({ modal, swipeDirection }),
[modal, swipeDirection],
);

return (
<DrawerContext.Provider value={contextValue}>
<DrawerPrimitive.Root
data-slot="drawer"
modal={modal}
swipeDirection={swipeDirection}
{...props}
/>
</DrawerContext.Provider>
);
}

function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}

/** Close the drawer through Base UI dismissal and focus handling. */
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}

function DrawerOverlay({
className,
...props
}: DrawerPrimitive.Backdrop.Props) {
return (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 min-h-dvh bg-dashboard-overlay opacity-[calc(1-var(--drawer-swipe-progress))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none motion-reduce:transition-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute",
className,
)}
{...props}
/>
);
}

/**
* Place the drawer on its swipe edge. While a nested drawer is open, this
* drawer scales back, peeks out behind it, and hides its content.
*/
function DrawerContent({
className,
children,
...props
}: DrawerPrimitive.Popup.Props) {
const { modal, swipeDirection } = useDrawer();
const swipeAxis =
swipeDirection === "down" || swipeDirection === "up" ? "y" : "x";

return (
<DrawerPortal>
{/* App drawers trap focus without a modal root, so keep the overlay.
Base UI skips it for a nested drawer. */}
<DrawerOverlay />
<DrawerPrimitive.Viewport
data-slot="drawer-viewport"
data-modal={modal}
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
>
<DrawerPrimitive.Popup
data-slot="drawer-popup"
data-swipe-axis={swipeAxis}
className={cn(
// Base.
"group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col border-dashboard-border-strong bg-popover text-sm text-popover-foreground transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none motion-reduce:transition-none select-none [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b",
// Nested.
"data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
// Bleed.
"after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full",
// Sizing.
"[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]",
// Stack.
"[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
// Transitions.
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]",
// Axis: y.
"data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
// Axis: x.
"data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
// Direction: down.
"data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
// Direction: up.
"data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
// Direction: left.
"data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
// Direction: right.
"data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
className,
)}
{...props}
>
<DrawerPrimitive.Content
data-slot="drawer-content"
className="flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text motion-reduce:transition-none group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none"
>
{children}
</DrawerPrimitive.Content>
</DrawerPrimitive.Popup>
</DrawerPrimitive.Viewport>
</DrawerPortal>
);
}

export { Drawer, DrawerClose, DrawerContent };
Loading
Loading