diff --git a/packages/solid-plugin/src/Positioner.tsx b/packages/solid-plugin/src/Positioner.tsx new file mode 100644 index 0000000..12a1747 --- /dev/null +++ b/packages/solid-plugin/src/Positioner.tsx @@ -0,0 +1,245 @@ +import { children, createMemo, createSignal } from 'solid-js'; +import type { JSX } from 'solid-js'; +import { PositionerContext } from './context.js'; +import type { PositionerChildInfo } from './context.js'; +import { toPx } from './utils.js'; + +export interface PositionerProps { + spacing?: number; + class?: string; + children?: JSX.Element; +} + +type StackMode = 'none' | 'stack-center' | 'stack-fill'; + +interface BucketGroup { + start: JSX.Element[]; + center: JSX.Element[]; + end: JSX.Element[]; + fill: JSX.Element[]; +} + +interface BucketModes { + start: StackMode; + center: StackMode; + end: StackMode; + fill: StackMode; +} + +const BUCKET_KEYS = ['start', 'center', 'end', 'fill'] as const; + +/* + * Two-component pattern (Positioner + PositionerInner) solves a SolidJS + * context ownership issue: + * + * `children(() => props.children)` evaluates child component functions + * lazily during rendering. Those child functions need to read + * `useContext(PositionerContext)` to register their alignment data. + * + * SolidJS context lookup follows the OWNER chain. If the outer + * Positioner calls `children()` itself, the Provider and the + * `children()` call are siblings in the owner tree — children + * evaluated by `children()` wouldn't see the Provider. + * + * Solution: Positioner renders the Provider, then delegates to + * PositionerInner as a child OF the Provider. PositionerInner + * calls `children()` — now the evaluated children are inside the + * Provider's rendered subtree, and context lookup finds it. + */ + +export function Positioner(props: PositionerProps) { + const pending: PositionerChildInfo[] = []; + const [tick, setTick] = createSignal(0); + + return ( + t + 1); + }, + unregister(info) { + const idx = pending.indexOf(info); + if (idx >= 0) pending.splice(idx, 1); + setTick((t) => t + 1); + }, + }} + > + + {props.children} + + + ); +} + +function PositionerInner(props: { + spacing?: number; + class?: string; + pending: PositionerChildInfo[]; + tick: () => number; + children?: JSX.Element; +}) { + const resolved = children(() => props.children); + + const buckets = createMemo(() => { + props.tick(); + /* Clear stale registrations before evaluating children — old child + components' onCleanup may not fire synchronously on re-eval. */ + props.pending.length = 0; + const kids = resolved(); + const kidArr = ( + Array.isArray(kids) ? kids : kids != null ? [kids] : [] + ) as JSX.Element[]; + const regs = props.pending; + const regCount = regs.length; + + if (kidArr.length !== regCount) { + console.warn( + `[Positioner] ${regCount} child(ren) registered but ${kidArr.length} found. All direct children of Positioner must call useUnion.`, + ); + } + + const items: BucketGroup = { start: [], center: [], end: [], fill: [] }; + const modes: BucketModes = { start: 'none', center: 'none', end: 'none', fill: 'none' }; + const bg: BucketGroup = { start: [], center: [], end: [], fill: [] }; + const content: BucketGroup = { start: [], center: [], end: [], fill: [] }; + + const n = Math.min(kidArr.length, regCount); + for (let i = 0; i < n; i++) { + const info = regs[i]; + const container = info.container(); + const h = info.horizontal(); + const v = info.vertical(); + const key = h === 'stack-center' || h === 'stack-fill' ? 'start' : ( + h === 'start' || h === 'center' || h === 'end' || h === 'fill' ? h : 'start' + ); + + if (container === 'background') { + bg[key].push(kidArr[i]); + } else if (container === 'content') { + content[key].push(kidArr[i]); + } else { + items[key].push(kidArr[i]); + } + + /* Track stack mode per-bucket — if any child in a bucket has + stack-center/stack-fill, the whole bucket switches to column. */ + if (v === 'stack-center' && modes[key] === 'none') modes[key] = 'stack-center'; + if (v === 'stack-fill') modes[key] = 'stack-fill'; + } + + for (let i = regCount; i < kidArr.length; i++) { + items.start.push(kidArr[i]); + } + + return { items, modes, bg, content }; + }); + + const b = buckets(); + + return ( +
+
+ +
+ + {renderOverlay('positioner-bg', b.bg, props.spacing)} + {renderOverlay('positioner-content', b.content, props.spacing)} +
+ ); +} + +function renderOverlay(cls: string, group: BucketGroup, spacing?: number) { + const hasItems = BUCKET_KEYS.some((k) => group[k].length > 0); + if (!hasItems) return null; + + return ( +
+ {BUCKET_KEYS.map((key) => ( + + ))} +
+ ); +} + +function BucketRow(props: { + group: BucketGroup; + modes: BucketModes; + spacing?: number; +}) { + return ( + <> + {BUCKET_KEYS.map((key) => ( + + ))} + + ); +} + +function PositionerBucket(props: { + kids: JSX.Element[]; + class: string; + fill?: boolean; + column?: boolean; + align?: string; +}) { + return ( +
+ {props.kids} +
+ ); +} diff --git a/packages/solid-plugin/src/StyledRect.tsx b/packages/solid-plugin/src/StyledRect.tsx index dd90be7..9273da6 100644 --- a/packages/solid-plugin/src/StyledRect.tsx +++ b/packages/solid-plugin/src/StyledRect.tsx @@ -8,6 +8,7 @@ export type StyledRectElement = keyof JSX.IntrinsicElements; export interface StyledRectProps { style?: UnionStyleProps; + css?: JSX.CSSProperties; as?: StyledRectElement; class?: string; children?: JSX.Element; @@ -31,19 +32,8 @@ function styleFromUnionProps(props: UnionStyleProps | undefined): JSX.CSSPropert if (props.paddingBottom !== undefined) s['padding-bottom'] = toPx(props.paddingBottom); if (props.paddingLeft !== undefined) s['padding-left'] = toPx(props.paddingLeft); - /* Inset: spacing around the background layer. Single-div can't faithfully - separate inset from padding/margin — multi-div overlay from design doc - not yet implemented. For now, prefer padding over margin semantics - since inset shrinks background around content. */ - if (props.inset !== undefined) s.margin = toPx(props.inset); - if (props.insetTop !== undefined) s['margin-top'] = toPx(props.insetTop); - if (props.insetRight !== undefined) s['margin-right'] = toPx(props.insetRight); - if (props.insetBottom !== undefined) s['margin-bottom'] = toPx(props.insetBottom); - if (props.insetLeft !== undefined) s['margin-left'] = toPx(props.insetLeft); - - /* Margin: spacing between sub-elements. Applied after inset so it - takes precedence when both are set on the same element — theme - authors should not set both for the same element. */ + /* Margin: spacing between sub-elements. The union css "inset" property (background + container shrink) is wholly owned by the Positioner — StyledRect never maps it. */ if (props.margin !== undefined) s.margin = toPx(props.margin); if (props.marginTop !== undefined) s['margin-top'] = toPx(props.marginTop); if (props.marginRight !== undefined) s['margin-right'] = toPx(props.marginRight); @@ -110,34 +100,72 @@ function styleFromUnionProps(props: UnionStyleProps | undefined): JSX.CSSPropert s['box-shadow'] = parts.join(' '); } - /* Outline as inset box-shadow — check for uniform (all four sides equal) */ - const hasLeftOutline = props.outlineLeftSize !== undefined && props.outlineLeftColor; - const hasRightOutline = props.outlineRightSize !== undefined && props.outlineRightColor; - const hasTopOutline = props.outlineTopSize !== undefined && props.outlineTopColor; - const hasBottomOutline = props.outlineBottomSize !== undefined && props.outlineBottomColor; - const allOutlineSides = hasLeftOutline && hasRightOutline && hasTopOutline && hasBottomOutline; - const uniformOutline = allOutlineSides - && props.outlineLeftSize === props.outlineRightSize - && props.outlineLeftSize === props.outlineTopSize - && props.outlineLeftSize === props.outlineBottomSize - && props.outlineLeftColor === props.outlineRightColor - && props.outlineLeftColor === props.outlineTopColor - && props.outlineLeftColor === props.outlineBottomColor; - - const outlineShadows: string[] = []; - if (uniformOutline) { - outlineShadows.push(`inset 0 0 0 ${toPx(props.outlineLeftSize)} ${props.outlineLeftColor}`); - } else { - if (hasLeftOutline) outlineShadows.push(`inset ${toPx(props.outlineLeftSize)} 0 0 0 ${props.outlineLeftColor}`); - if (hasTopOutline) outlineShadows.push(`inset 0 ${toPx(props.outlineTopSize)} 0 0 ${props.outlineTopColor}`); - if (hasRightOutline) outlineShadows.push(`inset 0 0 ${toPx(props.outlineRightSize)} 0 ${props.outlineRightColor}`); - if (hasBottomOutline) outlineShadows.push(`inset 0 0 0 ${toPx(props.outlineBottomSize)} ${props.outlineBottomColor}`); + /* Outline — flattened to native CSS `outline`. + * + * TODO: Per-side outline (+ expanded corner radius per adjacent widths) + * requires the pseudo-element approach from styledrect-css-mapping-design(1).md §4.6: + * + * outline pseudo-element { + * content: ""; + * position: absolute; + * pointer-events: none; + * inset: -Npx; + * border: Npx solid color; + * border-radius: calc(R + min(Nadj))px; + * } + * + * Union expresses outline as per-side widths/colors/styles, but CSS + * `outline` is inherently uniform. For temporary simplicity, we flatten + * all sides into a single uniform outline by picking the first side + * with a defined width. Per-side differences are dropped at this layer. + */ + const outlineW = props.outlineLeftSize ?? props.outlineRightSize ?? props.outlineTopSize ?? props.outlineBottomSize; + if (outlineW !== undefined) { + const outlineC = props.outlineLeftColor ?? props.outlineRightColor ?? props.outlineTopColor ?? props.outlineBottomColor; + const outlineS = props.outlineLeftStyle ?? props.outlineRightStyle ?? props.outlineTopStyle ?? props.outlineBottomStyle; + s.outline = [toPx(outlineW), outlineS ?? 'solid', outlineC].filter(Boolean).join(' '); + s['outline-offset'] = '0px'; + } else if (props.outline) { + s.outline = props.outline; + s['outline-offset'] = '0px'; + s['outline-style'] = 'solid'; } - if (outlineShadows.length > 0) { - const existing = s['box-shadow'] || ''; - s['box-shadow'] = existing - ? `${existing}, ${outlineShadows.join(', ')}` - : outlineShadows.join(', '); + + /* Positioner child — self-applied layout properties. + * These are no-ops outside a flex container (when not inside a ). */ + if (props.layoutAlignmentOrder !== undefined) s.order = props.layoutAlignmentOrder; + if (props.layoutAlignmentVertical) { + const v = props.layoutAlignmentVertical; + s['align-self'] = v === 'start' ? 'flex-start' + : v === 'center' ? 'center' + : v === 'end' ? 'flex-end' + : v === 'fill' ? 'stretch' + : undefined; + } + + return s; +} + +/* ── Positioner child style utility ─────────────────────────── */ + +export function positionerChildStyle(props: UnionStyleProps | undefined): JSX.CSSProperties { + if (!props) return {}; + const s: JSX.CSSProperties = {}; + + if (props.margin !== undefined) s.margin = toPx(props.margin); + if (props.marginTop !== undefined) s['margin-top'] = toPx(props.marginTop); + if (props.marginRight !== undefined) s['margin-right'] = toPx(props.marginRight); + if (props.marginBottom !== undefined) s['margin-bottom'] = toPx(props.marginBottom); + if (props.marginLeft !== undefined) s['margin-left'] = toPx(props.marginLeft); + + if (props.layoutAlignmentOrder !== undefined) s.order = props.layoutAlignmentOrder; + if (props.layoutAlignmentVertical) { + const v = props.layoutAlignmentVertical; + s['align-self'] = v === 'start' ? 'flex-start' + : v === 'center' ? 'center' + : v === 'end' ? 'flex-end' + : v === 'fill' ? 'stretch' + : undefined; } return s; @@ -147,10 +175,12 @@ export function StyledRect(props: StyledRectProps) { const inlineStyle = createMemo(() => { const base: JSX.CSSProperties = { position: 'relative', + 'overflow': 'visible', display: 'flex', - 'flex-direction': 'column', 'box-sizing': 'border-box', + 'background-clip': 'padding-box', ...styleFromUnionProps(props.style), + ...props.css, }; /* Merge style: directive overrides used by call sites */ for (const key of Object.keys(props)) { @@ -165,7 +195,7 @@ export function StyledRect(props: StyledRectProps) { }); const passthrough = () => { - const { style: _, as: _as, children: _c, class: _cl, ...rest } = props; + const { style: _, css: _css, as: _as, children: _c, class: _cl, ...rest } = props; const clean: Record = {}; for (const key of Object.keys(rest)) { if (!key.startsWith('style:')) { diff --git a/packages/solid-plugin/src/context.ts b/packages/solid-plugin/src/context.ts index 4ecc159..5fc3b45 100644 --- a/packages/solid-plugin/src/context.ts +++ b/packages/solid-plugin/src/context.ts @@ -20,3 +20,18 @@ export interface UnionAncestorData { } export const UnionAncestorContext = createContext({ elements: [] }); + +/* Positioner */ + +export interface PositionerChildInfo { + horizontal: () => string; + vertical: () => string; + container: () => string; +} + +export interface PositionerCtx { + register(info: PositionerChildInfo): void; + unregister(info: PositionerChildInfo): void; +} + +export const PositionerContext = createContext(null); diff --git a/packages/solid-plugin/src/index.ts b/packages/solid-plugin/src/index.ts index 6b98973..c8c2648 100644 --- a/packages/solid-plugin/src/index.ts +++ b/packages/solid-plugin/src/index.ts @@ -1,15 +1,17 @@ /* Core infrastructure */ export { UnionIRProvider } from './UnionIRProvider.js'; export type { UnionIRProviderProps } from './UnionIRProvider.js'; -export { UnionIRContext, UnionAncestorContext } from './context.js'; -export type { UnionIR, UnionAncestorData, AncestorElementData } from './context.js'; +export { UnionIRContext, UnionAncestorContext, PositionerContext } from './context.js'; +export type { UnionIR, UnionAncestorData, AncestorElementData, PositionerCtx, PositionerChildInfo } from './context.js'; export { useUnion } from './useUnion.js'; export type { UseUnionOptions, UseUnionResult } from './useUnion.js'; export { styleGroupToUnionStyleProps } from '@union-web/core'; export type { UnionStyleProps } from '@union-web/core'; export { toPx } from './utils.js'; -export { StyledRect } from './StyledRect.js'; +export { StyledRect, positionerChildStyle } from './StyledRect.js'; export type { StyledRectProps, StyledRectElement } from './StyledRect.js'; +export { Positioner } from './Positioner.js'; +export type { PositionerProps } from './Positioner.js'; /* Components */ export { ApplicationHeader } from './ApplicationHeader.js'; diff --git a/packages/solid-plugin/src/useUnion.ts b/packages/solid-plugin/src/useUnion.ts index 40503aa..346f110 100644 --- a/packages/solid-plugin/src/useUnion.ts +++ b/packages/solid-plugin/src/useUnion.ts @@ -1,7 +1,7 @@ -import { useContext, createMemo, createComponent } from 'solid-js'; +import { useContext, createMemo, createComponent, onCleanup } from 'solid-js'; import { createElement, resolveElement, matchRules } from '@union-web/core'; import type { StylePropertyGroup } from '@union-web/core'; -import { UnionIRContext, UnionAncestorContext } from './context.js'; +import { UnionIRContext, UnionAncestorContext, PositionerContext } from './context.js'; import type { AncestorElementData } from './context.js'; const DEFAULT_STATES = [ @@ -189,6 +189,18 @@ export function useUnion(options: UseUnionOptions): UseUnionResult { console.groupEnd(); } + /* Auto-register with parent Positioner if one exists */ + const posCtx = useContext(PositionerContext); + if (posCtx) { + const info = { + horizontal: () => resolvedCurrent().layout?.alignment?.horizontal ?? 'start', + vertical: () => resolvedCurrent().layout?.alignment?.vertical ?? 'center', + container: () => resolvedCurrent().layout?.alignment?.container ?? 'item', + }; + posCtx.register(info); + onCleanup(() => posCtx.unregister(info)); + } + return { styles, current: resolvedCurrent,