summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-10-11 07:54:57 -0400
committerFrank <[email protected]>2025-10-11 07:54:57 -0400
commit133da0f44844105bdf069a00214f9a62905c98c7 (patch)
treee2e0da0ead51c1a067d639ae1c61a713d9fa992b
parentf93e1e5c92032499acf3c0defc24b545a5b770cd (diff)
downloadopencode-133da0f44844105bdf069a00214f9a62905c98c7.tar.gz
opencode-133da0f44844105bdf069a00214f9a62905c98c7.zip
wip: zen refactor selector
-rw-r--r--packages/console/app/src/component/dropdown.css80
-rw-r--r--packages/console/app/src/component/dropdown.tsx79
-rw-r--r--packages/console/app/src/routes/user-menu.css67
-rw-r--r--packages/console/app/src/routes/user-menu.tsx44
-rw-r--r--packages/console/app/src/routes/workspace-picker.css77
-rw-r--r--packages/console/app/src/routes/workspace-picker.tsx62
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/member-section.module.css207
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/member-section.tsx142
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css66
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/role-dropdown.tsx43
10 files changed, 337 insertions, 530 deletions
diff --git a/packages/console/app/src/component/dropdown.css b/packages/console/app/src/component/dropdown.css
new file mode 100644
index 000000000..982367c6b
--- /dev/null
+++ b/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);
+ }
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/component/dropdown.tsx b/packages/console/app/src/component/dropdown.tsx
new file mode 100644
index 000000000..de99d4481
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/console/app/src/routes/user-menu.css b/packages/console/app/src/routes/user-menu.css
index 28c7937f5..15700579a 100644
--- a/packages/console/app/src/routes/user-menu.css
+++ b/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;
- }
} \ No newline at end of file
diff --git a/packages/console/app/src/routes/user-menu.tsx b/packages/console/app/src/routes/user-menu.tsx
index 8c011fc0b..0af89f81b 100644
--- a/packages/console/app/src/routes/user-menu.tsx
+++ b/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>
)
}
diff --git a/packages/console/app/src/routes/workspace-picker.css b/packages/console/app/src/routes/workspace-picker.css
index dec482286..ab7f5be66 100644
--- a/packages/console/app/src/routes/workspace-picker.css
+++ b/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"] {
diff --git a/packages/console/app/src/routes/workspace-picker.tsx b/packages/console/app/src/routes/workspace-picker.tsx
index 34a544973..934ec1688 100644
--- a/packages/console/app/src/routes/workspace-picker.tsx
+++ b/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">
diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css
index 25b5eabf4..9d6bbb392 100644
--- a/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css
+++ b/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"] {
diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx
index e60049a7e..557cd3161 100644
--- a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx
+++ b/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>
diff --git a/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css b/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css
new file mode 100644
index 000000000..f54f92c4e
--- /dev/null
+++ b/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;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.tsx b/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.tsx
new file mode 100644
index 000000000..93dfb4e4b
--- /dev/null
+++ b/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>
+ )
+}