Procházet zdrojové kódy

feat(app): updates to design system (#34066)

Aarav Sareen před 2 měsíci
rodič
revize
9c38b91e91

+ 9 - 4
packages/app/src/components/dialog-select-directory-v2.tsx

@@ -1,6 +1,6 @@
 import "@pierre/trees/web-components"
 import { FileTree } from "@pierre/trees"
-import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
+import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
 import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -27,6 +27,7 @@ import {
   pickerRoot,
 } from "./directory-picker-domain"
 import "./dialog-select-directory-v2.css"
+import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
 
 interface DialogSelectDirectoryV2Props {
   title?: string
@@ -266,8 +267,12 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
   onCleanup(() => tree?.cleanUp())
 
   return (
-    <Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
-      <div class="directory-picker-v2-body">
+    <Dialog size="large" class="directory-picker-v2">
+      <DialogHeader>
+        <DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
+      </DialogHeader>
+      <DividerV2 />
+      <DialogBody class="directory-picker-v2-body pt-4!">
         <div class="directory-picker-v2-path" ref={pathArea}>
           <TextInputV2
             value={input()}
@@ -349,7 +354,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
           </Show>
         </div>
         <div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
-      </div>
+      </DialogBody>
       <DialogFooter>
         <ButtonV2 variant="neutral" onClick={() => dialog.close()}>
           {language.t("common.cancel")}

+ 9 - 4
packages/app/src/components/settings-v2/dialog-server-v2.tsx

@@ -1,5 +1,6 @@
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
-import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
+import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
+import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
 import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
@@ -52,8 +53,12 @@ export const DialogServerV2: Component<{
   }
 
   return (
-    <Dialog title={title()} fit class="settings-v2-server-dialog">
-      <div class="flex w-full min-w-0 flex-1 flex-col px-4">
+    <Dialog fit class="settings-v2-server-dialog">
+      <DialogHeader hideClose={true}>
+        <DialogTitle>{title()}</DialogTitle>
+      </DialogHeader>
+      <DividerV2 />
+      <DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
         <div class="flex w-full min-w-0 flex-col gap-6">
           <div class="flex w-full min-w-0 flex-col gap-2">
             <label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
@@ -115,7 +120,7 @@ export const DialogServerV2: Component<{
             </div>
           </div>
         </div>
-      </div>
+      </DialogBody>
       <DialogFooter>
         <ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
           {language.t("common.cancel")}

+ 1 - 1
packages/app/src/components/settings-v2/settings-v2.css

@@ -633,7 +633,7 @@
 
 [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
   align-items: center;
-  padding: 24px 24px 0;
+  padding: 24px 24px 16px;
 }
 
 [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {

+ 10 - 3
packages/app/src/wsl/settings.tsx

@@ -1,7 +1,8 @@
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Tag } from "@opencode-ai/ui/v2/badge-v2"
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
-import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
+import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
+import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
@@ -30,8 +31,14 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
   const language = useLanguage()
   const openAddWsl = () => {
     dialog.push(() => (
-      <Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
-        <DialogAddWslServer />
+      <Dialog size="large" fit class="settings-v2-wsl-dialog">
+        <DialogHeader hideClose={true}>
+          <DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
+        </DialogHeader>
+        <DividerV2 />
+        <DialogBody>
+          <DialogAddWslServer />
+        </DialogBody>
       </Dialog>
     ))
   }

+ 24 - 0
packages/ui/src/v2/components/button-v2.css

@@ -89,6 +89,30 @@
   cursor: not-allowed;
 }
 
+/* Danger */
+[data-component="button-v2"][data-variant="danger"] {
+  background-color: var(--v2-background-bg-button-neutral);
+  color: var(--v2-state-fg-danger);
+  box-shadow: var(--v2-elevation-button-neutral);
+}
+
+[data-component="button-v2"][data-variant="danger"]:is(:hover, [data-state="hover"]):not(:disabled) {
+  background-image:
+    linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%),
+    linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
+}
+
+[data-component="button-v2"][data-variant="danger"]:is(:active, [data-state="pressed"]):not(:disabled) {
+  background-image:
+    linear-gradient(90deg, var(--v2-overlay-simple-overlay-pressed) 0%, var(--v2-overlay-simple-overlay-pressed) 100%),
+    linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
+}
+
+[data-component="button-v2"][data-variant="danger"]:is(:disabled, [data-state="disabled"]) {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
 /* Contrast */
 [data-component="button-v2"][data-variant="contrast"] {
   background-image:

+ 4 - 3
packages/ui/src/v2/components/button-v2.stories.tsx

@@ -4,7 +4,7 @@ const docs = `### Overview
 Button v2 with three visual variants and two sizes.
 
 ### API
-- \`variant\`: "neutral" | "contrast" | "ghost" | "ghost-muted".
+- \`variant\`: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted".
 - \`size\`: "normal" | "large".
 - \`icon\`: Optional icon name.
 - Inherits Kobalte Button props and native button attributes.
@@ -39,7 +39,7 @@ export default {
     },
     variant: {
       control: "select",
-      options: ["neutral", "contrast", "ghost", "ghost-muted"],
+      options: ["neutral", "danger", "contrast", "ghost", "ghost-muted"],
     },
     size: {
       control: "select",
@@ -61,6 +61,7 @@ export const Variants = {
       }}
     >
       <ButtonV2 variant="neutral">Neutral</ButtonV2>
+      <ButtonV2 variant="danger">Danger</ButtonV2>
       <ButtonV2 variant="contrast">Contrast</ButtonV2>
       <ButtonV2 variant="ghost">Ghost</ButtonV2>
       <ButtonV2 variant="ghost-muted" icon="edit">
@@ -115,7 +116,7 @@ export const Icon = {
 
 export const AllStates = {
   render: () => {
-    const variants = ["neutral", "contrast", "ghost", "ghost-muted"] as const
+    const variants = ["neutral", "danger", "contrast", "ghost", "ghost-muted"] as const
     const states = ["default", "hover", "pressed", "focus", "disabled"] as const
     const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
     return (

+ 1 - 1
packages/ui/src/v2/components/button-v2.tsx

@@ -7,7 +7,7 @@ export interface ButtonV2Props
   extends ComponentProps<typeof Kobalte>,
     Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
   size?: "small" | "normal" | "large"
-  variant?: "neutral" | "contrast" | "ghost" | "ghost-muted"
+  variant?: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted"
   icon?: IconProps["name"]
 }
 

+ 31 - 7
packages/ui/src/v2/components/dialog-v2.css

@@ -49,34 +49,51 @@
         display: flex;
         flex-direction: row;
         align-items: flex-start;
+        align-self: stretch;
+        width: 100%;
+        gap: 0;
         padding: 16px;
-        gap: 8px;
         flex-shrink: 0;
-        align-self: stretch;
+
+        [data-slot="dialog-header-title"] {
+          margin: 0;
+          flex: 1;
+          min-width: 0;
+          font-weight: 530;
+          font-size: 13px;
+          line-height: 16px;
+          letter-spacing: -0.04px;
+          color: var(--v2-text-text-base);
+          font-variation-settings: "slnt" 0;
+        }
 
         [data-slot="dialog-title-group"] {
           display: flex;
           flex-direction: column;
           align-items: flex-start;
-          gap: 8px;
+          gap: 4px;
           flex: 1;
           min-width: 0;
         }
 
         [data-slot="dialog-title"] {
           margin: 0;
+          flex: none;
+          flex-grow: 0;
           font-weight: 530;
           font-size: 15px;
-          line-height: 100%;
+          line-height: 20px;
           letter-spacing: -0.13px;
           color: var(--v2-text-text-base);
           font-variation-settings: "slnt" 0;
         }
 
         [data-slot="dialog-description"] {
+          flex: none;
+          flex-grow: 0;
           font-weight: 440;
           font-size: 13px;
-          line-height: 100%;
+          line-height: 20px;
           letter-spacing: -0.04px;
           color: var(--v2-text-text-muted);
           font-variation-settings: "slnt" 0;
@@ -86,8 +103,11 @@
           display: flex;
           justify-content: center;
           align-items: center;
-          width: 24px;
-          height: 24px;
+          width: 20px;
+          height: 20px;
+          margin-top: -2px;
+          margin-right: -2px;
+          margin-left: auto;
           border-radius: 4px;
           flex-shrink: 0;
           cursor: pointer;
@@ -97,6 +117,10 @@
             background: var(--v2-overlay-simple-overlay-hover);
           }
         }
+
+        &[data-hide-close] [data-slot="dialog-close-button"] {
+          display: none;
+        }
       }
 
       [data-slot="dialog-footer"] {

+ 60 - 28
packages/ui/src/v2/components/dialog-v2.stories.tsx

@@ -1,17 +1,17 @@
 import { Dialog as KobalteDialog } from "@kobalte/core/dialog"
-import { Dialog, DialogFooter } from "./dialog-v2"
+import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle, DialogTitleGroup } from "./dialog-v2"
 import { ButtonV2 } from "./button-v2"
 
 const docs = `### Overview
 Dialog content wrapper built on Kobalte's dialog primitive with v2 styling.
 
-### API
-- Optional: \`title\`, \`description\`, \`action\`.
-- \`size\`: normal | large | x-large.
-- \`fit\` and \`transition\` control layout and animation.
+Compose with \`DialogHeader\`, \`DialogTitle\`, \`DialogTitleGroup\`, \`DialogBody\`, and \`DialogFooter\`.
 
-### Variants and states
-- Sizes and optional header/action controls.
+### API
+- \`Dialog\`: \`size\` (normal | large | x-large), \`variant\`, \`fit\`.
+- \`DialogHeader\`: row container with optional \`closeLabel\` and \`hideClose\`.
+- \`DialogTitle\`: accessible single-line header title.
+- \`DialogTitleGroup\`: column with \`title\` and required \`description\`.
 
 ### Accessibility
 - Focus trapping and aria attributes provided by Kobalte Dialog.
@@ -34,6 +34,14 @@ export default {
   },
 }
 
+function dialogHeader(title: string, description: string) {
+  return (
+    <DialogHeader>
+      <DialogTitleGroup title={title} description={description} />
+    </DialogHeader>
+  )
+}
+
 export const Basic = {
   render: () => (
     <KobalteDialog defaultOpen>
@@ -42,8 +50,9 @@ export const Basic = {
       </KobalteDialog.Trigger>
       <KobalteDialog.Portal>
         <KobalteDialog.Overlay />
-        <Dialog title="Dialog" description="Description">
-          Dialog body content.
+        <Dialog>
+          {dialogHeader("Dialog", "Description")}
+          <DialogBody>Dialog body content.</DialogBody>
         </Dialog>
       </KobalteDialog.Portal>
     </KobalteDialog>
@@ -59,8 +68,9 @@ export const Sizes = {
         </KobalteDialog.Trigger>
         <KobalteDialog.Portal>
           <KobalteDialog.Overlay />
-          <Dialog title="Normal" description="Normal size">
-            Normal dialog content.
+          <Dialog>
+            {dialogHeader("Normal", "Normal size")}
+            <DialogBody>Normal dialog content.</DialogBody>
           </Dialog>
         </KobalteDialog.Portal>
       </KobalteDialog>
@@ -71,8 +81,9 @@ export const Sizes = {
         </KobalteDialog.Trigger>
         <KobalteDialog.Portal>
           <KobalteDialog.Overlay />
-          <Dialog size="large" title="Large" description="Large size">
-            Large dialog content.
+          <Dialog size="large">
+            {dialogHeader("Large", "Large size")}
+            <DialogBody>Large dialog content.</DialogBody>
           </Dialog>
         </KobalteDialog.Portal>
       </KobalteDialog>
@@ -83,8 +94,9 @@ export const Sizes = {
         </KobalteDialog.Trigger>
         <KobalteDialog.Portal>
           <KobalteDialog.Overlay />
-          <Dialog size="x-large" title="Extra large" description="X-large size">
-            X-large dialog content.
+          <Dialog size="x-large">
+            {dialogHeader("Extra large", "X-large size")}
+            <DialogBody>X-large dialog content.</DialogBody>
           </Dialog>
         </KobalteDialog.Portal>
       </KobalteDialog>
@@ -92,24 +104,41 @@ export const Sizes = {
   ),
 }
 
-export const CustomAction = {
+export const TitleOnly = {
+  render: () => (
+    <KobalteDialog defaultOpen>
+      <KobalteDialog.Trigger as={ButtonV2} variant="neutral">
+        Open dialog
+      </KobalteDialog.Trigger>
+      <KobalteDialog.Portal>
+        <KobalteDialog.Overlay />
+        <Dialog>
+          <DialogHeader>
+            <DialogTitle>Open project</DialogTitle>
+          </DialogHeader>
+          <DialogBody>Dialog body content.</DialogBody>
+        </Dialog>
+      </KobalteDialog.Portal>
+    </KobalteDialog>
+  ),
+}
+
+export const HeaderControls = {
   render: () => (
     <KobalteDialog>
       <KobalteDialog.Trigger as={ButtonV2} variant="neutral">
-        Open action dialog
+        Open dialog
       </KobalteDialog.Trigger>
       <KobalteDialog.Portal>
         <KobalteDialog.Overlay />
-        <Dialog
-          title="Custom action"
-          description="Dialog with a custom header action"
-          action={
+        <Dialog>
+          <DialogHeader>
+            <DialogTitleGroup title="Custom header" description="Dialog with an extra header control" />
             <ButtonV2 variant="neutral" size="small">
               Help
             </ButtonV2>
-          }
-        >
-          Dialog body content.
+          </DialogHeader>
+          <DialogBody>Dialog body content.</DialogBody>
         </Dialog>
       </KobalteDialog.Portal>
     </KobalteDialog>
@@ -124,7 +153,8 @@ export const WithFooter = {
       </KobalteDialog.Trigger>
       <KobalteDialog.Portal>
         <KobalteDialog.Overlay />
-        <Dialog title="Save changes" description="Your changes will be lost if you don't save them." fit>
+        <Dialog fit>
+          {dialogHeader("Save changes", "Your changes will be lost if you don't save them.")}
           <DialogFooter>
             <ButtonV2 variant="neutral">Cancel</ButtonV2>
             <ButtonV2 variant="contrast">Save</ButtonV2>
@@ -143,7 +173,8 @@ export const WithFooterThreeButtons = {
       </KobalteDialog.Trigger>
       <KobalteDialog.Portal>
         <KobalteDialog.Overlay />
-        <Dialog title="Unsaved changes" description="You have unsaved changes. What would you like to do?" fit>
+        <Dialog fit>
+          {dialogHeader("Unsaved changes", "You have unsaved changes. What would you like to do?")}
           <DialogFooter>
             <span style={{ "margin-right": "auto" }}>
               <ButtonV2 variant="ghost">Remind me later</ButtonV2>
@@ -165,8 +196,9 @@ export const Fit = {
       </KobalteDialog.Trigger>
       <KobalteDialog.Portal>
         <KobalteDialog.Overlay />
-        <Dialog title="Fit content" fit>
-          Dialog fits its content.
+        <Dialog fit>
+          {dialogHeader("Fit content", "Dialog fits its content.")}
+          <DialogBody>Dialog fits its content.</DialogBody>
         </Dialog>
       </KobalteDialog.Portal>
     </KobalteDialog>

+ 66 - 47
packages/ui/src/v2/components/dialog-v2.tsx

@@ -3,9 +3,6 @@ import { type ComponentProps, type JSXElement, type ParentProps, Show, children,
 import "./dialog-v2.css"
 
 export interface DialogProps extends ParentProps {
-  title?: JSXElement
-  description?: JSXElement
-  action?: JSXElement
   size?: "normal" | "large" | "x-large"
   variant?: "default" | "settings"
   class?: ComponentProps<"div">["class"]
@@ -13,26 +10,76 @@ export interface DialogProps extends ParentProps {
   fit?: boolean
 }
 
+export interface DialogHeaderProps extends ParentProps {
+  closeLabel?: string
+  hideClose?: boolean
+}
+
+export interface DialogTitleGroupProps {
+  title?: JSXElement
+  description: JSXElement
+}
+
 export function DialogFooter(props: ParentProps) {
   return <div data-slot="dialog-footer">{props.children}</div>
 }
 
+export function DialogBody(props: ParentProps & { class?: ComponentProps<"div">["class"] }) {
+  const [local] = splitProps(props, ["class", "children"])
+  return (
+    <div data-slot="dialog-body" class={local.class}>
+      {local.children}
+    </div>
+  )
+}
+
+export function DialogTitle(props: ParentProps) {
+  return <Kobalte.Title data-slot="dialog-header-title">{props.children}</Kobalte.Title>
+}
+
+export function DialogTitleGroup(props: DialogTitleGroupProps) {
+  const title = children(() => props.title)
+  const description = children(() => props.description)
+
+  return (
+    <div data-slot="dialog-title-group">
+      <Show when={title()}>{(t) => <Kobalte.Title data-slot="dialog-title">{t()}</Kobalte.Title>}</Show>
+      <Kobalte.Description data-slot="dialog-description">{description()}</Kobalte.Description>
+    </div>
+  )
+}
+
+export function DialogHeader(props: DialogHeaderProps) {
+  const [local] = splitProps(props, ["closeLabel", "hideClose", "children"])
+  const hideClose = () => local.hideClose === true
+
+  return (
+    <div data-slot="dialog-header" data-hide-close={hideClose() ? "" : undefined}>
+      {local.children}
+      {!hideClose() && (
+        <Kobalte.CloseButton data-slot="dialog-close-button" aria-label={local.closeLabel ?? "Close"}>
+          <svg
+            width="16"
+            height="16"
+            viewBox="0 0 16 16"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            aria-hidden="true"
+          >
+            <path
+              d="M12.4446 3.55469L3.55566 12.4436M3.55566 3.55469L12.4446 12.4436"
+              stroke="currentColor"
+              stroke-linejoin="round"
+            />
+          </svg>
+        </Kobalte.CloseButton>
+      )}
+    </div>
+  )
+}
+
 export function Dialog(props: DialogProps) {
-  const [local] = splitProps(props, [
-    "title",
-    "description",
-    "action",
-    "size",
-    "variant",
-    "class",
-    "classList",
-    "fit",
-    "children",
-  ])
-  const title = children(() => local.title)
-  const description = children(() => local.description)
-  const action = children(() => local.action)
-  const hasHeader = () => title() || action()
+  const [local] = splitProps(props, ["size", "variant", "class", "classList", "fit", "children"])
 
   return (
     <div
@@ -44,7 +91,6 @@ export function Dialog(props: DialogProps) {
       <div data-slot="dialog-container">
         <Kobalte.Content
           data-slot="dialog-content"
-          data-no-header={!hasHeader() ? "" : undefined}
           classList={{
             ...local.classList,
             [local.class ?? ""]: !!local.class,
@@ -58,34 +104,7 @@ export function Dialog(props: DialogProps) {
             }
           }}
         >
-          <Show when={hasHeader()}>
-            <div data-slot="dialog-header">
-              <div data-slot="dialog-title-group">
-                <Show when={title()}>{(t) => <Kobalte.Title data-slot="dialog-title">{t()}</Kobalte.Title>}</Show>
-                <Show when={description()}>
-                  {(d) => <Kobalte.Description data-slot="dialog-description">{d()}</Kobalte.Description>}
-                </Show>
-              </div>
-              <Show when={action()}>{(a) => a()}</Show>
-              <Kobalte.CloseButton data-slot="dialog-close-button" aria-label="Close">
-                <svg
-                  width="16"
-                  height="16"
-                  viewBox="0 0 16 16"
-                  fill="none"
-                  xmlns="http://www.w3.org/2000/svg"
-                  aria-hidden="true"
-                >
-                  <path
-                    d="M12.4446 3.55469L3.55566 12.4436M3.55566 3.55469L12.4446 12.4436"
-                    stroke="currentColor"
-                    stroke-linejoin="round"
-                  />
-                </svg>
-              </Kobalte.CloseButton>
-            </div>
-          </Show>
-          <div data-slot="dialog-body">{local.children}</div>
+          {local.children}
         </Kobalte.Content>
       </div>
     </div>

+ 11 - 0
packages/ui/src/v2/components/divider-v2.css

@@ -0,0 +1,11 @@
+[data-component="divider-v2"] {
+  box-sizing: border-box;
+  width: 100%;
+  height: 0.5px;
+  margin-block: 0.25px;
+  border: none;
+  background: var(--v2-border-border-strong);
+  flex: none;
+  align-self: stretch;
+  flex-grow: 0;
+}

+ 38 - 0
packages/ui/src/v2/components/divider-v2.stories.tsx

@@ -0,0 +1,38 @@
+import { DividerV2 } from "./divider-v2"
+
+const docs = `### Overview
+Horizontal hairline divider for v2 layouts.
+
+### API
+- Inherits native div attributes.
+- Stretches to full width of its flex parent.
+
+### Theming/tokens
+- Uses \`data-component="divider-v2"\`.
+- Border color: \`--v2-border-border-strong\`.
+`
+
+export default {
+  title: "UI V2/Divider",
+  id: "components-divider-v2",
+  component: DividerV2,
+  tags: ["autodocs"],
+  parameters: {
+    frameWidth: "320px",
+    docs: {
+      description: {
+        component: docs,
+      },
+    },
+  },
+}
+
+export const Basic = {
+  render: () => (
+    <div style={{ display: "flex", "flex-direction": "column", gap: "16px", padding: "16px" }}>
+      <span>Above</span>
+      <DividerV2 />
+      <span>Below</span>
+    </div>
+  ),
+}

+ 20 - 0
packages/ui/src/v2/components/divider-v2.tsx

@@ -0,0 +1,20 @@
+import { type ComponentProps, splitProps } from "solid-js"
+import "./divider-v2.css"
+
+export interface DividerV2Props extends ComponentProps<"div"> {}
+
+export function DividerV2(props: DividerV2Props) {
+  const [local, rest] = splitProps(props, ["class", "classList"])
+  return (
+    <div
+      {...rest}
+      role="separator"
+      aria-orientation="horizontal"
+      data-component="divider-v2"
+      classList={{
+        ...local.classList,
+        [local.class ?? ""]: !!local.class,
+      }}
+    />
+  )
+}