Browse Source

wip: desktop work

Adam 4 months ago
parent
commit
86447b5764

+ 133 - 123
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}
+    />
+  )
 }

+ 60 - 30
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) => {

+ 50 - 19
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>
                                   )
                                 }}

+ 102 - 0
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;
+  }
+}

+ 92 - 0
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,
+})

+ 0 - 1
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"

+ 1 - 0
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"> {

+ 1 - 0
packages/ui/src/components/index.ts

@@ -1,3 +1,4 @@
+export * from "./accordion"
 export * from "./button"
 export * from "./collapsible"
 export * from "./dialog"

+ 36 - 0
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>
   )
 

+ 2 - 0
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);