mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-06 02:25:24 +00:00
feat: shared tile look for cards and pages, plus new page pieces in SDK 1.1.0
This commit is contained in:
1 parent
1867be3f58
commit
19bdd589f3
41 files changed
+1497
-207
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { BottomEdge, EdgeOwned } from "./edge";
|
||||
|
||||
/**
|
||||
* A column of cards that can sit at any depth, like a settings page built
|
||||
* from several panels. Cards drop their box and the outermost ones draw a
|
||||
* line under themselves. Use TileStack when the sections are direct children.
|
||||
*/
|
||||
export function TileColumn({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={true}>
|
||||
<BottomEdge
|
||||
className={className}
|
||||
innerClassName="flex min-w-0 flex-col [&_[data-tile-card]]:border-x-0 [&_[data-tile-card]]:border-t-0 [&_[data-tile-card]]:border-b [&_[data-tile-card]]:border-border [&_[data-tile-card]_[data-tile-card]]:border-b-0"
|
||||
>
|
||||
<EdgeOwned>{children}</EdgeOwned>
|
||||
</BottomEdge>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The bar over a tile grid while it is being edited: a hint or extra controls
|
||||
* on the left, then Reset, Add tiles (while the picker is closed) and Done.
|
||||
*/
|
||||
export function TileEditBar({
|
||||
children,
|
||||
onReset,
|
||||
onAdd,
|
||||
onDone,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onReset?: () => void;
|
||||
/** Leave out while the tile picker is open. */
|
||||
onAdd?: () => void;
|
||||
onDone: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children && (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{onReset && (
|
||||
<Button size="xs" variant="ghost" className="gap-1" onClick={onReset}>
|
||||
<RotateCcw className="size-3" />
|
||||
{t("tileGrid.reset")}
|
||||
</Button>
|
||||
)}
|
||||
{onAdd && (
|
||||
<Button size="xs" variant="ghost" onClick={onAdd}>
|
||||
{t("tileGrid.addTiles")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="xs" onClick={onDone}>
|
||||
{t("tileGrid.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./layout";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { gridLines } from "./layout";
|
||||
import { scrollParent } from "./edge";
|
||||
|
||||
type Edge = "left" | "right" | "bottom" | "corner";
|
||||
interface Proposal {
|
||||
@@ -83,14 +84,6 @@ function propose(base: TileSlot[], id: string, proposal: Proposal) {
|
||||
return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot));
|
||||
}
|
||||
|
||||
function scrollParent(element: HTMLElement | null) {
|
||||
for (let node = element?.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (overflowY === "auto" || overflowY === "scroll") return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const NO_DRAG =
|
||||
"button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle";
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Children, isValidElement, type ReactNode } from "react";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { BottomEdge, EdgeOwned } from "./edge";
|
||||
|
||||
/**
|
||||
* Sections stacked edge to edge with a line under each, the column form of
|
||||
* the tile look. Cards inside drop their own border. The last line goes away
|
||||
* when the stack reaches the bottom of the screen.
|
||||
*/
|
||||
export function TileStack({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={true}>
|
||||
<BottomEdge className={className} innerClassName="flex min-w-0 flex-col">
|
||||
<EdgeOwned>
|
||||
{Children.toArray(children).map((section, index) => (
|
||||
<div
|
||||
key={keyOf(section, index)}
|
||||
className="min-w-0 shrink-0 border-b border-border"
|
||||
>
|
||||
{section}
|
||||
</div>
|
||||
))}
|
||||
</EdgeOwned>
|
||||
</BottomEdge>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(node: ReactNode, index: number): string | number {
|
||||
return isValidElement(node) && node.key !== null ? node.key : index;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Children, isValidElement, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { gridSizeStyle, type GridSize } from "@/components/panel-layout";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { BottomEdge } from "./edge";
|
||||
|
||||
/**
|
||||
* A fixed grid of tiles edge to edge with one line between neighbours, for
|
||||
* stat strips and card lists. `size` is tiles per row ("auto" fits as many as
|
||||
* `minWidth` allows). Use TileGrid when people should move and resize tiles.
|
||||
*/
|
||||
export function TileWall({
|
||||
size = "auto",
|
||||
minWidth = 280,
|
||||
gridClassName,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
size?: GridSize;
|
||||
minWidth?: number;
|
||||
/** Column classes (like "grid-cols-1 md:grid-cols-3") in place of size. */
|
||||
gridClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const tiles = Children.toArray(children);
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={true}>
|
||||
{/* The grid sits a pixel wider so the right edge line is cut off. */}
|
||||
<BottomEdge className={className}>
|
||||
<div
|
||||
className={cn("-mr-px grid", gridClassName)}
|
||||
style={
|
||||
gridClassName ? undefined : gridSizeStyle(size, minWidth, "0px")
|
||||
}
|
||||
>
|
||||
{tiles.map((tile, index) => (
|
||||
<div
|
||||
key={keyOf(tile, index)}
|
||||
className="flex min-w-0 flex-col border-b border-r border-border *:flex-1"
|
||||
>
|
||||
{tile}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BottomEdge>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(node: ReactNode, index: number): string | number {
|
||||
return isValidElement(node) && node.key !== null ? node.key : index;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function scrollParent(element: HTMLElement | null) {
|
||||
for (let node = element?.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (overflowY === "auto" || overflowY === "scroll") return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True inside a TileStack or TileColumn section, which draws its own line. */
|
||||
const EdgeOwnedContext = createContext(false);
|
||||
|
||||
export function EdgeOwned({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<EdgeOwnedContext.Provider value={true}>
|
||||
{children}
|
||||
</EdgeOwnedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the element's end reaches the bottom of the visible scroll area,
|
||||
* where a closing line would sit on the screen edge and only add noise.
|
||||
*/
|
||||
function useReachesBottom(ref: React.RefObject<HTMLElement | null>) {
|
||||
const [reaches, setReaches] = useState(false);
|
||||
const reachesRef = useRef(reaches);
|
||||
reachesRef.current = reaches;
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
const scroller = scrollParent(element);
|
||||
if (!element || !scroller) return;
|
||||
// Measured without the line itself, so showing it never flips the answer.
|
||||
const update = () => {
|
||||
const end =
|
||||
element.getBoundingClientRect().bottom -
|
||||
scroller.getBoundingClientRect().top +
|
||||
scroller.scrollTop -
|
||||
(reachesRef.current ? 0 : 1);
|
||||
setReaches(end >= scroller.clientHeight - 0.5);
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(scroller);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return reaches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps tiles whose last row draws a line under itself. The line stays when
|
||||
* there is room below and is clipped when the tiles reach the bottom of the
|
||||
* screen, the same rule the tile grid uses. Inside a stack section the
|
||||
* section owns the line, so it is always clipped.
|
||||
*/
|
||||
export function BottomEdge({
|
||||
className,
|
||||
innerClassName,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const owned = useContext(EdgeOwnedContext);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reaches = useReachesBottom(ref);
|
||||
return (
|
||||
<div ref={ref} className={cn("min-w-0 overflow-clip", className)}>
|
||||
<div className={cn((owned || reaches) && "-mb-px", innerClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
export { TileGrid, type TileGridProps } from "./TileGrid";
|
||||
export { TileFrame } from "./TileFrame";
|
||||
export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker";
|
||||
export { TileWall } from "./TileWall";
|
||||
export { TileStack } from "./TileStack";
|
||||
export { TileColumn } from "./TileColumn";
|
||||
export { TileEditBar } from "./TileEditBar";
|
||||
export { SeamlessTileContext, useInSeamlessTile } from "./context";
|
||||
export * from "./layout";
|
||||
Reference in new issue
Block a user