Browse Source

wip: zen refactor selector

Frank 4 months ago
parent
commit
133da0f448

+ 80 - 0
packages/console/app/src/component/dropdown.css

@@ -0,0 +1,80 @@
+[data-component="dropdown"] {
+  position: relative;
+
+  [data-slot="trigger"] {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: var(--space-2);
+    padding: var(--space-2) var(--space-3);
+    border: none;
+    border-radius: var(--border-radius-sm);
+    background-color: transparent;
+    color: var(--color-text);
+    font-size: var(--font-size-sm);
+    font-family: var(--font-sans);
+    cursor: pointer;
+    transition: all 0.15s ease;
+
+    &:hover {
+      background-color: var(--color-surface-hover);
+    }
+
+    span {
+      flex: 1;
+      text-align: left;
+      font-weight: 500;
+    }
+  }
+
+  [data-slot="chevron"] {
+    flex-shrink: 0;
+    color: var(--color-text-secondary);
+  }
+
+  [data-slot="dropdown"] {
+    position: absolute;
+    top: 100%;
+    z-index: 1000;
+    margin-top: var(--space-1);
+    border: 1px solid var(--color-border);
+    border-radius: var(--border-radius-sm);
+    background-color: var(--color-bg);
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+    min-width: 160px;
+
+    &[data-align="left"] {
+      left: 0;
+    }
+
+    &[data-align="right"] {
+      right: 0;
+    }
+
+    @media (prefers-color-scheme: dark) {
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+    }
+  }
+
+  [data-slot="item"] {
+    display: block;
+    width: 100%;
+    padding: var(--space-2-5) var(--space-3);
+    border: none;
+    background: none;
+    color: var(--color-text);
+    font-size: var(--font-size-sm);
+    font-family: var(--font-sans);
+    text-align: left;
+    cursor: pointer;
+    transition: background-color 0.15s ease;
+
+    &:hover {
+      background-color: var(--color-bg-surface);
+    }
+
+    &[data-selected="true"] {
+      background-color: var(--color-accent-alpha);
+    }
+  }
+}

+ 79 - 0
packages/console/app/src/component/dropdown.tsx

@@ -0,0 +1,79 @@
+import { JSX, Show, createEffect, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { IconChevron } from "./icon"
+import "./dropdown.css"
+
+interface DropdownProps {
+  trigger: JSX.Element | string
+  children: JSX.Element
+  open?: boolean
+  onOpenChange?: (open: boolean) => void
+  align?: "left" | "right"
+  class?: string
+}
+
+export function Dropdown(props: DropdownProps) {
+  const [store, setStore] = createStore({
+    isOpen: props.open ?? false,
+  })
+  let dropdownRef: HTMLDivElement | undefined
+
+  createEffect(() => {
+    if (props.open !== undefined) {
+      setStore("isOpen", props.open)
+    }
+  })
+
+  createEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
+        setStore("isOpen", false)
+        props.onOpenChange?.(false)
+      }
+    }
+
+    document.addEventListener("click", handleClickOutside)
+    onCleanup(() => document.removeEventListener("click", handleClickOutside))
+  })
+
+  const toggle = () => {
+    const newValue = !store.isOpen
+    setStore("isOpen", newValue)
+    props.onOpenChange?.(newValue)
+  }
+
+  return (
+    <div data-component="dropdown" class={props.class} ref={dropdownRef}>
+      <button data-slot="trigger" type="button" onClick={toggle}>
+        {typeof props.trigger === "string" ? <span>{props.trigger}</span> : props.trigger}
+        <IconChevron data-slot="chevron" />
+      </button>
+
+      <Show when={store.isOpen}>
+        <div data-slot="dropdown" data-align={props.align ?? "left"}>
+          {props.children}
+        </div>
+      </Show>
+    </div>
+  )
+}
+
+interface DropdownItemProps {
+  children: JSX.Element
+  selected?: boolean
+  onClick?: () => void
+  type?: "button" | "submit" | "reset"
+}
+
+export function DropdownItem(props: DropdownItemProps) {
+  return (
+    <button
+      data-slot="item"
+      data-selected={props.selected ?? false}
+      type={props.type ?? "button"}
+      onClick={props.onClick}
+    >
+      {props.children}
+    </button>
+  )
+}

+ 8 - 59
packages/console/app/src/routes/user-menu.css

