chore: initialize @limitless/ui git repo + add AppShell
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>
This commit is contained in:
208
src/components/Offcanvas.tsx
Normal file
208
src/components/Offcanvas.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
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>
|
||||
);
|
||||
Reference in New Issue
Block a user