Fix renderable.add with index (#222)

Now delegating to and using similar logic as `insertBefore`
This commit is contained in:
Sebastian
2025-10-21 17:30:41 +02:00
committed by GitHub
parent d9165171d8
commit 169872019f
5 changed files with 828 additions and 26 deletions
+26 -26
View File
@@ -1042,40 +1042,38 @@ export abstract class Renderable extends BaseRenderable {
return -1
}
if (this.renderableMapById.has(renderable.id)) {
console.warn(`A renderable with id ${renderable.id} already exists in ${this.id}, removing it`)
this.remove(renderable.id)
const anchorRenderable = index !== undefined ? this._childrenInLayoutOrder[index] : undefined
if (anchorRenderable) {
return this.insertBefore(renderable, anchorRenderable)
}
this.replaceParent(renderable)
const childLayoutNode = renderable.getLayoutNode()
let insertedIndex: number
if (index !== undefined) {
insertedIndex = Math.max(0, Math.min(index, this._childrenInLayoutOrder.length))
this._childrenInLayoutOrder.splice(index, 0, renderable)
this._forceLayoutUpdateFor = this._childrenInLayoutOrder.slice(index)
this.yogaNode.insertChild(childLayoutNode, insertedIndex)
if (renderable.parent === this) {
this.yogaNode.removeChild(renderable.getLayoutNode())
this._childrenInLayoutOrder.splice(this._childrenInLayoutOrder.indexOf(renderable), 1)
} else {
insertedIndex = this._childrenInLayoutOrder.length
this._childrenInLayoutOrder.push(renderable)
this.yogaNode.insertChild(childLayoutNode, insertedIndex)
}
this.replaceParent(renderable)
this.needsZIndexSort = true
this.renderableMapById.set(renderable.id, renderable)
this._childrenInZIndexOrder.push(renderable)
this.needsZIndexSort = true
this.childrenPrimarySortDirty = true
this.renderableMapById.set(renderable.id, renderable)
this._childrenInZIndexOrder.push(renderable)
if (typeof renderable.onLifecyclePass === "function") {
this._ctx.registerLifecyclePass(renderable)
}
if (typeof renderable.onLifecyclePass === "function") {
this._ctx.registerLifecyclePass(renderable)
if (renderable._liveCount > 0) {
this.propagateLiveCount(renderable._liveCount)
}
}
this._newChildren.push(renderable)
if (renderable._liveCount > 0) {
this.propagateLiveCount(renderable._liveCount)
}
const childLayoutNode = renderable.getLayoutNode()
const insertedIndex = this._childrenInLayoutOrder.length
this._childrenInLayoutOrder.push(renderable)
this.yogaNode.insertChild(childLayoutNode, insertedIndex)
this.childrenPrimarySortDirty = true
this.requestRender()
@@ -1147,6 +1145,8 @@ export abstract class Renderable extends BaseRenderable {
this._childrenInLayoutOrder.splice(insertedIndex, 0, renderable)
this.yogaNode.insertChild(renderable.getLayoutNode(), insertedIndex)
this.requestRender()
return insertedIndex
}
@@ -1524,7 +1524,7 @@ export class RootRenderable extends Renderable {
public render(buffer: OptimizedBuffer, deltaTime: number): void {
if (!this.visible) return
// console.log("RootRenderable render", this.width, this.height)
// 0. Run lifecycle pass
for (const renderable of this._ctx.getLifecyclePasses()) {
renderable.onLifecyclePass?.call(renderable)
@@ -71,4 +71,456 @@ describe("Renderable - insertBefore", () => {
const reorderedFrame = captureFrame()
expect(reorderedFrame).toMatchSnapshot("insertBefore reordered state")
})
test("ensure .add with index works correctly", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 20,
height: 10,
})
// Create 5 text renderables in order
const items = [
new TextRenderable(testRenderer, { id: "order-1", content: "First" }),
new TextRenderable(testRenderer, { id: "order-2", content: "Second" }),
new TextRenderable(testRenderer, { id: "order-3", content: "Third" }),
new TextRenderable(testRenderer, { id: "order-4", content: "Fourth" }),
new TextRenderable(testRenderer, { id: "order-5", content: "Fifth" }),
]
// Add items in initial order [1, 2, 3, 4, 5]
for (const item of items) {
container.add(item)
}
testRenderer.root.add(container)
await renderOnce()
let children = container.getChildren()
expect(children.length).toBe(5)
expect(children[0]?.id).toBe("order-1")
expect(children[1]?.id).toBe("order-2")
expect(children[2]?.id).toBe("order-3")
expect(children[3]?.id).toBe("order-4")
expect(children[4]?.id).toBe("order-5")
// Reproduce the EXACT sequence from SolidJS reconciler output:
container.add(items[4]!, 1) // order-5 at index 1
container.add(items[0]!) // order-1 at index undefined
container.add(items[3]!, 2) // order-4 at index 2
container.add(items[1]!, 4) // order-2 at index 4
await renderOnce()
children = container.getChildren()
// Expected: [5, 4, 3, 2, 1]
expect(children.length).toBe(5)
expect(children[0]?.id).toBe("order-5")
expect(children[1]?.id).toBe("order-4")
expect(children[2]?.id).toBe("order-3")
expect(children[3]?.id).toBe("order-2")
expect(children[4]?.id).toBe("order-1")
})
})
describe("Renderable - add method", () => {
test("basic add appends to end", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item2)
container.add(item3)
const children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-1")
expect(children[1]?.id).toBe("item-2")
expect(children[2]?.id).toBe("item-3")
})
test("add with index 0 inserts at beginning", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item2)
container.add(item3, 0) // Insert at beginning
const children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-3")
expect(children[1]?.id).toBe("item-1")
expect(children[2]?.id).toBe("item-2")
})
test("add with middle index inserts correctly", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item2)
container.add(item3, 1) // Insert in middle
const children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-1")
expect(children[1]?.id).toBe("item-3")
expect(children[2]?.id).toBe("item-2")
})
test("add with large index appends to end", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item2)
container.add(item3, 999) // Out of bounds index
const children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-1")
expect(children[1]?.id).toBe("item-2")
expect(children[2]?.id).toBe("item-3")
})
test("add returns correct index", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
const idx1 = container.add(item1)
const idx2 = container.add(item2)
const idx3 = container.add(item3, 1)
expect(idx1).toBe(0)
expect(idx2).toBe(1)
expect(idx3).toBe(1) // Inserted at index 1
})
test("add null/undefined returns -1", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const idx1 = container.add(null as any)
const idx2 = container.add(undefined as any)
expect(idx1).toBe(-1)
expect(idx2).toBe(-1)
expect(container.getChildrenCount()).toBe(0)
})
test("re-adding existing child moves it", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item2)
container.add(item3)
// Re-add item1 to end
container.add(item1)
let children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-2")
expect(children[1]?.id).toBe("item-3")
expect(children[2]?.id).toBe("item-1")
// Re-add item3 to beginning
container.add(item3, 0)
children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-3")
expect(children[1]?.id).toBe("item-2")
expect(children[2]?.id).toBe("item-1")
})
test("adding child from another parent removes it from old parent", async () => {
const container1 = new BoxRenderable(testRenderer, {
id: "container-1",
width: 10,
height: 10,
})
const container2 = new BoxRenderable(testRenderer, {
id: "container-2",
width: 10,
height: 10,
})
const item = new TextRenderable(testRenderer, { id: "item", content: "A" })
container1.add(item)
expect(container1.getChildrenCount()).toBe(1)
expect(item.parent).toBe(container1)
container2.add(item)
expect(container1.getChildrenCount()).toBe(0)
expect(container2.getChildrenCount()).toBe(1)
expect(item.parent).toBe(container2)
})
})
describe("Renderable - insertBefore method", () => {
test("insertBefore with null anchor appends to end", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item2)
container.insertBefore(item3, null as any)
const children = container.getChildren()
expect(children.length).toBe(3)
expect(children[2]?.id).toBe("item-3")
})
test("insertBefore inserts at correct position", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item3)
container.insertBefore(item2, item3) // Insert item2 before item3
const children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-1")
expect(children[1]?.id).toBe("item-2")
expect(children[2]?.id).toBe("item-3")
})
test("insertBefore at beginning", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item2)
container.insertBefore(item3, item1) // Insert before first item
const children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-3")
expect(children[1]?.id).toBe("item-1")
expect(children[2]?.id).toBe("item-2")
})
test("insertBefore moves existing child", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item2)
container.add(item3)
// Move item3 before item1
container.insertBefore(item3, item1)
let children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-3")
expect(children[1]?.id).toBe("item-1")
expect(children[2]?.id).toBe("item-2")
// Move item1 before item2
container.insertBefore(item1, item2)
children = container.getChildren()
expect(children.length).toBe(3)
expect(children[0]?.id).toBe("item-3")
expect(children[1]?.id).toBe("item-1")
expect(children[2]?.id).toBe("item-2")
})
test("insertBefore with invalid anchor throws error", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const notAChild = new TextRenderable(testRenderer, { id: "not-child", content: "X" })
container.add(item1)
expect(() => {
container.insertBefore(item2, notAChild)
}).toThrow("Anchor does not exist")
})
test("insertBefore returns correct index", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
container.add(item1)
container.add(item3)
const idx = container.insertBefore(item2, item3)
expect(idx).toBe(1)
})
test("insertBefore with null object returns -1", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const anchor = new TextRenderable(testRenderer, { id: "anchor", content: "A" })
container.add(anchor)
const idx = container.insertBefore(null as any, anchor)
expect(idx).toBe(-1)
})
test("complex reordering scenario", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const items = [
new TextRenderable(testRenderer, { id: "A", content: "A" }),
new TextRenderable(testRenderer, { id: "B", content: "B" }),
new TextRenderable(testRenderer, { id: "C", content: "C" }),
new TextRenderable(testRenderer, { id: "D", content: "D" }),
new TextRenderable(testRenderer, { id: "E", content: "E" }),
]
// Initial: A, B, C, D, E
items.forEach((item) => container.add(item))
let children = container.getChildren()
expect(children.map((c) => c.id)).toEqual(["A", "B", "C", "D", "E"])
// Move E before B: A, E, B, C, D
container.insertBefore(items[4]!, items[1]!)
children = container.getChildren()
expect(children.map((c) => c.id)).toEqual(["A", "E", "B", "C", "D"])
// Move A before D: E, B, C, A, D
container.insertBefore(items[0]!, items[3]!)
children = container.getChildren()
expect(children.map((c) => c.id)).toEqual(["E", "B", "C", "A", "D"])
// Move C before E: C, E, B, A, D
container.insertBefore(items[2]!, items[4]!)
children = container.getChildren()
expect(children.map((c) => c.id)).toEqual(["C", "E", "B", "A", "D"])
})
test("multiple sequential adds and inserts", async () => {
const container = new BoxRenderable(testRenderer, {
id: "container",
width: 10,
height: 10,
})
const items = [
new TextRenderable(testRenderer, { id: "1", content: "1" }),
new TextRenderable(testRenderer, { id: "2", content: "2" }),
new TextRenderable(testRenderer, { id: "3", content: "3" }),
new TextRenderable(testRenderer, { id: "4", content: "4" }),
]
container.add(items[0]!)
container.add(items[1]!)
expect(container.getChildren().map((c) => c.id)).toEqual(["1", "2"])
container.insertBefore(items[2]!, items[1]!)
expect(container.getChildren().map((c) => c.id)).toEqual(["1", "3", "2"])
container.add(items[3]!, 0)
expect(container.getChildren().map((c) => c.id)).toEqual(["4", "1", "3", "2"])
// Move "2" before "4"
container.insertBefore(items[1]!, items[3]!)
expect(container.getChildren().map((c) => c.id)).toEqual(["2", "4", "1", "3"])
})
})
@@ -3,6 +3,7 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createSignal, Match, onMount, Switch } from "solid-js"
import { Session } from "../session.tsx"
import { SplitModeDemo } from "./animation-demo.tsx"
import AutocompleteDemo from "./autocomplete-demo.tsx"
import { CodeDemo } from "./code-demo.tsx"
import ExtendDemo from "./extend-demo.tsx"
import InputScene from "./input-demo.tsx"
@@ -29,6 +30,11 @@ const EXAMPLES = [
description: "Interactive InputElement demo with validation and multiple fields",
scene: "input-demo",
},
{
name: "Autocomplete Demo",
description: "@ mention autocomplete with keyboard navigation",
scene: "autocomplete-demo",
},
{
name: "Mouse demo",
description: "Mouse interaction",
@@ -135,6 +141,9 @@ const ExampleSelector = () => {
<Match when={selectedScene() === "input-demo"}>
<InputScene />
</Match>
<Match when={selectedScene() === "autocomplete-demo"}>
<AutocompleteDemo />
</Match>
<Match when={selectedScene() === "mouse-demo"}>
<MouseScene />
</Match>
@@ -0,0 +1,270 @@
import { type InputRenderable, type BoxRenderable, type KeyEvent, TextAttributes } from "@opentui/core"
import { createSignal, createMemo, For, Show, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { useRenderer } from "@opentui/solid"
type AutocompleteOption = {
display: string
description?: string
}
const SAMPLE_OPTIONS: AutocompleteOption[] = [
{ display: "alice", description: "Alice Johnson" },
{ display: "bob", description: "Bob Smith" },
{ display: "charlie", description: "Charlie Brown" },
{ display: "diana", description: "Diana Prince" },
{ display: "eve", description: "Eve Anderson" },
{ display: "frank", description: "Frank Miller" },
{ display: "grace", description: "Grace Hopper" },
{ display: "henry", description: "Henry Ford" },
{ display: "iris", description: "Iris Chang" },
{ display: "jack", description: "Jack Dorsey" },
{ display: "karen", description: "Karen Walker" },
{ display: "leo", description: "Leo Martinez" },
{ display: "maria", description: "Maria Garcia" },
{ display: "noah", description: "Noah Wilson" },
{ display: "olivia", description: "Olivia Taylor" },
{ display: "peter", description: "Peter Parker" },
{ display: "quinn", description: "Quinn Roberts" },
{ display: "rachel", description: "Rachel Green" },
{ display: "sam", description: "Sam Anderson" },
{ display: "tina", description: "Tina Turner" },
{ display: "uma", description: "Uma Thurman" },
{ display: "victor", description: "Victor Hugo" },
{ display: "wendy", description: "Wendy Williams" },
{ display: "xavier", description: "Xavier Thompson" },
{ display: "yuki", description: "Yuki Tanaka" },
{ display: "zoe", description: "Zoe Chen" },
{ display: "adam", description: "Adam Davis" },
{ display: "bella", description: "Bella Rodriguez" },
{ display: "carlos", description: "Carlos Sanchez" },
{ display: "derek", description: "Derek Lee" },
{ display: "emma", description: "Emma Watson" },
{ display: "felix", description: "Felix White" },
{ display: "gina", description: "Gina Lopez" },
{ display: "harry", description: "Harry Potter" },
{ display: "isla", description: "Isla Fisher" },
{ display: "james", description: "James Bond" },
{ display: "kate", description: "Kate Middleton" },
{ display: "luke", description: "Luke Skywalker" },
{ display: "maya", description: "Maya Angelou" },
{ display: "nick", description: "Nick Fury" },
{ display: "oscar", description: "Oscar Wilde" },
{ display: "paul", description: "Paul McCartney" },
{ display: "queenie", description: "Queenie Goldstein" },
{ display: "ryan", description: "Ryan Reynolds" },
{ display: "sara", description: "Sara Connor" },
{ display: "tony", description: "Tony Stark" },
{ display: "ursula", description: "Ursula Le Guin" },
{ display: "vera", description: "Vera Wang" },
{ display: "will", description: "Will Smith" },
{ display: "xena", description: "Xena Warrior" },
{ display: "yasmin", description: "Yasmin Khan" },
{ display: "zack", description: "Zack Morris" },
{ display: "amber", description: "Amber Heard" },
{ display: "blake", description: "Blake Lively" },
{ display: "chris", description: "Chris Evans" },
{ display: "donna", description: "Donna Noble" },
{ display: "ethan", description: "Ethan Hunt" },
{ display: "fiona", description: "Fiona Apple" },
{ display: "george", description: "George Clooney" },
{ display: "hannah", description: "Hannah Montana" },
{ display: "ivan", description: "Ivan Drago" },
{ display: "julia", description: "Julia Roberts" },
{ display: "keith", description: "Keith Richards" },
{ display: "linda", description: "Linda Hamilton" },
{ display: "mark", description: "Mark Zuckerberg" },
{ display: "nina", description: "Nina Simone" },
{ display: "oliver", description: "Oliver Twist" },
{ display: "penny", description: "Penny Lane" },
{ display: "quincy", description: "Quincy Jones" },
{ display: "rose", description: "Rose Tyler" },
{ display: "steve", description: "Steve Jobs" },
{ display: "tracy", description: "Tracy Chapman" },
{ display: "umar", description: "Umar Johnson" },
{ display: "violet", description: "Violet Baudelaire" },
{ display: "wade", description: "Wade Wilson" },
{ display: "xander", description: "Xander Harris" },
{ display: "yvonne", description: "Yvonne Strahovski" },
{ display: "zeus", description: "Zeus King" },
]
const AutocompleteDemo = () => {
const renderer = useRenderer()
let input: InputRenderable
let anchor: BoxRenderable
const [inputValue, setInputValue] = createSignal("")
const [store, setStore] = createStore({
visible: false,
selected: 0,
index: 0,
position: { x: 0, y: 0, width: 0 },
})
const filter = createMemo(() => {
if (!store.visible) return ""
return inputValue().substring(store.index + 1)
})
const options = createMemo(() => {
const filterText = filter().toLowerCase()
if (!filterText) return SAMPLE_OPTIONS.slice(0, 8)
return SAMPLE_OPTIONS.filter(
(opt) => opt.display.toLowerCase().includes(filterText) || opt.description?.toLowerCase().includes(filterText),
).slice(0, 8)
})
const height = createMemo(() => {
if (options().length) return Math.min(8, options().length)
return 1
})
function move(direction: -1 | 1) {
if (!store.visible) return
if (!options().length) return
let next = store.selected + direction
if (next < 0) next = options().length - 1
if (next >= options().length) next = 0
setStore("selected", next)
}
function select() {
const selected = options()[store.selected]
if (!selected) return
const newValue = inputValue().slice(0, store.index) + "@" + selected.display + " "
setInputValue(newValue)
input.value = newValue
input.cursorPosition = newValue.length
hide()
}
function show() {
setStore({
visible: true,
selected: 0,
index: input.cursorPosition,
position: {
x: anchor.x,
y: anchor.y,
width: anchor.width,
},
})
}
function hide() {
setStore("visible", false)
}
function handleKeyDown(e: KeyEvent) {
if (store.visible) {
if (e.name === "up") {
e.preventDefault()
move(-1)
}
if (e.name === "down") {
e.preventDefault()
move(1)
}
if (e.name === "escape") {
e.preventDefault()
hide()
}
if (e.name === "return") {
e.preventDefault()
select()
}
} else {
if (e.name === "@") {
const last = inputValue().at(-1)
if (last === " " || last === undefined) {
show()
}
}
}
}
function handleInput(value: string) {
setInputValue(value)
if (store.visible && value.length <= store.index) {
hide()
}
}
onMount(() => {
renderer.setBackgroundColor("#1a1b26")
input.focus()
})
return (
<box height="100%" width="100%" flexDirection="column" gap={1} padding={2}>
<box>
<text attributes={TextAttributes.BOLD} fg="#7aa2f7">
Autocomplete Demo
</text>
<text attributes={TextAttributes.DIM} fg="#9aa5ce">
Type @ to trigger autocomplete. Use arrow keys to navigate, Enter to select.
</text>
</box>
<box ref={(r) => (anchor = r)} flexDirection="column">
<box border borderColor="#3b4261" padding={1}>
<input
ref={(r) => (input = r)}
value={inputValue()}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder="Type @ to mention someone..."
cursorColor="#7aa2f7"
backgroundColor="#1a1b26"
focusedBackgroundColor="#1a1b26"
/>
</box>
{/* Autocomplete popup */}
<box
visible={store.visible}
position="absolute"
top={store.position.y - height()}
left={store.position.x}
width={store.position.width}
zIndex={100}
border
borderColor="#7aa2f7"
>
<box backgroundColor="#24283b" height={height()}>
<For
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg="#9aa5ce">No matching items</text>
</box>
}
>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === store.selected ? "#7aa2f7" : undefined}
flexDirection="row"
>
<text fg={index() === store.selected ? "#1a1b26" : "#c0caf5"}>@{option.display}</text>
<Show when={option.description}>
<text fg={index() === store.selected ? "#1a1b26" : "#9aa5ce"}> - {option.description}</text>
</Show>
</box>
)}
</For>
</box>
</box>
</box>
<box marginTop={2}>
<text fg="#9aa5ce">Current input: </text>
<text fg="#c0caf5">{inputValue() || "(empty)"}</text>
</box>
</box>
)
}
export default AutocompleteDemo
@@ -755,5 +755,76 @@ describe("SolidJS Renderer - Control Flow Components", () => {
expect(anotherVisible).toBeDefined()
expect(anotherVisible?.id).toBe("another-visible")
})
it("REPRODUCE BUG: For component has incorrect ordering after array reordering", async () => {
interface Option {
id: string
display: string
description?: string
}
const [options, setOptions] = createSignal<Option[]>([])
testSetup = await testRender(
() => (
<box id="container">
<For each={options()}>
{(option, index) => (
<box id={`option-${option.id}`}>
<text>
{option.display}
<Show when={option.description}>
<span> - {option.description}</span>
</Show>
</text>
</box>
)}
</For>
</box>
),
{ width: 50, height: 25 },
)
await testSetup.renderOnce()
// === BUG: Array reversal causes incorrect ordering ===
const orderedItems = [
{ id: "order-1", display: "First" },
{ id: "order-2", display: "Second" },
{ id: "order-3", display: "Third" },
{ id: "order-4", display: "Fourth" },
{ id: "order-5", display: "Fifth" },
]
setOptions(orderedItems)
await testSetup.renderOnce()
const container = testSetup.renderer.root.findDescendantById("container")!
let children = container.getChildren()
// Verify initial order
expect(children.length).toBe(5)
expect(children[0]?.id).toBe("option-order-1")
expect(children[1]?.id).toBe("option-order-2")
expect(children[2]?.id).toBe("option-order-3")
expect(children[3]?.id).toBe("option-order-4")
expect(children[4]?.id).toBe("option-order-5")
// Reverse the array - THIS EXPOSES THE BUG
setOptions([...orderedItems].reverse())
await testSetup.renderOnce()
children = container.getChildren()
// BUG: The order is INCORRECT after reversing!
// Expected: [order-5, order-4, order-3, order-2, order-1]
// Actual might have swapped elements
expect(children.length).toBe(5)
expect(children[0]?.id).toBe("option-order-5")
expect(children[1]?.id).toBe("option-order-4")
expect(children[2]?.id).toBe("option-order-3")
expect(children[3]?.id).toBe("option-order-2") // ← BUG: This might be order-1
expect(children[4]?.id).toBe("option-order-1") // ← BUG: This might be order-2
})
})
})