Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/solid/bunfig.toml
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
preload = ["@opentui/solid/preload"]

[test]
preload = ["@opentui/solid/preload"]
root = "tests"
2 changes: 1 addition & 1 deletion packages/solid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"scripts": {
"build": "bun scripts/build.ts",
"publish": "bun scripts/publish.ts",
"test": "bun --conditions=browser --preload=./scripts/preload.ts test"
"test": "bun test"
},
"exports": {
".": {
Expand Down
98 changes: 98 additions & 0 deletions packages/solid/src/elements/extras.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { createEffect, createMemo, getOwner, onCleanup, runWithOwner, splitProps, untrack } from "solid-js"
import { createSlotNode, createElement, insert, spread, type DomNode } from "../reconciler"
import type { JSX } from "../../jsx-runtime"
import type { ValidComponent, ComponentProps } from "solid-js"
import { useRenderer } from "./hooks"

/**
* Renders components somewhere else in the DOM
*
* Useful for inserting modals and tooltips outside of an cropping layout. If no mount point is given, the portal is inserted on the root renderable; it is wrapped in a `<box>`
*
* @description https://docs.solidjs.com/reference/components/portal
*/
export function Portal(props: { mount?: DomNode; ref?: (el: {}) => void; children: JSX.Element }): DomNode {
const renderer = useRenderer()

const marker = createSlotNode(),
mount = () => props.mount || renderer.root,
owner = getOwner()
let content: undefined | (() => JSX.Element)

createEffect(
() => {
// basically we backdoor into a sort of renderEffect here
content || (content = runWithOwner(owner, () => createMemo(() => props.children)))
const el = mount()
const container = createElement("box"),
renderRoot = container

Object.defineProperty(container, "_$host", {
get() {
return marker.parent
},
configurable: true,
})
insert(renderRoot, content)
el.add(container)
props.ref && (props as any).ref(container)
onCleanup(() => el.remove(container.id))
},
undefined,
{ render: true },
)
return marker
}

export type DynamicProps<T extends ValidComponent, P = ComponentProps<T>> = {
[K in keyof P]: P[K]
} & {
component: T | undefined
}

/**
* Renders an arbitrary component or element with the given props
*
* This is a lower level version of the `Dynamic` component, useful for
* performance optimizations in libraries. Do not use this unless you know
* what you are doing.
* ```typescript
* const element = () => multiline() ? 'textarea' : 'input';
* createDynamic(element, { value: value() });
* ```
* @description https://docs.solidjs.com/reference/components/dynamic
*/
export function createDynamic<T extends ValidComponent>(
component: () => T | undefined,
props: ComponentProps<T>,
): JSX.Element {
const cached = createMemo<Function | string | undefined>(component)
return createMemo(() => {
const component = cached()
switch (typeof component) {
case "function":
// if (isDev) Object.assign(component, { [$DEVCOMP]: true })
return untrack(() => component(props))

case "string":
const el = createElement(component)
spread(el, props)
return el

default:
break
}
}) as unknown as JSX.Element
}

/**
* Renders an arbitrary custom or native component and passes the other props
* ```typescript
* <Dynamic component={multiline() ? 'textarea' : 'input'} value={value()} />
* ```
* @description https://docs.solidjs.com/reference/components/dynamic
*/
export function Dynamic<T extends ValidComponent>(props: DynamicProps<T>): JSX.Element {
const [, others] = splitProps(props, ["component"])
return createDynamic(() => props.component, others as ComponentProps<T>)
}
2 changes: 2 additions & 0 deletions packages/solid/src/elements/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
} from "@opentui/core"
import type { RenderableConstructor } from "../types/elements"
export * from "./hooks"
export * from "./extras"
export * from "./slot"

class SpanRenderable extends TextNodeRenderable {
constructor(
Expand Down
130 changes: 130 additions & 0 deletions packages/solid/src/elements/slot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { BaseRenderable, isTextNodeRenderable, TextNodeRenderable, TextRenderable } from "@opentui/core"
import Yoga, { Display, type Node as YogaNode } from "yoga-layout"

class SlotBaseRenderable extends BaseRenderable {
constructor(id: string) {
super({
id,
})
}

public add(obj: BaseRenderable | unknown, index?: number): number {
throw new Error("Can't add children on an Slot renderable")
}

public getChildren(): BaseRenderable[] {
return []
}

public remove(id: string): void {}

public insertBefore(obj: BaseRenderable | unknown, anchor: BaseRenderable | unknown): void {
throw new Error("Can't add children on an Slot renderable")
}

public getRenderable(id: string): BaseRenderable | undefined {
return undefined
}

public getChildrenCount(): number {
return 0
}

public requestRender(): void {}
}

export class TextSlotRenderable extends TextNodeRenderable {
protected slotParent?: SlotRenderable
protected destroyed: boolean = false

constructor(id: string, parent?: SlotRenderable) {
super({ id: id })
this._visible = false
this.slotParent = parent
}

public override destroy(): void {
if (this.destroyed) {
return
}
this.destroyed = true

this.slotParent?.destroy()
super.destroy()
}
}

export class LayoutSlotRenderable extends SlotBaseRenderable {
protected yogaNode: YogaNode
protected slotParent?: SlotRenderable
protected destroyed: boolean = false

constructor(id: string, parent?: SlotRenderable) {
super(id)

this._visible = false
this.slotParent = parent
this.yogaNode = Yoga.Node.create()
this.yogaNode.setDisplay(Display.None)
}

public getLayoutNode(): YogaNode {
return this.yogaNode
}

public updateFromLayout() {}

public updateLayout() {}

public onRemove() {}

public override destroy(): void {
if (this.destroyed) {
return
}
this.destroyed = true

super.destroy()
this.slotParent?.destroy()
}
}

export class SlotRenderable extends SlotBaseRenderable {
layoutNode?: LayoutSlotRenderable
textNode?: TextSlotRenderable
protected destroyed: boolean = false

constructor(id: string) {
super(id)

this._visible = false
}

getSlotChild(parent: BaseRenderable) {
if (isTextNodeRenderable(parent) || parent instanceof TextRenderable) {
if (!this.textNode) {
this.textNode = new TextSlotRenderable(`slot-text-${this.id}`, this)
}
return this.textNode
}

if (!this.layoutNode) {
this.layoutNode = new LayoutSlotRenderable(`slot-layout-${this.id}`, this)
}
return this.layoutNode
}

public override destroy(): void {
if (this.destroyed) {
return
}
this.destroyed = true

if (this.layoutNode) {
this.layoutNode.destroy()
}
if (this.textNode) {
this.textNode.destroy()
}
}
}
50 changes: 41 additions & 9 deletions packages/solid/src/reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import {
type TextNodeOptions,
} from "@opentui/core"
import { useContext } from "solid-js"
import { createRenderer } from "solid-js/universal"
import { getComponentCatalogue, RendererContext } from "./elements"
import { createRenderer } from "./renderer"
import { getComponentCatalogue, RendererContext, SlotRenderable } from "./elements"
import { getNextId } from "./utils/id-counter"
import { log } from "./utils/log"

Expand Down Expand Up @@ -64,18 +64,30 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void {
node instanceof TextNode,
)

if (node instanceof SlotRenderable) {
node = node.getSlotChild(parent)
}

if (anchor && anchor instanceof SlotRenderable) {
anchor = anchor.getSlotChild(parent)
}

if (isTextNodeRenderable(node)) {
if (!(parent instanceof TextRenderable) && !isTextNodeRenderable(parent)) {
// TODO this can happen naturally with match and show, probably should handle better
log(`Text must have a <text> as a parent: ${parent.id} above ${node.id}`)
return
throw new Error(
`Orphan text error: "${node
.toChunks()
.map((c) => c.text)
.join("")}" must have a <text> as a parent: ${parent.id} above ${node.id}`,
)
}
}

// Renderable nodes
if (!(parent instanceof BaseRenderable)) {
log("[INSERT]", "Tried to mount a non base renderable")
return
console.error("[INSERT]", "Tried to mount a non base renderable")
// Can't be a noop, have to panic
throw new Error("Tried to mount a non base renderable")
}

if (!anchor) {
Expand All @@ -96,10 +108,22 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void {
function _removeNode(parent: DomNode, node: DomNode): void {
log("Removing node:", logId(node), "from parent:", logId(parent))

parent.remove(node.id)
if (node instanceof SlotRenderable) {
node = node.getSlotChild(parent)
}

if (isTextNodeRenderable(parent)) {
if (typeof node !== "string" && !isTextNodeRenderable(node)) {
console.warn("Node not a valid child of TextNode")
} else {
parent.remove(node)
}
} else {
parent.remove(node.id)
}

process.nextTick(() => {
if (node instanceof Renderable && !node.parent) {
if (node instanceof BaseRenderable && !node.parent) {
node.destroyRecursively()
return
}
Expand All @@ -118,6 +142,12 @@ function _createTextNode(value: string | number): TextNode {
return TextNode.fromString(value, { id })
}

export function createSlotNode(): SlotRenderable {
const id = getNextId("slot-node")
log("Creating slot node", id)
return new SlotRenderable(id)
}

function _getParentNode(childNode: DomNode): DomNode | undefined {
log("Getting parent of node:", logId(childNode))

Expand Down Expand Up @@ -162,6 +192,8 @@ export const {

createTextNode: _createTextNode,

createSlotNode,

replaceText(textNode: TextNode, value: string): void {
log("Replacing text:", value, "in node:", logId(textNode))

Expand Down
11 changes: 11 additions & 0 deletions packages/solid/src/renderer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createRenderer as createRendererDX } from "./universal.js"
import type { RendererOptions, Renderer } from "./universal.js"
import { mergeProps } from "solid-js"

export type { RendererOptions, Renderer } from "./universal.js"

export function createRenderer<NodeType>(options: RendererOptions<NodeType>): Renderer<NodeType> {
const renderer = createRendererDX(options)
renderer.mergeProps = mergeProps
return renderer
}
30 changes: 30 additions & 0 deletions packages/solid/src/renderer/universal.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface RendererOptions<NodeType> {
createElement(tag: string): NodeType
createTextNode(value: string): NodeType
createSlotNode(): NodeType
replaceText(textNode: NodeType, value: string): void
isTextNode(node: NodeType): boolean
setProperty<T>(node: NodeType, name: string, value: T, prev?: T): void
insertNode(parent: NodeType, node: NodeType, anchor?: NodeType): void
removeNode(parent: NodeType, node: NodeType): void
getParentNode(node: NodeType): NodeType | undefined
getFirstChild(node: NodeType): NodeType | undefined
getNextSibling(node: NodeType): NodeType | undefined
}

export interface Renderer<NodeType> {
render(code: () => NodeType, node: NodeType): () => void
effect<T>(fn: (prev?: T) => T, init?: T): void
memo<T>(fn: () => T, equal: boolean): () => T
createComponent<T>(Comp: (props: T) => NodeType, props: T): NodeType
createElement(tag: string): NodeType
createTextNode(value: string): NodeType
insertNode(parent: NodeType, node: NodeType, anchor?: NodeType): void
insert<T>(parent: any, accessor: (() => T) | T, marker?: any | null, initial?: any): NodeType
spread<T>(node: any, accessor: (() => T) | T, skipChildren?: boolean): void
setProp<T>(node: NodeType, name: string, value: T, prev?: T): T
mergeProps(...sources: unknown[]): unknown
use<A, T>(fn: (element: NodeType, arg: A) => T, element: NodeType, arg: A): T
}

export function createRenderer<NodeType>(options: RendererOptions<NodeType>): Renderer<NodeType>
Loading
Loading