commit ef92d54a393c7c1dc81906de910bc7e182f4d9f0
parent c5b4b5c5fb452d2220329c2a261aa6396478e9b1
Author: triesap <triesap@radroots.dev>
Date: Fri, 26 Dec 2025 17:50:38 +0000
components: add ViewStack and ViewPane primitives
- Export ViewStack and ViewPane from components index
- Define typed view context contract and context key
- Implement ViewPane state derivation with transitions and pointer-events gating
- Implement ViewStack context provider with reactive basis sync and layout styling
Diffstat:
4 files changed, 305 insertions(+), 0 deletions(-)
diff --git a/apps-lib/src/lib/components/index.ts b/apps-lib/src/lib/components/index.ts
@@ -5,3 +5,5 @@ export { default as Glyphi } from "./glyphi.svelte";
export { default as ImageBlob } from "./image-blob.svelte";
export { default as ImageSrc } from "./image-src.svelte";
export { default as Input } from "./input.svelte";
+export { default as ViewPane } from "./view-pane.svelte";
+export { default as ViewStack } from "./view-stack.svelte";
diff --git a/apps-lib/src/lib/components/view-context.ts b/apps-lib/src/lib/components/view-context.ts
@@ -0,0 +1,19 @@
+export type ViewMode = "stack" | "flow";
+export type ViewPointerEvents = "none" | "auto";
+
+export type ViewContext<T extends string> = {
+ active_view: T;
+ mode: ViewMode;
+ fade: boolean;
+ transition_ms: number;
+ opacity_inactive: number;
+ scale_inactive: number;
+ offset_x: string;
+ offset_y: string;
+ blur_inactive_px: number;
+ pointer_events_inactive: ViewPointerEvents;
+ z_index_active: number;
+ z_index_inactive: number;
+};
+
+export const VIEW_CONTEXT_KEY = "radroots:view";
diff --git a/apps-lib/src/lib/components/view-pane.svelte b/apps-lib/src/lib/components/view-pane.svelte
@@ -0,0 +1,169 @@
+<script lang="ts">
+ import type { Snippet } from "svelte";
+ import { get_context } from "$lib/utils/app";
+ import {
+ VIEW_CONTEXT_KEY,
+ type ViewContext,
+ type ViewMode,
+ type ViewPointerEvents,
+ } from "./view-context";
+
+ const DEFAULT_TRANSITION_MS = 260;
+ const DEFAULT_OPACITY_INACTIVE = 0;
+ const DEFAULT_SCALE_INACTIVE = 0.98;
+ const DEFAULT_OFFSET_X = "0px";
+ const DEFAULT_OFFSET_Y = "12px";
+ const DEFAULT_BLUR_INACTIVE_PX = 0;
+ const DEFAULT_POINTER_EVENTS_INACTIVE: ViewPointerEvents = "none";
+ const DEFAULT_Z_INDEX_ACTIVE = 2;
+ const DEFAULT_Z_INDEX_INACTIVE = 1;
+ const DEFAULT_DISPLAY = "flex";
+ const DEFAULT_DIRECTION = "column";
+ const DEFAULT_ALIGN = "stretch";
+ const DEFAULT_JUSTIFY = "flex-start";
+ const DEFAULT_WIDTH = "100%";
+ const DEFAULT_HEIGHT = "100%";
+ const DEFAULT_MIN_HEIGHT = "0px";
+ const DEFAULT_PADDING = "0px";
+ const DEFAULT_GAP = "0px";
+ const DEFAULT_OVERFLOW = "hidden";
+ const DEFAULT_BACKGROUND = "transparent";
+ const EASE_OUT = "cubic-bezier(0.16, 1, 0.3, 1)";
+
+ type ViewPaneBasis<T extends string> = {
+ view: T;
+ active?: boolean;
+ active_view?: T;
+ mode?: ViewMode;
+ fade?: boolean;
+ transition_ms?: number;
+ opacity_inactive?: number;
+ scale_inactive?: number;
+ offset_x?: string;
+ offset_y?: string;
+ blur_inactive_px?: number;
+ pointer_events_inactive?: ViewPointerEvents;
+ z_index_active?: number;
+ z_index_inactive?: number;
+ display?: string;
+ direction?: string;
+ align?: string;
+ justify?: string;
+ width?: string;
+ height?: string;
+ min_height?: string;
+ padding?: string;
+ gap?: string;
+ overflow?: string;
+ background?: string;
+ style?: string;
+ style_active?: string;
+ style_inactive?: string;
+ role?: string;
+ tabindex?: number;
+ aria_label?: string;
+ };
+
+ let {
+ basis,
+ children,
+ }: {
+ basis: ViewPaneBasis<string>;
+ children: Snippet;
+ } = $props();
+
+ const view_context =
+ get_context<ViewContext<string> | undefined>(VIEW_CONTEXT_KEY);
+
+ const active_view = $derived(basis.active_view ?? view_context?.active_view);
+ const is_active = $derived(
+ basis.active !== undefined
+ ? basis.active
+ : active_view
+ ? basis.view === active_view
+ : true,
+ );
+ const mode = $derived(basis.mode ?? view_context?.mode ?? "stack");
+ const fade = $derived(basis.fade ?? view_context?.fade ?? true);
+ const transition_ms = $derived(
+ basis.transition_ms ?? view_context?.transition_ms ?? DEFAULT_TRANSITION_MS,
+ );
+ const opacity_inactive = $derived(
+ basis.opacity_inactive ??
+ view_context?.opacity_inactive ??
+ DEFAULT_OPACITY_INACTIVE,
+ );
+ const scale_inactive = $derived(
+ basis.scale_inactive ??
+ view_context?.scale_inactive ??
+ DEFAULT_SCALE_INACTIVE,
+ );
+ const offset_x = $derived(
+ basis.offset_x ?? view_context?.offset_x ?? DEFAULT_OFFSET_X,
+ );
+ const offset_y = $derived(
+ basis.offset_y ?? view_context?.offset_y ?? DEFAULT_OFFSET_Y,
+ );
+ const blur_inactive_px = $derived(
+ basis.blur_inactive_px ??
+ view_context?.blur_inactive_px ??
+ DEFAULT_BLUR_INACTIVE_PX,
+ );
+ const pointer_events_inactive = $derived(
+ basis.pointer_events_inactive ??
+ view_context?.pointer_events_inactive ??
+ DEFAULT_POINTER_EVENTS_INACTIVE,
+ );
+ const z_index_active = $derived(
+ basis.z_index_active ??
+ view_context?.z_index_active ??
+ DEFAULT_Z_INDEX_ACTIVE,
+ );
+ const z_index_inactive = $derived(
+ basis.z_index_inactive ??
+ view_context?.z_index_inactive ??
+ DEFAULT_Z_INDEX_INACTIVE,
+ );
+ const display = $derived(basis.display ?? DEFAULT_DISPLAY);
+ const direction = $derived(basis.direction ?? DEFAULT_DIRECTION);
+ const align = $derived(basis.align ?? DEFAULT_ALIGN);
+ const justify = $derived(basis.justify ?? DEFAULT_JUSTIFY);
+ const width = $derived(basis.width ?? DEFAULT_WIDTH);
+ const height = $derived(basis.height ?? DEFAULT_HEIGHT);
+ const min_height = $derived(basis.min_height ?? DEFAULT_MIN_HEIGHT);
+ const padding = $derived(basis.padding ?? DEFAULT_PADDING);
+ const gap = $derived(basis.gap ?? DEFAULT_GAP);
+ const overflow = $derived(basis.overflow ?? DEFAULT_OVERFLOW);
+ const background = $derived(basis.background ?? DEFAULT_BACKGROUND);
+
+ const opacity = $derived(is_active ? 1 : opacity_inactive);
+ const transform = $derived(
+ `translate3d(${is_active ? "0px" : offset_x}, ${is_active ? "0px" : offset_y}, 0) scale(${is_active ? 1 : scale_inactive})`,
+ );
+ const blur_val = $derived(is_active ? 0 : blur_inactive_px);
+ const filter = $derived(blur_val ? `blur(${blur_val}px)` : "none");
+ const pointer_events = $derived(is_active ? "auto" : pointer_events_inactive);
+ const z_index = $derived(is_active ? z_index_active : z_index_inactive);
+ const transition = $derived(
+ fade
+ ? `opacity ${transition_ms}ms ${EASE_OUT}, transform ${transition_ms}ms ${EASE_OUT}, filter ${transition_ms}ms ${EASE_OUT}`
+ : `transform ${transition_ms}ms ${EASE_OUT}, filter ${transition_ms}ms ${EASE_OUT}`,
+ );
+ const position = $derived(mode === "stack" ? "absolute" : "relative");
+ const inset = $derived(mode === "stack" ? "0" : "auto");
+
+ const style = $derived(
+ `position: ${position}; inset: ${inset}; width: ${width}; height: ${height}; min-height: ${min_height}; display: ${display}; flex-direction: ${direction}; align-items: ${align}; justify-content: ${justify}; gap: ${gap}; padding: ${padding}; overflow: ${overflow}; background: ${background}; opacity: ${opacity}; transform: ${transform}; filter: ${filter}; transition: ${transition}; pointer-events: ${pointer_events}; z-index: ${z_index}; box-sizing: border-box; will-change: transform, opacity, filter; backface-visibility: hidden; transform-style: preserve-3d; ${basis.style ?? ""} ${is_active ? basis.style_active ?? "" : basis.style_inactive ?? ""}`,
+ );
+</script>
+
+<div
+ data-view={basis.view}
+ style={style}
+ role={basis.role ?? undefined}
+ tabindex={basis.tabindex ?? undefined}
+ aria-label={basis.aria_label ?? undefined}
+ aria-hidden={!is_active}
+>
+ {@render children()}
+</div>
diff --git a/apps-lib/src/lib/components/view-stack.svelte b/apps-lib/src/lib/components/view-stack.svelte
@@ -0,0 +1,115 @@
+<script lang="ts">
+ import type { Snippet } from "svelte";
+ import { set_context } from "$lib/utils/app";
+ import {
+ VIEW_CONTEXT_KEY,
+ type ViewContext,
+ type ViewMode,
+ type ViewPointerEvents,
+ } from "./view-context";
+
+ const DEFAULT_TRANSITION_MS = 260;
+ const DEFAULT_OPACITY_INACTIVE = 0;
+ const DEFAULT_SCALE_INACTIVE = 0.98;
+ const DEFAULT_OFFSET_X = "0px";
+ const DEFAULT_OFFSET_Y = "12px";
+ const DEFAULT_BLUR_INACTIVE_PX = 0;
+ const DEFAULT_POINTER_EVENTS_INACTIVE: ViewPointerEvents = "none";
+ const DEFAULT_Z_INDEX_ACTIVE = 2;
+ const DEFAULT_Z_INDEX_INACTIVE = 1;
+
+ type ViewStackBasis<T extends string> = {
+ active_view: T;
+ mode?: ViewMode;
+ width?: string;
+ height?: string;
+ min_height?: string;
+ padding?: string;
+ gap?: string;
+ direction?: string;
+ align?: string;
+ justify?: string;
+ overflow?: string;
+ background?: string;
+ style?: string;
+ fade?: boolean;
+ transition_ms?: number;
+ opacity_inactive?: number;
+ scale_inactive?: number;
+ offset_x?: string;
+ offset_y?: string;
+ blur_inactive_px?: number;
+ pointer_events_inactive?: ViewPointerEvents;
+ z_index_active?: number;
+ z_index_inactive?: number;
+ };
+
+ let {
+ basis,
+ children,
+ }: {
+ basis: ViewStackBasis<string>;
+ children: Snippet;
+ } = $props();
+
+ const view_context = $state<ViewContext<string>>({
+ active_view: basis.active_view,
+ mode: basis.mode ?? "stack",
+ fade: basis.fade ?? true,
+ transition_ms: basis.transition_ms ?? DEFAULT_TRANSITION_MS,
+ opacity_inactive: basis.opacity_inactive ?? DEFAULT_OPACITY_INACTIVE,
+ scale_inactive: basis.scale_inactive ?? DEFAULT_SCALE_INACTIVE,
+ offset_x: basis.offset_x ?? DEFAULT_OFFSET_X,
+ offset_y: basis.offset_y ?? DEFAULT_OFFSET_Y,
+ blur_inactive_px: basis.blur_inactive_px ?? DEFAULT_BLUR_INACTIVE_PX,
+ pointer_events_inactive:
+ basis.pointer_events_inactive ?? DEFAULT_POINTER_EVENTS_INACTIVE,
+ z_index_active: basis.z_index_active ?? DEFAULT_Z_INDEX_ACTIVE,
+ z_index_inactive: basis.z_index_inactive ?? DEFAULT_Z_INDEX_INACTIVE,
+ });
+
+ set_context(VIEW_CONTEXT_KEY, view_context);
+
+ $effect(() => {
+ view_context.active_view = basis.active_view;
+ view_context.mode = basis.mode ?? "stack";
+ view_context.fade = basis.fade ?? true;
+ view_context.transition_ms =
+ basis.transition_ms ?? DEFAULT_TRANSITION_MS;
+ view_context.opacity_inactive =
+ basis.opacity_inactive ?? DEFAULT_OPACITY_INACTIVE;
+ view_context.scale_inactive =
+ basis.scale_inactive ?? DEFAULT_SCALE_INACTIVE;
+ view_context.offset_x = basis.offset_x ?? DEFAULT_OFFSET_X;
+ view_context.offset_y = basis.offset_y ?? DEFAULT_OFFSET_Y;
+ view_context.blur_inactive_px =
+ basis.blur_inactive_px ?? DEFAULT_BLUR_INACTIVE_PX;
+ view_context.pointer_events_inactive =
+ basis.pointer_events_inactive ?? DEFAULT_POINTER_EVENTS_INACTIVE;
+ view_context.z_index_active =
+ basis.z_index_active ?? DEFAULT_Z_INDEX_ACTIVE;
+ view_context.z_index_inactive =
+ basis.z_index_inactive ?? DEFAULT_Z_INDEX_INACTIVE;
+ });
+
+ const mode = $derived(basis.mode ?? "stack");
+ const width = $derived(basis.width ?? "100%");
+ const height = $derived(basis.height ?? "100%");
+ const min_height = $derived(basis.min_height ?? "0px");
+ const padding = $derived(basis.padding ?? "0px");
+ const gap = $derived(basis.gap ?? "0px");
+ const direction = $derived(basis.direction ?? "column");
+ const align = $derived(basis.align ?? "stretch");
+ const justify = $derived(basis.justify ?? "flex-start");
+ const overflow = $derived(basis.overflow ?? "hidden");
+ const background = $derived(basis.background ?? "transparent");
+ const display = $derived(mode === "flow" ? "flex" : "block");
+
+ const style = $derived(
+ `position: relative; width: ${width}; height: ${height}; min-height: ${min_height}; padding: ${padding}; display: ${display}; flex-direction: ${direction}; align-items: ${align}; justify-content: ${justify}; gap: ${gap}; overflow: ${overflow}; background: ${background}; isolation: isolate; contain: layout paint style; ${basis.style ?? ""}`,
+ );
+</script>
+
+<div style={style}>
+ {@render children()}
+</div>