This brings the long-untracked @limitless/ui source tree under version
control. Until now /srv/k8s/templates/limitless-ui has been a plain
file: dependency consumed by gscChronos / gscCRM / gscAdmin, with
copies scattered across web/gsc{Portal,WWW,Aether,Register}/ and
apps/gsc{Meet,Share}/. None were git-tracked.
Treating /srv/k8s/templates/limitless-ui as the canonical going
forward; secondary copies should be replaced with this version
in their consumers' Dockerfiles when they next get touched.
Changes in this initial commit beyond the snapshot:
- Add src/layout/AppShell.tsx — runtime-loaded chrome (header,
sidebar, footer) backed by gsc-shell-api. Public surface:
AppShell, ShellProvider, useShell, ShellConfig types
Framework-agnostic (no Next.js dep). Apps pass appKey + apiUrl +
getToken; AppShell composes the existing PageShell / Navbar /
Sidebar / Footer primitives with API data.
- Re-export AppShell from src/index.ts.
- Fix build script: `tsc -p tsconfig.json --noEmit false`. The bare
`tsc` command was a no-op because tsconfig.json sets noEmit:true
for typecheck speed. Existing dist/ only existed because of an
earlier emit; clean rebuilds were silently broken.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
5.0 KiB
TypeScript
209 lines
5.0 KiB
TypeScript
import React, { useEffect, useRef, useCallback } from 'react';
|
|
|
|
export interface OffcanvasProps {
|
|
/** Whether the offcanvas is open */
|
|
isOpen: boolean;
|
|
/** Callback when offcanvas should close */
|
|
onClose: () => void;
|
|
/** Placement of the offcanvas */
|
|
placement?: 'start' | 'end' | 'top' | 'bottom';
|
|
/** Title for the header */
|
|
title?: React.ReactNode;
|
|
/** Show backdrop */
|
|
backdrop?: boolean | 'static';
|
|
/** Allow scrolling body when open */
|
|
scroll?: boolean;
|
|
/** Enable keyboard (Escape) to close */
|
|
keyboard?: boolean;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
/** Header CSS classes */
|
|
headerClassName?: string;
|
|
/** Body CSS classes */
|
|
bodyClassName?: string;
|
|
/** Children content */
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
export const Offcanvas: React.FC<OffcanvasProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
placement = 'start',
|
|
title,
|
|
backdrop = true,
|
|
scroll = false,
|
|
keyboard = true,
|
|
className = '',
|
|
headerClassName = '',
|
|
bodyClassName = '',
|
|
children,
|
|
}) => {
|
|
const offcanvasRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Handle escape key
|
|
useEffect(() => {
|
|
if (!keyboard || !isOpen) return;
|
|
|
|
const handleKeydown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeydown);
|
|
return () => document.removeEventListener('keydown', handleKeydown);
|
|
}, [keyboard, isOpen, onClose]);
|
|
|
|
// Handle body scroll
|
|
useEffect(() => {
|
|
if (isOpen && !scroll) {
|
|
document.body.style.overflow = 'hidden';
|
|
} else {
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
return () => {
|
|
document.body.style.overflow = '';
|
|
};
|
|
}, [isOpen, scroll]);
|
|
|
|
// Handle backdrop click
|
|
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
|
if (backdrop === 'static') return;
|
|
if (e.target === e.currentTarget) {
|
|
onClose();
|
|
}
|
|
}, [backdrop, onClose]);
|
|
|
|
// Focus trap
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const offcanvas = offcanvasRef.current;
|
|
if (!offcanvas) return;
|
|
|
|
const focusableElements = offcanvas.querySelectorAll(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
);
|
|
const firstElement = focusableElements[0] as HTMLElement;
|
|
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
|
|
|
const handleTab = (e: KeyboardEvent) => {
|
|
if (e.key !== 'Tab') return;
|
|
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === firstElement) {
|
|
lastElement?.focus();
|
|
e.preventDefault();
|
|
}
|
|
} else {
|
|
if (document.activeElement === lastElement) {
|
|
firstElement?.focus();
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
};
|
|
|
|
firstElement?.focus();
|
|
document.addEventListener('keydown', handleTab);
|
|
return () => document.removeEventListener('keydown', handleTab);
|
|
}, [isOpen]);
|
|
|
|
const classes = [
|
|
'll-offcanvas',
|
|
`ll-offcanvas-${placement}`,
|
|
isOpen && 'll-offcanvas-show',
|
|
className,
|
|
].filter(Boolean).join(' ');
|
|
|
|
if (!isOpen && !backdrop) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
{backdrop && (
|
|
<div
|
|
className={`ll-offcanvas-backdrop ${isOpen ? 'll-offcanvas-backdrop-show' : ''}`}
|
|
onClick={handleBackdropClick}
|
|
/>
|
|
)}
|
|
|
|
{/* Offcanvas */}
|
|
<div
|
|
ref={offcanvasRef}
|
|
className={classes}
|
|
tabIndex={-1}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={title ? 'll-offcanvas-title' : undefined}
|
|
>
|
|
{/* Header */}
|
|
{title && (
|
|
<div className={`ll-offcanvas-header ${headerClassName}`}>
|
|
<h5 className="ll-offcanvas-title" id="ll-offcanvas-title">
|
|
{title}
|
|
</h5>
|
|
<button
|
|
type="button"
|
|
className="ll-offcanvas-close"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
>
|
|
<span aria-hidden="true">×</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Body */}
|
|
<div className={`ll-offcanvas-body ${bodyClassName}`}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export interface OffcanvasHeaderProps {
|
|
className?: string;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
export const OffcanvasHeader: React.FC<OffcanvasHeaderProps> = ({
|
|
className = '',
|
|
children,
|
|
}) => (
|
|
<div className={`ll-offcanvas-header ${className}`}>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
export interface OffcanvasTitleProps {
|
|
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
className?: string;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
export const OffcanvasTitle: React.FC<OffcanvasTitleProps> = ({
|
|
as: Component = 'h5',
|
|
className = '',
|
|
children,
|
|
}) => (
|
|
<Component className={`ll-offcanvas-title ${className}`}>
|
|
{children}
|
|
</Component>
|
|
);
|
|
|
|
export interface OffcanvasBodyProps {
|
|
className?: string;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
export const OffcanvasBody: React.FC<OffcanvasBodyProps> = ({
|
|
className = '',
|
|
children,
|
|
}) => (
|
|
<div className={`ll-offcanvas-body ${className}`}>
|
|
{children}
|
|
</div>
|
|
);
|