@@ -1,68 +1,17 @@
 [data-component="user-menu"] {
-  position: relative;
-
-  [data-slot="trigger"] {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    gap: var(--space-2);
-    padding: var(--space-2) var(--space-3);
-    border: none;
-    border-radius: var(--border-radius-sm);
-    background-color: transparent;
-    color: var(--color-text);
-    font-size: var(--font-size-sm);
-    font-family: var(--font-sans);
-    cursor: pointer;
-    transition: all 0.15s ease;
-
-    &:hover {
-      background-color: var(--color-surface-hover);
-    }
-
-    span {
-      flex: 1;
-      text-align: left;
-      font-weight: 500;
+  [data-component="dropdown"] {
+    [data-slot="trigger"] span {
       color: var(--color-text-muted);
     }
-  }
 
-  [data-slot="chevron"] {
-    flex-shrink: 0;
-    color: var(--color-text-secondary);
-  }
-
-  [data-slot="dropdown"] {
-    position: absolute;
-    top: 100%;
-    right: 0;
-    z-index: 1000;
-    margin-top: var(--space-1);
-    border: 1px solid var(--color-border);
-    border-radius: var(--border-radius-sm);
-    background-color: var(--color-bg);
-    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-    min-width: 160px;
-
-    @media (prefers-color-scheme: dark) {
-      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+    [data-slot="dropdown"] {
+      form {
+        width: 100%;
+      }
     }
 
-    form {
-      width: 100%;
+    [data-slot="item"] {
+      color: var(--color-danger);
     }
   }
-
-  [data-slot="item"],
-  [data-slot="create-item"] {
-    width: 100%;
-    padding: var(--space-2-5) var(--space-3);
-    border: none;
-    background: none;
-    color: var(--color-danger);
-    font-size: var(--font-size-sm);
-    font-family: var(--font-sans);
-    text-align: left;
-  }
 }

+ 8 - 36
packages/console/app/src/routes/user-menu.tsx

@@ -1,9 +1,7 @@
-import { Show, onCleanup, createEffect } from "solid-js"
-import { createStore } from "solid-js/store"
 import { action, redirect } from "@solidjs/router"
 import { getRequestEvent } from "solid-js/web"
 import { useAuthSession } from "~/context/auth.session"
-import { IconChevron } from "~/component/icon"
+import { Dropdown } from "~/component/dropdown"
 import "./user-menu.css"
 
 const logout = action(async () => {
@@ -23,41 +21,15 @@ const logout = action(async () => {
 })
 
 export function UserMenu(props: { email: string | null | undefined }) {
-  const [store, setStore] = createStore({
-    showDropdown: false,
-  })
-  let dropdownRef: HTMLDivElement | undefined
-
-  createEffect(() => {
-    const handleClickOutside = (event: MouseEvent) => {
-      if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
-        setStore("showDropdown", false)
-      }
-    }
-
-    document.addEventListener("click", handleClickOutside)
-
-    onCleanup(() => document.removeEventListener("click", handleClickOutside))
-  })
-
   return (
     <div data-component="user-menu">
-      <div ref={dropdownRef}>
-        <button data-slot="trigger" type="button" onClick={() => setStore("showDropdown", !store.showDropdown)}>
-          <span>{props.email}</span>
-          <IconChevron data-slot="chevron" />
-        </button>
-
-        <Show when={store.showDropdown}>
-          <div data-slot="dropdown">
-            <form action={logout} method="post">
-              <button type="submit" formaction={logout} data-slot="item">
-                Logout
-              </button>
-            </form>
-          </div>
-        </Show>
-      </div>
+      <Dropdown trigger={props.email ?? ""} align="right">
+        <form action={logout} method="post">
+          <button type="submit" formaction={logout} data-slot="item">
+            Logout
+          </button>
+        </form>
+      </Dropdown>
     </div>
   )
 }

+ 20 - 57
packages/console/app/src/routes/workspace-picker.css

@@ -1,66 +1,23 @@
 [data-component="workspace-picker"] {
-  position: relative;
-
-  [data-slot="trigger"] {
-    /* Override blue accent colors with neutral colors for dropdown trigger */
-    --color-accent: var(--color-border);
-    --color-accent-hover: var(--color-border);
-    --color-accent-active: var(--color-border);
-    --color-primary: var(--color-border);
-    --color-primary-hover: var(--color-border);
-    --color-primary-active: var(--color-border);
-    --color-primary-alpha-20: transparent;
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    gap: var(--space-2);
-    padding: var(--space-2) var(--space-3);
-    border: none;
-    border-radius: var(--border-radius-sm);
-    background-color: transparent;
-    color: var(--color-text);
-    font-size: var(--font-size-sm);
-    font-family: var(--font-sans);
-    cursor: pointer;
-    transition: all 0.15s ease;
-
-    &:hover {
-      background-color: var(--color-surface-hover);
-    }
-
-    span {
-      flex: 1;
-      text-align: left;
-      font-weight: 500;
-      color: var(--color-text);
+  [data-component="dropdown"] {
+    [data-slot="trigger"] {
+      /* Override blue accent colors with neutral colors for dropdown trigger */
+      --color-accent: var(--color-border);
+      --color-accent-hover: var(--color-border);
+      --color-accent-active: var(--color-border);
+      --color-primary: var(--color-border);
+      --color-primary-hover: var(--color-border);
+      --color-primary-active: var(--color-border);
+      --color-primary-alpha-20: transparent;
     }
-  }
-
-  [data-slot="chevron"] {
-    flex-shrink: 0;
-    color: var(--color-text-secondary);
-  }
-
-  [data-slot="dropdown"] {
-    position: absolute;
-    top: 100%;
-    left: 0;
-    z-index: 1000;
-    margin-top: var(--space-1);
-    border: 1px solid var(--color-border);
-    border-radius: var(--border-radius-sm);
-    background-color: var(--color-bg);
-    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-    max-height: 240px;
-    overflow-y: auto;
-    min-width: 200px;
 
-    @media (prefers-color-scheme: dark) {
-      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+    [data-slot="dropdown"] {
+      max-height: 240px;
+      overflow-y: auto;
+      min-width: 200px;
     }
   }
 
-  [data-slot="item"],
   [data-slot="create-item"] {
     width: 100%;
     padding: var(--space-2-5) var(--space-3);
@@ -70,6 +27,12 @@
     font-size: var(--font-size-sm);
     font-family: var(--font-sans);
     text-align: left;
+    cursor: pointer;
+    transition: background-color 0.15s ease;
+
+    &:hover {
+      background-color: var(--color-bg-surface);
+    }
   }
 
   [data-slot="create-form"] {

+ 15 - 47
packages/console/app/src/routes/workspace-picker.tsx

@@ -1,5 +1,5 @@
 import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
-import { For, Show, createEffect, onCleanup } from "solid-js"
+import { For, Show, createEffect } from "solid-js"
 import { createStore } from "solid-js/store"
 import { withActor } from "~/context/auth.withActor"
 import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -7,7 +7,7 @@ import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/ind
 import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
 import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
 import { Workspace } from "@opencode-ai/console-core/workspace.js"
-import { IconChevron } from "~/component/icon"
+import { Dropdown, DropdownItem } from "~/component/dropdown"
 import { Modal } from "~/component/modal"
 import "./workspace-picker.css"
 
@@ -45,9 +45,7 @@ export function WorkspacePicker() {
   const submission = useSubmission(createWorkspace)
   const [store, setStore] = createStore({
     showForm: false,
-    showDropdown: false,
   })
-  let dropdownRef: HTMLDivElement | undefined
   let inputRef: HTMLInputElement | undefined
 
   const currentWorkspace = () => {
@@ -56,7 +54,7 @@ export function WorkspacePicker() {
   }
 
   const handleWorkspaceNew = () => {
-    setStore({ showForm: true, showDropdown: false })
+    setStore("showForm", true)
   }
 
   createEffect(() => {
@@ -66,11 +64,7 @@ export function WorkspacePicker() {
   })
 
   const handleSelectWorkspace = (workspaceID: string) => {
-    if (workspaceID === params.id) {
-      setStore("showDropdown", false)
-      return
-    }
-
+    if (workspaceID === params.id) return
     window.location.href = `/workspace/${workspaceID}`
   }
 
@@ -78,48 +72,22 @@ export function WorkspacePicker() {
   createEffect(() => {
     params.id
     setStore("showForm", false)
-    setStore("showDropdown", false)
-  })
-
-  createEffect(() => {
-    const handleClickOutside = (event: MouseEvent) => {
-      if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
-        setStore("showDropdown", false)
-      }
-    }
-
-    document.addEventListener("click", handleClickOutside)
-    onCleanup(() => document.removeEventListener("click", handleClickOutside))
   })
 
   return (
     <div data-component="workspace-picker">
-      <div ref={dropdownRef}>
-        <button data-slot="trigger" type="button" onClick={() => setStore("showDropdown", !store.showDropdown)}>
-          <span>{currentWorkspace()}</span>
-          <IconChevron data-slot="chevron" />
+      <Dropdown trigger={currentWorkspace()} align="left">
+        <For each={workspaces()}>
+          {(workspace) => (
+            <DropdownItem selected={workspace.id === params.id} onClick={() => handleSelectWorkspace(workspace.id)}>
+              {workspace.name || workspace.slug}
+            </DropdownItem>
+          )}
+        </For>
+        <button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
+          + Create New Workspace
         </button>
-
-        <Show when={store.showDropdown}>
-          <div data-slot="dropdown">
-            <For each={workspaces()}>
-              {(workspace) => (
-                <button
-                  data-slot="item"
-                  data-selected={workspace.id === params.id}
-                  type="button"
-                  onClick={() => handleSelectWorkspace(workspace.id)}
-                >
-                  {workspace.name || workspace.slug}
-                </button>
-              )}
-            </For>
-            <button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
-              + Create New Workspace
-            </button>
-          </div>
-        </Show>
-      </div>
+      </Dropdown>
 
       <Modal open={store.showForm} onClose={() => setStore("showForm", false)} title="Create New Workspace">
         <form data-slot="create-form" action={createWorkspace} method="post">

+ 1 - 206
packages/console/app/src/routes/workspace/[id]/members/member-section.module.css

@@ -92,93 +92,6 @@
       line-height: 1.4;
       margin-top: calc(var(--space-1) * -1);
     }
-
-    [data-slot="role-selector"] {
-      position: relative;
-
-      [data-slot="trigger"] {
-        display: flex;
-        align-items: center;
-        justify-content: space-between;
-        gap: var(--space-2);
-        width: 100%;
-        padding: var(--space-2) var(--space-3);
-        border: 1px solid var(--color-border);
-        border-radius: var(--border-radius-sm);
-        background-color: var(--color-bg);
-        color: var(--color-text);
-        font-size: var(--font-size-sm);
-        line-height: 1.5;
-        cursor: pointer;
-        transition: all 0.15s ease;
-
-        &:hover {
-          border-color: var(--color-accent);
-        }
-
-        &:focus {
-          outline: none;
-          border-color: var(--color-accent);
-          box-shadow: 0 0 0 3px var(--color-accent-alpha);
-        }
-
-        [data-slot="chevron"] {
-          opacity: 0.6;
-          transition: transform 0.15s ease;
-        }
-      }
-
-      [data-slot="dropdown"] {
-        position: absolute;
-        top: 100%;
-        left: 0;
-        z-index: 10;
-        margin-top: var(--space-1);
-        padding: var(--space-1);
-        background-color: var(--color-bg);
-        border: 1px solid var(--color-border);
-        border-radius: var(--border-radius-sm);
-        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-        min-width: 280px;
-        width: max-content;
-
-        [data-slot="item"] {
-          display: block;
-          width: 100%;
-          padding: var(--space-2) var(--space-3);
-          border: none;
-          background-color: transparent;
-          color: var(--color-text);
-          font-size: var(--font-size-sm);
-          text-align: left;
-          cursor: pointer;
-          border-radius: var(--border-radius-sm);
-          transition: background-color 0.15s ease;
-
-          &:hover {
-            background-color: var(--color-bg-surface);
-          }
-
-          &[data-selected="true"] {
-            background-color: var(--color-accent-alpha);
-          }
-
-          div {
-            strong {
-              display: block;
-              color: var(--color-text);
-              margin-bottom: var(--space-1);
-            }
-
-            p {
-              font-size: var(--font-size-xs);
-              color: var(--color-text-muted);
-              margin: 0;
-            }
-          }
-        }
-      }
-    }
   }
 
   [data-slot="members-table"] {
@@ -226,125 +139,7 @@
 
       &[data-slot="member-role"] {
         font-family: var(--font-mono);
-
-        [data-slot="role-selector"] {
-          position: relative;
-
-          [data-slot="trigger"] {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            gap: var(--space-2);
-            width: 100%;
-            padding: var(--space-2) var(--space-3);
-            border: 1px solid var(--color-border);
-            border-radius: var(--border-radius-sm);
-            background-color: var(--color-bg);
-            color: var(--color-text);
-            font-size: var(--font-size-sm);
-            line-height: 1.5;
-            cursor: pointer;
-            transition: all 0.15s ease;
-            font-family: var(--font-sans);
-
-            &:hover {
-              border-color: var(--color-accent);
-            }
-
-            &:focus {
-              outline: none;
-              border-color: var(--color-accent);
-              box-shadow: 0 0 0 3px var(--color-accent-alpha);
-            }
-
-            [data-slot="chevron"] {
-              opacity: 0.6;
-              transition: transform 0.15s ease;
-            }
-          }
-
-          [data-slot="dropdown"] {
-            position: absolute;
-            top: 100%;
-            left: 0;
-            z-index: 10;
-            margin-top: var(--space-1);
-            padding: var(--space-1);
-            background-color: var(--color-bg);
-            border: 1px solid var(--color-border);
-            border-radius: var(--border-radius-sm);
-            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-            min-width: 280px;
-            width: max-content;
-
-            [data-slot="item"] {
-              display: block;
-              width: 100%;
-              padding: var(--space-2) var(--space-3);
-              border: none;
-              background-color: transparent;
-              color: var(--color-text);
-              font-size: var(--font-size-sm);
-              text-align: left;
-              cursor: pointer;
-              border-radius: var(--border-radius-sm);
-              transition: background-color 0.15s ease;
-
-              &:hover {
-                background-color: var(--color-bg-surface);
-              }
-
-              &[data-selected="true"] {
-                background-color: var(--color-accent-alpha);
-              }
-
-              div {
-                strong {
-                  display: block;
-                  color: var(--color-text);
-                  margin-bottom: var(--space-1);
-                }
-
-                p {
-                  font-size: var(--font-size-xs);
-                  color: var(--color-text-muted);
-                  margin: 0;
-                }
-              }
-            }
-          }
-        }
-
-        button {
-          display: flex;
-          align-items: center;
-          gap: var(--space-2);
-          padding: var(--space-2) var(--space-3);
-          font-size: var(--font-size-sm);
-          font-weight: 400;
-          border: none;
-          background-color: transparent;
-          color: var(--color-text-muted);
-          font-family: var(--font-mono);
-          border-radius: var(--border-radius-sm);
-          cursor: pointer;
-          transition: all 0.15s ease;
-          text-transform: none;
-
-          &:hover:not(:disabled) {
-            background-color: var(--color-bg-surface);
-            color: var(--color-text);
-          }
-
-          &:disabled {
-            cursor: default;
-            color: var(--color-text);
-          }
-
-          span {
-            font-family: inherit;
-          }
-        }
+        text-transform: capitalize;
       }
 
       &[data-slot="member-usage"] {

+ 17 - 125
packages/console/app/src/routes/workspace/[id]/members/member-section.tsx

@@ -1,12 +1,12 @@
 import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
-import { createEffect, createSignal, For, Show, onCleanup } from "solid-js"
+import { createEffect, For, Show } from "solid-js"
 import { withActor } from "~/context/auth.withActor"
 import { createStore } from "solid-js/store"
 import styles from "./member-section.module.css"
 import { UserRole } from "@opencode-ai/console-core/schema/user.sql.js"
 import { Actor } from "@opencode-ai/console-core/actor.js"
 import { User } from "@opencode-ai/console-core/user.js"
-import { IconChevron } from "~/component/icon"
+import { RoleDropdown } from "./role-dropdown"
 
 const listMembers = query(async (workspaceID: string) => {
   "use server"
@@ -92,29 +92,15 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
   const [store, setStore] = createStore({
     editing: false,
     selectedRole: props.member.role as (typeof UserRole)[number],
-    showRoleDropdown: false,
     limit: "",
   })
 
-  let roleDropdownRef: HTMLDivElement | undefined
-
   createEffect(() => {
     if (!submission.pending && submission.result && !submission.result.error) {
       setStore("editing", false)
     }
   })
 
-  createEffect(() => {
-    const handleClickOutside = (event: MouseEvent) => {
-      if (roleDropdownRef && !roleDropdownRef.contains(event.target as Node)) {
-        setStore("showRoleDropdown", false)
-      }
-    }
-
-    document.addEventListener("click", handleClickOutside)
-    onCleanup(() => document.removeEventListener("click", handleClickOutside))
-  })
-
   function show() {
     while (true) {
       submission.clear()
@@ -127,7 +113,6 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
 
   function hide() {
     setStore("editing", false)
-    setStore("showRoleDropdown", false)
   }
 
   function getUsageDisplay() {
@@ -153,58 +138,16 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
     return `$${currentUsage} / ${limit}`
   }
 
-  const roleLabels = {
-    admin: { title: "Admin", description: "Can manage models, members, and billing" },
-    member: { title: "Member", description: "Can only generate API keys for themselves" },
-  }
-
   return (
     <tr>
       <td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
       <td data-slot="member-role">
         <Show when={store.editing && !isCurrentUser()} fallback={<span>{props.member.role}</span>}>
-          <div data-slot="role-selector" ref={roleDropdownRef}>
-            <button
-              data-slot="trigger"
-              type="button"
-              onClick={() => setStore("showRoleDropdown", !store.showRoleDropdown)}
-            >
-              <span>{roleLabels[store.selectedRole].title}</span>
-              <IconChevron data-slot="chevron" />
-            </button>
-            <Show when={store.showRoleDropdown}>
-              <div data-slot="dropdown">
-                <button
-                  data-slot="item"
-                  data-selected={store.selectedRole === "admin"}
-                  type="button"
-                  onClick={() => {
-                    setStore("selectedRole", "admin")
-                    setStore("showRoleDropdown", false)
-                  }}
-                >
-                  <div>
-                    <strong>Admin</strong>
-                    <p>{roleLabels.admin.description}</p>
-                  </div>
-                </button>
-                <button
-                  data-slot="item"
-                  data-selected={store.selectedRole === "member"}
-                  type="button"
-                  onClick={() => {
-                    setStore("selectedRole", "member")
-                    setStore("showRoleDropdown", false)
-                  }}
-                >
-                  <div>
-                    <strong>{roleLabels.member.title}</strong>
-                    <p>{roleLabels.member.description}</p>
-                  </div>
-                </button>
-              </div>
-            </Show>
-          </div>
+          <RoleDropdown
+            value={store.selectedRole}
+            options={roleOptions}
+            onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
+          />
         </Show>
       </td>
       <td data-slot="member-usage">
@@ -260,6 +203,11 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
   )
 }
 
+const roleOptions = [
+  { value: "admin", description: "Can manage models, members, and billing" },
+  { value: "member", description: "Can only generate API keys for themselves" },
+]
+
 export function MemberSection() {
   const params = useParams()
   const data = createAsync(() => listMembers(params.id))
@@ -267,12 +215,10 @@ export function MemberSection() {
   const [store, setStore] = createStore({
     show: false,
     selectedRole: "member" as (typeof UserRole)[number],
-    showRoleDropdown: false,
     limit: "",
   })
 
   let input: HTMLInputElement
-  let roleDropdownRef: HTMLDivElement | undefined
 
   createEffect(() => {
     if (!submission.pending && submission.result && !submission.result.error) {
@@ -280,17 +226,6 @@ export function MemberSection() {
     }
   })
 
-  createEffect(() => {
-    const handleClickOutside = (event: MouseEvent) => {
-      if (roleDropdownRef && !roleDropdownRef.contains(event.target as Node)) {
-        setStore("showRoleDropdown", false)
-      }
-    }
-
-    document.addEventListener("click", handleClickOutside)
-    onCleanup(() => document.removeEventListener("click", handleClickOutside))
-  })
-
   function show() {
     while (true) {
       submission.clear()
@@ -304,12 +239,6 @@ export function MemberSection() {
 
   function hide() {
     setStore("show", false)
-    setStore("showRoleDropdown", false)
-  }
-
-  const roleLabels = {
-    admin: { title: "Admin", description: "Can manage models, members, and billing" },
-    member: { title: "Member", description: "Can only generate API keys for themselves" },
   }
 
   return (
@@ -340,48 +269,11 @@ export function MemberSection() {
             </div>
             <div data-slot="input-field">
               <p>Role</p>
-              <div data-slot="role-selector" ref={roleDropdownRef}>
-                <button
-                  data-slot="trigger"
-                  type="button"
-                  onClick={() => setStore("showRoleDropdown", !store.showRoleDropdown)}
-                >
-                  <span>{roleLabels[store.selectedRole].title}</span>
-                  <IconChevron data-slot="chevron" />
-                </button>
-                <Show when={store.showRoleDropdown}>
-                  <div data-slot="dropdown">
-                    <button
-                      data-slot="item"
-                      data-selected={store.selectedRole === "admin"}
-                      type="button"
-                      onClick={() => {
-                        setStore("selectedRole", "admin")
-                        setStore("showRoleDropdown", false)
-                      }}
-                    >
-                      <div>
-                        <strong>Admin</strong>
-                        <p>{roleLabels.admin.description}</p>
-                      </div>
-                    </button>
-                    <button
-                      data-slot="item"
-                      data-selected={store.selectedRole === "member"}
-                      type="button"
-                      onClick={() => {
-                        setStore("selectedRole", "member")
-                        setStore("showRoleDropdown", false)
-                      }}
-                    >
-                      <div>
-                        <strong>{roleLabels.member.title}</strong>
-                        <p>{roleLabels.member.description}</p>
-                      </div>
-                    </button>
-                  </div>
-                </Show>
-              </div>
+              <RoleDropdown
+                value={store.selectedRole}
+                options={roleOptions}
+                onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
+              />
             </div>
             <div data-slot="input-field">
               <p>Monthly spending limit</p>

+ 66 - 0
packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css

@@ -0,0 +1,66 @@
+.role-dropdown {
+  [data-slot="trigger"] {
+    border: 1px solid var(--color-border);
+    background-color: var(--color-bg);
+    width: 100%;
+    text-transform: capitalize;
+
+    &:hover {
+      border-color: var(--color-accent);
+      background-color: var(--color-bg);
+    }
+
+    &:focus {
+      outline: none;
+      border-color: var(--color-accent);
+      box-shadow: 0 0 0 3px var(--color-accent-alpha);
+    }
+  }
+
+  [data-slot="chevron"] {
+    opacity: 0.6;
+  }
+
+  [data-slot="dropdown"] {
+    padding: var(--space-1);
+    min-width: 280px;
+    width: max-content;
+  }
+
+  [data-slot="role-item"] {
+    display: block;
+    width: 100%;
+    padding: var(--space-2) var(--space-3);
+    border: none;
+    background-color: transparent;
+    color: var(--color-text);
+    font-size: var(--font-size-sm);
+    text-align: left;
+    cursor: pointer;
+    border-radius: var(--border-radius-sm);
+    transition: background-color 0.15s ease;
+
+    &:hover {
+      background-color: var(--color-bg-surface);
+    }
+
+    &[data-selected="true"] {
+      background-color: var(--color-accent-alpha);
+    }
+
+    div {
+      strong {
+        display: block;
+        color: var(--color-text);
+        margin-bottom: var(--space-1);
+        text-transform: capitalize;
+      }
+
+      p {
+        font-size: var(--font-size-xs);
+        color: var(--color-text-muted);
+        margin: 0;
+      }
+    }
+  }
+}

+ 43 - 0
packages/console/app/src/routes/workspace/[id]/members/role-dropdown.tsx

@@ -0,0 +1,43 @@
+import { createSignal } from "solid-js"
+import { Dropdown } from "~/component/dropdown"
+import "./role-dropdown.css"
+
+interface RoleOption {
+  value: string
+  description: string
+}
+
+interface RoleDropdownProps {
+  value: string
+  options: RoleOption[]
+  onChange: (value: string) => void
+}
+
+export function RoleDropdown(props: RoleDropdownProps) {
+  const [open, setOpen] = createSignal(false)
+
+  const handleSelect = (value: string) => {
+    props.onChange(value)
+    setOpen(false)
+  }
+
+  return (
+    <Dropdown trigger={props.value} open={open()} onOpenChange={setOpen} class="role-dropdown">
+      <>
+        {props.options.map((option) => (
+          <button
+            data-slot="role-item"
+            data-selected={props.value === option.value}
+            type="button"
+            onClick={() => handleSelect(option.value)}
+          >
+            <div>
+              <strong>{option.value}</strong>
+              <p>{option.description}</p>
+            </div>
+          </button>
+        ))}
+      </>
+    </Dropdown>
+  )
+}