summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-24 11:23:32 -0500
committerAdam <[email protected]>2025-10-24 12:16:33 -0500
commit86447b576490ee4cde0825418015652d8ed26794 (patch)
tree02f0a7780eb7c96d1fa7a801088743a29e265a9d
parentfe8f6d7a3eef34e932bd43d244460d417865de88 (diff)
downloadopencode-86447b576490ee4cde0825418015652d8ed26794.tar.gz
opencode-86447b576490ee4cde0825418015652d8ed26794.zip
wip: desktop work
-rw-r--r--packages/desktop/src/components/diff.tsx256
-rw-r--r--packages/desktop/src/components/prompt-input.tsx90
-rw-r--r--packages/desktop/src/pages/index.tsx69
-rw-r--r--packages/ui/src/components/accordion.css102
-rw-r--r--packages/ui/src/components/accordion.tsx92
-rw-r--r--packages/ui/src/components/collapsible.tsx1
-rw-r--r--packages/ui/src/components/icon.tsx1
-rw-r--r--packages/ui/src/components/index.ts1
-rw-r--r--packages/ui/src/demo.tsx36
-rw-r--r--packages/ui/src/styles/index.css2
10 files changed, 477 insertions, 173 deletions
diff --git a/packages/desktop/src/components/diff.tsx b/packages/desktop/src/components/diff.tsx
index 4667bbb3a..0facaba47 100644
--- a/packages/desktop/src/components/diff.tsx
+++ b/packages/desktop/src/components/diff.tsx
@@ -1,140 +1,150 @@
-import { type FileContents, FileDiff, type DiffLineAnnotation } from "@pierre/precision-diffs"
+import { type FileContents, FileDiff, type DiffLineAnnotation, DiffFileRendererOptions } from "@pierre/precision-diffs"
+import { ComponentProps, createEffect, splitProps } from "solid-js"
-export interface DiffProps {
+export type DiffProps<T = {}> = Omit<DiffFileRendererOptions<T>, "themes"> & {
before: FileContents
after: FileContents
+ annotations?: DiffLineAnnotation<T>[]
+ class?: string
+ classList?: ComponentProps<"div">["classList"]
}
-export function Diff(props: DiffProps) {
- let container!: HTMLDivElement
-
- console.log(props)
+// interface ThreadMetadata {
+// threadId: string
+// }
- interface ThreadMetadata {
- threadId: string
- }
+export function Diff<T>(props: DiffProps<T>) {
+ let container!: HTMLDivElement
+ const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
- const lineAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [
- {
- side: "additions",
- // The line number specified for an annotation is the visual line number
- // you see in the number column of a diff
- lineNumber: 16,
- metadata: { threadId: "68b329da9893e34099c7d8ad5cb9c940" },
- },
- ]
-
- const instance = new FileDiff<ThreadMetadata>({
- // You can provide a 'theme' prop that maps to any
- // built in shiki theme or you can register a custom
- // theme. We also include 2 custom themes
- //
- // 'pierre-night' and 'pierre-light
- //
- // For the rest of the available shiki themes, check out:
- // https://shiki.style/themes
- theme: "none",
- // Or can also provide a 'themes' prop, which allows the code to adapt
- // to your OS light or dark theme
- // themes: { dark: 'pierre-night', light: 'pierre-light' },
- // When using the 'themes' prop, 'themeType' allows you to force 'dark'
- // or 'light' theme, or inherit from the OS ('system') theme.
- themeType: "system",
- // Disable the line numbers for your diffs, generally not recommended
- disableLineNumbers: false,
- // Whether code should 'wrap' with long lines or 'scroll'.
- overflow: "scroll",
- // Normally you shouldn't need this prop, but if you don't provide a
- // valid filename or your file doesn't have an extension you may want to
- // override the automatic detection. You can specify that language here:
- // https://shiki.style/languages
- // lang?: SupportedLanguages;
- // 'diffStyle' controls whether the diff is presented side by side or
- // in a unified (single column) view
- diffStyle: "split",
- // Line decorators to help highlight changes.
- // 'bars' (default):
- // Shows some red-ish or green-ish (theme dependent) bars on the left
- // edge of relevant lines
- //
- // 'classic':
- // shows '+' characters on additions and '-' characters on deletions
- //
- // 'none':
- // No special diff indicators are shown
- diffIndicators: "bars",
- // By default green-ish or red-ish background are shown on added and
- // deleted lines respectively. Disable that feature here
- disableBackground: false,
- // Diffs are split up into hunks, this setting customizes what to show
- // between each hunk.
- //
- // 'line-info' (default):
- // Shows a bar that tells you how many lines are collapsed. If you are
- // using the oldFile/newFile API then you can click those bars to
- // expand the content between them
- //
- // 'metadata':
- // Shows the content you'd see in a normal patch file, usually in some
- // format like '@@ -60,6 +60,22 @@'. You cannot use these to expand
- // hidden content
- //
- // 'simple':
- // Just a subtle bar separator between each hunk
- hunkSeparators: "line-info",
- // On lines that have both additions and deletions, we can run a
- // separate diff check to mark parts of the lines that change.
- // 'none':
- // Do not show these secondary highlights
- //
- // 'char':
- // Show changes at a per character granularity
- //
- // 'word':
- // Show changes but rounded up to word boundaries
- //
- // 'word-alt' (default):
- // Similar to 'word', however we attempt to minimize single character
- // gaps between highlighted changes
- lineDiffType: "word-alt",
- // If lines exceed these character lengths then we won't perform the
- // line lineDiffType check
- maxLineDiffLength: 1000,
- // If any line in the diff exceeds this value then we won't attempt to
- // syntax highlight the diff
- maxLineLengthForHighlighting: 1000,
- // Enabling this property will hide the file header with file name and
- // diff stats.
- disableFileHeader: false,
- // You can optionally pass a render function for rendering out line
- // annotations. Just return the dom node to render
- renderAnnotation(annotation: DiffLineAnnotation<ThreadMetadata>): HTMLElement {
- // Despite the diff itself being rendered in the shadow dom,
- // annotations are inserted via the web components 'slots' api and you
- // can use all your normal normal css and styling for them
- const element = document.createElement("div")
- element.innerText = annotation.metadata.threadId
- return element
- },
- })
+ // const lineAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [
+ // {
+ // side: "additions",
+ // // The line number specified for an annotation is the visual line number
+ // // you see in the number column of a diff
+ // lineNumber: 16,
+ // metadata: { threadId: "68b329da9893e34099c7d8ad5cb9c940" },
+ // },
+ // ]
// If you ever want to update the options for an instance, simple call
// 'setOptions' with the new options. Bear in mind, this does NOT merge
// existing properties, it's a full replace
- instance.setOptions({
- ...instance.options,
- theme: "pierre-dark",
- themes: undefined,
- })
+ // instance.setOptions({
+ // ...instance.options,
+ // theme: "pierre-dark",
+ // themes: undefined,
+ // })
// When ready to render, simply call .render with old/new file, optional
// annotations and a container element to hold the diff
- instance.render({
- oldFile: props.before,
- newFile: props.after,
- lineAnnotations,
- containerWrapper: container,
+ createEffect(() => {
+ const instance = new FileDiff<T>({
+ theme: "pierre-light",
+ // Or can also provide a 'themes' prop, which allows the code to adapt
+ // to your OS light or dark theme
+ // themes: { dark: 'pierre-night', light: 'pierre-light' },
+ // When using the 'themes' prop, 'themeType' allows you to force 'dark'
+ // or 'light' theme, or inherit from the OS ('system') theme.
+ themeType: "system",
+ // Disable the line numbers for your diffs, generally not recommended
+ disableLineNumbers: false,
+ // Whether code should 'wrap' with long lines or 'scroll'.
+ overflow: "scroll",
+ // Normally you shouldn't need this prop, but if you don't provide a
+ // valid filename or your file doesn't have an extension you may want to
+ // override the automatic detection. You can specify that language here:
+ // https://shiki.style/languages
+ // lang?: SupportedLanguages;
+ // 'diffStyle' controls whether the diff is presented side by side or
+ // in a unified (single column) view
+ diffStyle: "unified",
+ // Line decorators to help highlight changes.
+ // 'bars' (default):
+ // Shows some red-ish or green-ish (theme dependent) bars on the left
+ // edge of relevant lines
+ //
+ // 'classic':
+ // shows '+' characters on additions and '-' characters on deletions
+ //
+ // 'none':
+ // No special diff indicators are shown
+ diffIndicators: "bars",
+ // By default green-ish or red-ish background are shown on added and
+ // deleted lines respectively. Disable that feature here
+ disableBackground: false,
+ // Diffs are split up into hunks, this setting customizes what to show
+ // between each hunk.
+ //
+ // 'line-info' (default):
+ // Shows a bar that tells you how many lines are collapsed. If you are
+ // using the oldFile/newFile API then you can click those bars to
+ // expand the content between them
+ //
+ // 'metadata':
+ // Shows the content you'd see in a normal patch file, usually in some
+ // format like '@@ -60,6 +60,22 @@'. You cannot use these to expand
+ // hidden content
+ //
+ // 'simple':
+ // Just a subtle bar separator between each hunk
+ hunkSeparators: "line-info",
+ // On lines that have both additions and deletions, we can run a
+ // separate diff check to mark parts of the lines that change.
+ // 'none':
+ // Do not show these secondary highlights
+ //
+ // 'char':
+ // Show changes at a per character granularity
+ //
+ // 'word':
+ // Show changes but rounded up to word boundaries
+ //
+ // 'word-alt' (default):
+ // Similar to 'word', however we attempt to minimize single character
+ // gaps between highlighted changes
+ lineDiffType: "word-alt",
+ // If lines exceed these character lengths then we won't perform the
+ // line lineDiffType check
+ maxLineDiffLength: 1000,
+ // If any line in the diff exceeds this value then we won't attempt to
+ // syntax highlight the diff
+ maxLineLengthForHighlighting: 1000,
+ // Enabling this property will hide the file header with file name and
+ // diff stats.
+ disableFileHeader: true,
+ // You can optionally pass a render function for rendering out line
+ // annotations. Just return the dom node to render
+ // renderAnnotation(annotation: DiffLineAnnotation<T>): HTMLElement {
+ // // Despite the diff itself being rendered in the shadow dom,
+ // // annotations are inserted via the web components 'slots' api and you
+ // // can use all your normal normal css and styling for them
+ // const element = document.createElement("div")
+ // element.innerText = annotation.metadata.threadId
+ // return element
+ // },
+ ...others,
+ })
+
+ instance.render({
+ oldFile: local.before,
+ newFile: local.after,
+ lineAnnotations: local.annotations,
+ containerWrapper: container,
+ })
})
- return <div ref={container} />
+ return (
+ <div
+ style={{
+ "--pjs-font-family": "var(--font-family-mono)",
+ "--pjs-font-size": "var(--font-size-small)",
+ "--pjs-line-height": "24px",
+ "--pjs-tab-size": 4,
+ "--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
+ "--pjs-header-font-family": "var(--font-family-sans)",
+ }}
+ ref={container}
+ />
+ )
}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 47893f44c..3838d19ba 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -1,6 +1,6 @@
import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui"
import { useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, on, Component, createMemo, Show, For } from "solid-js"
+import { createEffect, on, Component, createMemo, Show, For, onMount, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils"
@@ -46,6 +46,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
const isFocused = createFocusSignal(() => editorRef)
+ const handlePaste = (event: ClipboardEvent) => {
+ event.preventDefault()
+ event.stopPropagation()
+ // @ts-expect-error
+ const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
+ addPart({ type: "text", content: plainText })
+ }
+
+ onMount(() => {
+ editorRef.addEventListener("paste", handlePaste)
+ })
+ onCleanup(() => {
+ editorRef.removeEventListener("paste", handlePaste)
+ })
+
createEffect(() => {
if (isFocused()) {
handleInput()
@@ -144,16 +159,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawText = store.contentParts.map((p) => p.content).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
- if (!atMatch) return
- const startIndex = atMatch.index!
- const endIndex = cursorPosition
+ const startIndex = atMatch ? atMatch.index! : cursorPosition
+ const endIndex = atMatch ? cursorPosition : cursorPosition
+
+ const pushText = (acc: { parts: ContentPart[] }, value: string) => {
+ if (!value) return
+ const last = acc.parts[acc.parts.length - 1]
+ if (last && last.type === "text") {
+ acc.parts[acc.parts.length - 1] = {
+ type: "text",
+ content: last.content + value,
+ }
+ return
+ }
+ acc.parts.push({ type: "text", content: value })
+ }
const {
parts: nextParts,
- cursorIndex,
- cursorOffset,
inserted,
+ cursorPositionAfter,
} = store.contentParts.reduce(
(acc, item) => {
if (acc.inserted) {
@@ -180,17 +206,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const head = item.content.slice(0, headLength)
const tail = item.content.slice(tailLength)
- if (head) acc.parts.push({ type: "text", content: head })
+ pushText(acc, head)
- acc.parts.push(part)
-
- const rest = /^\s/.test(tail) ? tail : ` ${tail}`
- if (rest) {
- acc.cursorIndex = acc.parts.length
- acc.cursorOffset = Math.min(1, rest.length)
- acc.parts.push({ type: "text", content: rest })
+ if (part.type === "text") {
+ pushText(acc, part.content)
+ }
+ if (part.type !== "text") {
+ acc.parts.push({ ...part })
}
+ const needsGap = Boolean(atMatch)
+ const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail
+ pushText(acc, rest)
+
+ const baseCursor = startIndex + part.content.length
+ const cursorAddition = needsGap && rest.length > 0 ? 1 : 0
+ acc.cursorPositionAfter = baseCursor + cursorAddition
+
acc.inserted = true
acc.runningIndex = nextIndex
return acc
@@ -199,29 +231,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
parts: [] as ContentPart[],
runningIndex: 0,
inserted: false,
- cursorIndex: null as number | null,
- cursorOffset: 0,
+ cursorPositionAfter: cursorPosition + part.content.length,
},
)
- if (!inserted || cursorIndex === null) return
+ if (!inserted) {
+ const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
+ const appendedAcc = { parts: [...baseParts] as ContentPart[] }
+ if (part.type === "text") pushText(appendedAcc, part.content)
+ if (part.type !== "text") appendedAcc.parts.push({ ...part })
+ const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
+ setStore("contentParts", next)
+ setStore("popoverIsOpen", false)
+ const nextCursor = rawText.length + part.content.length
+ queueMicrotask(() => setCursorPosition(editorRef, nextCursor))
+ return
+ }
setStore("contentParts", nextParts)
setStore("popoverIsOpen", false)
- queueMicrotask(() => {
- const node = editorRef.childNodes[cursorIndex]
- if (node && node.nodeType === Node.TEXT_NODE) {
- const range = document.createRange()
- const selection = window.getSelection()
- const length = node.textContent ? node.textContent.length : 0
- const offset = cursorOffset > length ? length : cursorOffset
- range.setStart(node, offset)
- range.collapse(true)
- selection?.removeAllRanges()
- selection?.addRange(range)
- }
- })
+ queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter))
}
const handleKeyDown = (event: KeyboardEvent) => {
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index f6ea4cb90..f61e36549 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -1,4 +1,4 @@
-import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon } from "@opencode-ai/ui"
+import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon, Accordion } from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
@@ -55,7 +55,6 @@ export default function Page() {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault()
- // TODO: command palette
return
}
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
@@ -571,7 +570,6 @@ export default function Page() {
<div class="flex flex-col items-start gap-50 pb-[800px]">
<For each={local.session.userMessages()}>
{(message) => {
- console.log(message)
return (
<div
data-message={message.id}
@@ -583,22 +581,55 @@ export default function Page() {
</div>
<div class="text-14-regular text-text-base">{message.summary?.text}</div>
</div>
- <div class="">
- <For each={message.summary?.diffs}>
- {(diff) => (
- <Diff
- before={{
- name: diff.file!,
- contents: diff.before!,
- }}
- after={{
- name: diff.file!,
- contents: diff.after!,
- }}
- />
- )}
- </For>
- </div>
+ <Show when={message.summary?.diffs.length}>
+ <Accordion class="w-full" multiple>
+ <For each={message.summary?.diffs || []}>
+ {(diff) => (
+ <Accordion.Item value={diff.file}>
+ <Accordion.Header>
+ <Accordion.Trigger>
+ <div class="flex items-center justify-between w-full">
+ <div class="flex items-center gap-5">
+ <FileIcon
+ node={{ path: diff.file, type: "file" }}
+ class="shrink-0 size-4"
+ />
+ <div class="flex">
+ <Show when={diff.file.includes("/")}>
+ <span class="text-text-base">
+ {getDirectory(diff.file)}/
+ </span>
+ </Show>
+ <span class="text-text-strong">{getFilename(diff.file)}</span>
+ </div>
+ </div>
+ <div class="flex gap-4 items-center justify-end">
+ <div class="flex gap-2 justify-end items-center">
+ <span class="text-12-mono text-right text-text-diff-add-base">{`+${diff.additions}`}</span>
+ <span class="text-12-mono text-right text-text-diff-delete-base">{`-${diff.deletions}`}</span>
+ </div>
+ <Icon name="chevron-grabber-vertical" size="small" />
+ </div>
+ </div>
+ </Accordion.Trigger>
+ </Accordion.Header>
+ <Accordion.Content>
+ <Diff
+ before={{
+ name: diff.file!,
+ contents: diff.before!,
+ }}
+ after={{
+ name: diff.file!,
+ contents: diff.after!,
+ }}
+ />
+ </Accordion.Content>
+ </Accordion.Item>
+ )}
+ </For>
+ </Accordion>
+ </Show>
</div>
)
}}
diff --git a/packages/ui/src/components/accordion.css b/packages/ui/src/components/accordion.css
new file mode 100644
index 000000000..c8dfdfab1
--- /dev/null
+++ b/packages/ui/src/components/accordion.css
@@ -0,0 +1,102 @@
+[data-component="accordion"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0px;
+ align-self: stretch;
+ border-radius: 8px;
+ border: 1px solid var(--border-weak-base);
+
+ [data-slot="accordion-item"] {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0px;
+ align-self: stretch;
+
+ [data-slot="accordion-header"] {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+
+ [data-slot="accordion-trigger"] {
+ width: 100%;
+ display: flex;
+ height: 40px;
+ padding: 8px 12px;
+ justify-content: space-between;
+ align-items: center;
+ align-self: stretch;
+ cursor: default;
+ user-select: none;
+
+ background-color: var(--surface-base);
+ border-bottom: 1px solid var(--border-weak-base);
+ color: var(--text-strong);
+ transition: background-color 0.15s ease;
+
+ /* text-12-regular */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+
+ &:hover {
+ background-color: var(--surface-base);
+ }
+
+ &:focus-visible {
+ outline: none;
+ }
+
+ &[data-disabled] {
+ cursor: not-allowed;
+ }
+ }
+ }
+
+ &:last-child {
+ [data-slot="accordion-trigger"] {
+ border-bottom: none;
+ }
+ }
+
+ &[data-expanded] {
+ border-bottom: 1px solid var(--border-weak-base);
+ }
+
+ [data-slot="accordion-content"] {
+ overflow: hidden;
+ width: 100%;
+
+ /* animation: slideUp 250ms cubic-bezier(0.87, 0, 0.13, 1); */
+ /**/
+ /* &[data-expanded] { */
+ /* animation: slideDown 250ms cubic-bezier(0.87, 0, 0.13, 1); */
+ /* } */
+ }
+ }
+}
+
+@keyframes slideDown {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--kb-accordion-content-height);
+ }
+}
+
+@keyframes slideUp {
+ from {
+ height: var(--kb-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+}
diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx
new file mode 100644
index 000000000..535d38e3d
--- /dev/null
+++ b/packages/ui/src/components/accordion.tsx
@@ -0,0 +1,92 @@
+import { Accordion as Kobalte } from "@kobalte/core/accordion"
+import { splitProps } from "solid-js"
+import type { ComponentProps, ParentProps } from "solid-js"
+
+export interface AccordionProps extends ComponentProps<typeof Kobalte> {}
+export interface AccordionItemProps extends ComponentProps<typeof Kobalte.Item> {}
+export interface AccordionHeaderProps extends ComponentProps<typeof Kobalte.Header> {}
+export interface AccordionTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
+export interface AccordionContentProps extends ComponentProps<typeof Kobalte.Content> {}
+
+function AccordionRoot(props: AccordionProps) {
+ const [split, rest] = splitProps(props, ["class", "classList"])
+ return (
+ <Kobalte
+ {...rest}
+ data-component="accordion"
+ classList={{
+ ...(split.classList ?? {}),
+ [split.class ?? ""]: !!split.class,
+ }}
+ />
+ )
+}
+
+function AccordionItem(props: AccordionItemProps) {
+ const [split, rest] = splitProps(props, ["class", "classList"])
+ return (
+ <Kobalte.Item
+ {...rest}
+ data-slot="accordion-item"
+ classList={{
+ ...(split.classList ?? {}),
+ [split.class ?? ""]: !!split.class,
+ }}
+ />
+ )
+}
+
+function AccordionHeader(props: ParentProps<AccordionHeaderProps>) {
+ const [split, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.Header
+ {...rest}
+ data-slot="accordion-header"
+ classList={{
+ ...(split.classList ?? {}),
+ [split.class ?? ""]: !!split.class,
+ }}
+ >
+ {split.children}
+ </Kobalte.Header>
+ )
+}
+
+function AccordionTrigger(props: ParentProps<AccordionTriggerProps>) {
+ const [split, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.Trigger
+ {...rest}
+ data-slot="accordion-trigger"
+ classList={{
+ ...(split.classList ?? {}),
+ [split.class ?? ""]: !!split.class,
+ }}
+ >
+ {split.children}
+ </Kobalte.Trigger>
+ )
+}
+
+function AccordionContent(props: ParentProps<AccordionContentProps>) {
+ const [split, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.Content
+ {...rest}
+ data-slot="accordion-content"
+ classList={{
+ ...(split.classList ?? {}),
+ [split.class ?? ""]: !!split.class,
+ }}
+ >
+ {split.children}
+ </Kobalte.Content>
+ )
+}
+
+export const Accordion = Object.assign(AccordionRoot, {
+ Item: AccordionItem,
+ Header: AccordionHeader,
+ Trigger: AccordionTrigger,
+ Content: AccordionContent,
+})
diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx
index 011d103c1..f926192e8 100644
--- a/packages/ui/src/components/collapsible.tsx
+++ b/packages/ui/src/components/collapsible.tsx
@@ -8,7 +8,6 @@ export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> {
function CollapsibleRoot(props: CollapsibleProps) {
const [local, others] = splitProps(props, ["class", "classList"])
-
return (
<Kobalte
data-component="collapsible"
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 0c42083de..0011a9676 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -138,6 +138,7 @@ const newIcons = {
"edit-small-2": `<path d="M17.0834 17.0833V17.5833H17.5834V17.0833H17.0834ZM2.91675 17.0833H2.41675V17.5833H2.91675V17.0833ZM2.91675 2.91659V2.41659H2.41675V2.91659H2.91675ZM9.58341 3.41659H10.0834V2.41659H9.58341V2.91659V3.41659ZM17.5834 10.4166V9.91659H16.5834V10.4166H17.0834H17.5834ZM10.4167 7.08325L10.0632 6.7297L9.91675 6.87615V7.08325H10.4167ZM10.4167 9.58325H9.91675V10.0833H10.4167V9.58325ZM12.9167 9.58325V10.0833H13.1239L13.2703 9.93681L12.9167 9.58325ZM15.4167 2.08325L15.7703 1.7297L15.4167 1.37615L15.0632 1.7297L15.4167 2.08325ZM17.9167 4.58325L18.2703 4.93681L18.6239 4.58325L18.2703 4.2297L17.9167 4.58325ZM17.0834 17.0833V16.5833H2.91675V17.0833V17.5833H17.0834V17.0833ZM2.91675 17.0833H3.41675V2.91659H2.91675H2.41675V17.0833H2.91675ZM2.91675 2.91659V3.41659H9.58341V2.91659V2.41659H2.91675V2.91659ZM17.0834 10.4166H16.5834V17.0833H17.0834H17.5834V10.4166H17.0834ZM10.4167 7.08325H9.91675V9.58325H10.4167H10.9167V7.08325H10.4167ZM10.4167 9.58325V10.0833H12.9167V9.58325V9.08325H10.4167V9.58325ZM10.4167 7.08325L10.7703 7.43681L15.7703 2.43681L15.4167 2.08325L15.0632 1.7297L10.0632 6.7297L10.4167 7.08325ZM15.4167 2.08325L15.0632 2.43681L17.5632 4.93681L17.9167 4.58325L18.2703 4.2297L15.7703 1.7297L15.4167 2.08325ZM17.9167 4.58325L17.5632 4.2297L12.5632 9.2297L12.9167 9.58325L13.2703 9.93681L18.2703 4.93681L17.9167 4.58325Z" fill="currentColor"/>`,
folder: `<path d="M2.08301 2.91675V16.2501H17.9163V5.41675H9.99967L8.33301 2.91675H2.08301Z" stroke="currentColor" stroke-linecap="round"/>`,
"pencil-line": `<path d="M9.58301 17.9166H17.9163M17.9163 5.83325L14.1663 2.08325L2.08301 14.1666V17.9166H5.83301L17.9163 5.83325Z" stroke="currentColor" stroke-linecap="square"/>`,
+ "chevron-grabber-vertical": `<path d="M6.66675 12.4998L10.0001 15.8332L13.3334 12.4998M6.66675 7.49984L10.0001 4.1665L13.3334 7.49984" stroke="currentColor" stroke-linecap="square"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index 3691363cd..31672001b 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -1,3 +1,4 @@
+export * from "./accordion"
export * from "./button"
export * from "./collapsible"
export * from "./dialog"
diff --git a/packages/ui/src/demo.tsx b/packages/ui/src/demo.tsx
index 7c507d7d9..791281815 100644
--- a/packages/ui/src/demo.tsx
+++ b/packages/ui/src/demo.tsx
@@ -1,6 +1,7 @@
import type { Component } from "solid-js"
import { createSignal } from "solid-js"
import {
+ Accordion,
Button,
Select,
Tabs,
@@ -214,6 +215,41 @@ const Demo: Component = () => {
</Collapsible.Content>
</Collapsible>
</section>
+ <h3>Accordion</h3>
+ <section>
+ <Accordion collapsible>
+ <Accordion.Item value="item-1">
+ <Accordion.Header>
+ <Accordion.Trigger>What is Kobalte?</Accordion.Trigger>
+ </Accordion.Header>
+ <Accordion.Content>
+ <div style={{ padding: "16px" }}>
+ <p>Kobalte is a UI toolkit for building accessible web apps and design systems with SolidJS.</p>
+ </div>
+ </Accordion.Content>
+ </Accordion.Item>
+ <Accordion.Item value="item-2">
+ <Accordion.Header>
+ <Accordion.Trigger>Is it accessible?</Accordion.Trigger>
+ </Accordion.Header>
+ <Accordion.Content>
+ <div style={{ padding: "16px" }}>
+ <p>Yes. It adheres to the WAI-ARIA design patterns.</p>
+ </div>
+ </Accordion.Content>
+ </Accordion.Item>
+ <Accordion.Item value="item-3">
+ <Accordion.Header>
+ <Accordion.Trigger>Can it be animated?</Accordion.Trigger>
+ </Accordion.Header>
+ <Accordion.Content>
+ <div style={{ padding: "16px" }}>
+ <p>Yes! You can animate the content height using CSS animations.</p>
+ </div>
+ </Accordion.Content>
+ </Accordion.Item>
+ </Accordion>
+ </section>
</div>
)
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index dc5335c43..7d426a83e 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -5,7 +5,9 @@
@import "./base.css" layer(base);
+@import "../components/accordion.css" layer(components);
@import "../components/button.css" layer(components);
+@import "../components/collapsible.css" layer(components);
@import "../components/dialog.css" layer(components);
@import "../components/icon.css" layer(components);
@import "../components/icon-button.css" layer(components);