Explorar el Código

fix(data): select component ux

Adam hace 4 semanas
padre
commit
f026c1422c

+ 1 - 0
bun.lock

@@ -855,6 +855,7 @@
       "version": "1.17.19",
       "dependencies": {
         "@ibm/plex": "6.4.1",
+        "@kobalte/core": "catalog:",
         "@opencode-ai/stats-core": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@solidjs/meta": "catalog:",

+ 1 - 0
packages/stats/app/package.json

@@ -13,6 +13,7 @@
   },
   "dependencies": {
     "@ibm/plex": "6.4.1",
+    "@kobalte/core": "catalog:",
     "@opencode-ai/stats-core": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@solidjs/meta": "catalog:",

+ 25 - 41
packages/stats/app/src/routes/[lab]/[model].tsx

@@ -39,7 +39,7 @@ import {
   uniqueComparisonPairs,
   type ComparisonModelRef,
 } from "../compare-cards"
-import { createBreadcrumbMenuRoot } from "../breadcrumb-menu"
+import { BreadcrumbSelect } from "../breadcrumb-select"
 import {
   applyThemePreference,
   Footer,
@@ -285,10 +285,9 @@ function ModelHero(props: {
   const labModels = () =>
     props.catalogData?.labs.find((lab) => lab.id === providerSlug(labId()))?.models ??
     (props.catalog ? [props.catalog] : [])
-  const menuRoot = createBreadcrumbMenuRoot()
   return (
     <section id="overview" data-section="model-hero">
-      <nav ref={menuRoot} data-component="model-hero-breadcrumb" aria-label="Data breadcrumb">
+      <nav data-component="model-hero-breadcrumb" aria-label="Data breadcrumb">
         <a data-slot="model-hero-crumb" href={language.route(import.meta.env.BASE_URL)}>
           Data
         </a>
@@ -302,25 +301,17 @@ function ModelHero(props: {
             </span>
           }
         >
-          <details data-component="model-hero-menu">
-            <summary data-slot="model-hero-crumb" data-menu="true">
-              <span>{props.labName}</span>
-              <ChevronDownIcon />
-            </summary>
-            <div data-slot="model-hero-options">
-              <For each={labs()}>
-                {(lab) => (
-                  <a
-                    data-slot="model-hero-option"
-                    data-current={lab.id === providerSlug(labId()) ? "true" : undefined}
-                    href={language.route(`${import.meta.env.BASE_URL}${lab.id}`)}
-                  >
-                    {lab.name}
-                  </a>
-                )}
-              </For>
-            </div>
-          </details>
+          <BreadcrumbSelect
+            ariaLabel="Choose a lab"
+            label={props.labName}
+            options={labs().map((lab) => ({
+              href: language.route(`${import.meta.env.BASE_URL}${lab.id}`),
+              label: lab.name,
+              value: lab.id,
+            }))}
+            value={providerSlug(labId())}
+            variant="model"
+          />
         </Show>
         <span data-slot="model-hero-separator">/</span>
         <Show
@@ -332,25 +323,18 @@ function ModelHero(props: {
             </span>
           }
         >
-          <details data-component="model-hero-menu">
-            <summary data-slot="model-hero-crumb" data-menu="true" data-current="true" aria-current="page">
-              <span>{modelName()}</span>
-              <ChevronDownIcon />
-            </summary>
-            <div data-slot="model-hero-options">
-              <For each={labModels()}>
-                {(model) => (
-                  <a
-                    data-slot="model-hero-option"
-                    data-current={model.id === props.catalog?.id ? "true" : undefined}
-                    href={language.route(`${import.meta.env.BASE_URL}${model.id}`)}
-                  >
-                    {model.name}
-                  </a>
-                )}
-              </For>
-            </div>
-          </details>
+          <BreadcrumbSelect
+            ariaLabel="Choose a model"
+            current
+            label={modelName()}
+            options={labModels().map((model) => ({
+              href: language.route(`${import.meta.env.BASE_URL}${model.id}`),
+              label: model.name,
+              value: model.id,
+            }))}
+            value={props.catalog?.id ?? ""}
+            variant="model"
+          />
         </Show>
       </nav>
       <div data-slot="model-hero-title-row">

+ 15 - 24
packages/stats/app/src/routes/[lab]/index.tsx

@@ -29,7 +29,7 @@ import { SectionHeading } from "../section-heading"
 import { runStatsEffect } from "../../stats-runtime"
 import { setStatsPageCacheHeaders } from "../stats-cache"
 import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards"
-import { createBreadcrumbMenuRoot } from "../breadcrumb-menu"
+import { BreadcrumbSelect } from "../breadcrumb-select"
 import {
   applyThemePreference,
   Footer,
@@ -206,9 +206,9 @@ function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) {
 function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
   const language = useLanguage()
   const labs = () => props.labs ?? []
-  const menuRoot = createBreadcrumbMenuRoot()
+  const current = () => labs().find((lab) => lab.name === props.label)
   return (
-    <nav ref={menuRoot} data-component="lab-hero-breadcrumb" aria-label="Data breadcrumb">
+    <nav data-component="lab-hero-breadcrumb" aria-label="Data breadcrumb">
       <a data-slot="lab-hero-crumb" href={language.route(import.meta.env.BASE_URL)}>
         Data
       </a>
@@ -224,27 +224,18 @@ function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
           </span>
         }
       >
-        <details data-component="lab-hero-menu">
-          <summary data-slot="lab-hero-crumb" data-current="true" aria-current="page">
-            <span>{props.label}</span>
-            <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
-              <path d="M4.75 6.25L8 9.5L11.25 6.25" fill="none" stroke="currentColor" stroke-width="1.5" />
-            </svg>
-          </summary>
-          <div data-slot="lab-hero-options">
-            <For each={labs()}>
-              {(lab) => (
-                <a
-                  data-slot="lab-hero-option"
-                  data-current={lab.name === props.label ? "true" : undefined}
-                  href={language.route(`${import.meta.env.BASE_URL}${lab.id}`)}
-                >
-                  {lab.name}
-                </a>
-              )}
-            </For>
-          </div>
-        </details>
+        <BreadcrumbSelect
+          ariaLabel="Choose a lab"
+          current
+          label={props.label}
+          options={labs().map((lab) => ({
+            href: language.route(`${import.meta.env.BASE_URL}${lab.id}`),
+            label: lab.name,
+            value: lab.id,
+          }))}
+          value={current()?.id ?? ""}
+          variant="lab"
+        />
       </Show>
     </nav>
   )

+ 0 - 52
packages/stats/app/src/routes/breadcrumb-menu.ts

@@ -1,52 +0,0 @@
-import { onCleanup, onMount } from "solid-js"
-
-export function createBreadcrumbMenuRoot() {
-  let root: HTMLElement | undefined
-
-  onMount(() => {
-    const closeMenus = (except?: HTMLDetailsElement) => {
-      root?.querySelectorAll<HTMLDetailsElement>("details[open]").forEach((menu) => {
-        if (menu !== except) menu.open = false
-      })
-    }
-    const onPointerDown = (event: PointerEvent) => {
-      const path = event.composedPath()
-      root?.querySelectorAll<HTMLDetailsElement>("details[open]").forEach((menu) => {
-        if (!path.includes(menu)) menu.open = false
-      })
-    }
-    const onKeyDown = (event: KeyboardEvent) => {
-      if (event.key !== "Escape") return
-      const menu = root?.querySelector<HTMLDetailsElement>("details[open]")
-      if (!menu) return
-      event.preventDefault()
-      menu.open = false
-      menu.querySelector<HTMLElement>("summary")?.focus()
-    }
-    const onToggle = (event: Event) => {
-      if (!(event.target instanceof HTMLDetailsElement) || !event.target.open) return
-      closeMenus(event.target)
-    }
-    const onFocusOut = () => {
-      queueMicrotask(() => {
-        if (root?.contains(document.activeElement)) return
-        closeMenus()
-      })
-    }
-
-    document.addEventListener("pointerdown", onPointerDown)
-    document.addEventListener("keydown", onKeyDown)
-    root?.addEventListener("toggle", onToggle, true)
-    root?.addEventListener("focusout", onFocusOut)
-    onCleanup(() => {
-      document.removeEventListener("pointerdown", onPointerDown)
-      document.removeEventListener("keydown", onKeyDown)
-      root?.removeEventListener("toggle", onToggle, true)
-      root?.removeEventListener("focusout", onFocusOut)
-    })
-  })
-
-  return (element: HTMLElement) => {
-    root = element
-  }
-}

+ 71 - 0
packages/stats/app/src/routes/breadcrumb-select.tsx

@@ -0,0 +1,71 @@
+import { Select } from "@kobalte/core/select"
+import { createMemo } from "solid-js"
+
+export type BreadcrumbSelectOption = {
+  href: string
+  label: string
+  value: string
+}
+
+export function BreadcrumbSelect(props: {
+  ariaLabel: string
+  current?: boolean
+  label: string
+  options: BreadcrumbSelectOption[]
+  value: string
+  variant: "lab" | "model"
+}) {
+  const selected = createMemo(() => props.options.find((option) => option.value === props.value) ?? null)
+
+  return (
+    <Select<BreadcrumbSelectOption>
+      data-component="stats-breadcrumb-select"
+      data-variant={props.variant}
+      options={props.options}
+      optionValue="value"
+      optionTextValue="label"
+      value={selected()}
+      placeholder={props.label}
+      disallowEmptySelection
+      closeOnSelection
+      shouldFocusWrap
+      placement="bottom"
+      gutter={8}
+      sameWidth={false}
+      itemComponent={(itemProps) => (
+        <Select.Item
+          item={itemProps.item}
+          data-slot="stats-breadcrumb-select-option"
+          data-current={itemProps.item.rawValue.value === props.value ? "true" : undefined}
+        >
+          <Select.ItemLabel>{itemProps.item.rawValue.label}</Select.ItemLabel>
+        </Select.Item>
+      )}
+      onChange={(option) => {
+        if (!option || option.value === props.value) return
+        window.location.assign(option.href)
+      }}
+    >
+      <Select.Trigger
+        data-slot="stats-breadcrumb-select-trigger"
+        data-current={props.current ? "true" : undefined}
+        data-variant={props.variant}
+        aria-label={props.ariaLabel}
+        aria-current={props.current ? "page" : undefined}
+      >
+        <Select.Value<BreadcrumbSelectOption> data-slot="stats-breadcrumb-select-value">
+          {(state) => <span>{state.selectedOption()?.label ?? props.label}</span>}
+        </Select.Value>
+        <Select.Icon data-slot="stats-breadcrumb-select-icon">
+          <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
+            <path d="M4.75 6.25L8 9.5L11.25 6.25" fill="none" stroke="currentColor" stroke-width="1.5" />
+          </svg>
+        </Select.Icon>
+      </Select.Trigger>
+      <Select.HiddenSelect />
+      <Select.Content data-component="stats-breadcrumb-select-content" data-variant={props.variant}>
+        <Select.Listbox data-slot="stats-breadcrumb-select-listbox" />
+      </Select.Content>
+    </Select>
+  )
+}

+ 1 - 1
packages/stats/app/src/routes/compare/index.tsx

@@ -134,7 +134,7 @@ export default function ModelCompareIndex() {
                 Data
               </a>
               <span data-slot="compare-home-separator">/</span>
-              <span data-slot="compare-home-crumb" data-current="true">
+              <span data-slot="compare-home-crumb" data-current="true" aria-current="page">
                 Compare
               </span>
             </nav>

+ 99 - 106
packages/stats/app/src/routes/index.css

@@ -2900,29 +2900,13 @@
   color: var(--stats-faint);
 }
 
-[data-page="stats"] [data-component="lab-hero-menu"] {
-  position: relative;
-  display: inline-block;
-}
-
-[data-page="stats"] [data-component="lab-hero-menu"] summary {
-  cursor: pointer;
-  list-style: none;
-}
-
-[data-page="stats"] [data-component="lab-hero-menu"] summary::-webkit-details-marker {
-  display: none;
-}
-
 [data-page="stats"] [data-slot="lab-hero-crumb"][data-current="true"] {
   padding-right: 4px;
   color: var(--stats-text);
 }
 
 [data-page="stats"] a[data-slot="lab-hero-crumb"]:hover,
-[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible,
-[data-page="stats"] summary[data-slot="lab-hero-crumb"]:hover,
-[data-page="stats"] summary[data-slot="lab-hero-crumb"]:focus-visible {
+[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible {
   color: var(--stats-text);
   outline: none;
   text-decoration: none;
@@ -2941,57 +2925,6 @@
   color: currentColor;
 }
 
-[data-page="stats"] [data-component="lab-hero-menu"][open] [data-slot="lab-hero-crumb"] svg {
-  transform: rotate(180deg);
-}
-
-[data-page="stats"] [data-slot="lab-hero-options"] {
-  position: absolute;
-  top: calc(100% + 8px);
-  left: 50%;
-  z-index: 5;
-  display: grid;
-  width: max-content;
-  min-width: 180px;
-  max-width: min(280px, calc(100vw - 48px));
-  max-height: min(360px, calc(100vh - 160px));
-  padding: 6px;
-  overflow: auto;
-  border: 1px solid var(--stats-line);
-  background: var(--stats-bg);
-  box-shadow: 0 12px 32px #0000001a;
-  transform: translateX(-50%);
-}
-
-[data-page="stats"] [data-slot="lab-hero-option"] {
-  display: flex;
-  align-items: center;
-  min-width: 0;
-  height: 30px;
-  padding: 0 8px;
-  overflow: hidden;
-  color: var(--stats-muted);
-  font-size: 13px;
-  font-weight: 500;
-  line-height: 1.2;
-  text-decoration: none;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-[data-page="stats"] [data-slot="lab-hero-option"]:hover,
-[data-page="stats"] [data-slot="lab-hero-option"]:focus-visible {
-  background: var(--stats-layer-2);
-  color: var(--stats-text);
-  outline: none;
-  text-decoration: none;
-}
-
-[data-page="stats"] [data-slot="lab-hero-option"][data-current="true"] {
-  background: var(--stats-layer-2);
-  color: var(--stats-text);
-}
-
 [data-page="stats"] [data-slot="lab-hero-separator"] {
   display: inline-flex;
   align-items: center;
@@ -3125,92 +3058,150 @@
   color: var(--stats-text);
 }
 
-[data-page="stats"] [data-component="model-hero-menu"] {
-  position: relative;
-  display: inline-block;
+[data-page="stats"] [data-component="stats-breadcrumb-select"] {
+  display: inline-flex;
   min-width: 0;
 }
 
-[data-page="stats"] [data-component="model-hero-menu"] summary {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"] {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  max-width: min(100%, 280px);
+  height: 24px;
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0 4px 0 8px;
+  overflow: hidden;
+  border: 0;
+  border-radius: 0;
+  appearance: none;
+  background: var(--stats-layer-2);
+  color: var(--stats-hero-muted);
+  font: inherit;
+  font-size: 13px;
+  font-weight: 500;
+  line-height: 1.1;
+  white-space: nowrap;
   cursor: pointer;
-  list-style: none;
 }
 
-[data-page="stats"] [data-component="model-hero-menu"] summary::-webkit-details-marker {
-  display: none;
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-variant="lab"] {
+  color: var(--stats-faint);
 }
 
-[data-page="stats"] a[data-slot="model-hero-crumb"]:hover,
-[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible,
-[data-page="stats"] summary[data-slot="model-hero-crumb"]:hover,
-[data-page="stats"] summary[data-slot="model-hero-crumb"]:focus-visible {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-current="true"],
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:hover,
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-expanded] {
   color: var(--stats-text);
-  outline: none;
-  text-decoration: none;
 }
 
-[data-page="stats"] [data-slot="model-hero-crumb"] span {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:focus-visible {
+  color: var(--stats-text);
+  outline: 1px solid var(--stats-text);
+  outline-offset: 2px;
+}
+
+[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] {
   min-width: 0;
   overflow: hidden;
+}
+
+[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] span {
+  display: block;
+  overflow: hidden;
   text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-[data-page="stats"] [data-slot="model-hero-crumb"] svg {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] {
+  display: inline-flex;
   flex: 0 0 auto;
+}
+
+[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] svg {
   width: 16px;
   height: 16px;
   color: currentColor;
 }
 
-[data-page="stats"] [data-component="model-hero-menu"][open] [data-slot="model-hero-crumb"] svg {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-expanded]
+  [data-slot="stats-breadcrumb-select-icon"] {
   transform: rotate(180deg);
 }
 
-[data-page="stats"] [data-slot="model-hero-options"] {
-  position: absolute;
-  top: calc(100% + 8px);
-  left: 50%;
-  z-index: 5;
-  display: grid;
+[data-page="stats"] [data-popper-positioner]:has([data-component="stats-breadcrumb-select-content"]) {
+  z-index: 20;
+}
+
+[data-page="stats"] [data-component="stats-breadcrumb-select-content"] {
+  z-index: 20;
   width: max-content;
   min-width: 180px;
   max-width: min(360px, calc(100vw - 48px));
-  max-height: min(360px, calc(100vh - 160px));
-  padding: 6px;
-  overflow: auto;
+  overflow: hidden;
   border: 1px solid var(--stats-line);
   background: var(--stats-bg);
   box-shadow: 0 12px 32px #0000001a;
-  transform: translateX(-50%);
 }
 
-[data-page="stats"] [data-slot="model-hero-option"] {
+[data-page="stats"] [data-component="stats-breadcrumb-select-content"][data-variant="lab"] {
+  max-width: min(280px, calc(100vw - 48px));
+}
+
+[data-page="stats"] [data-slot="stats-breadcrumb-select-listbox"] {
+  display: grid;
+  max-height: min(360px, calc(100vh - 160px));
+  box-sizing: border-box;
+  margin: 0;
+  padding: 6px;
+  overflow: auto;
+  list-style: none;
+  outline: none;
+}
+
+[data-page="stats"] [data-slot="stats-breadcrumb-select-option"] {
   display: flex;
   align-items: center;
   min-width: 0;
   height: 30px;
+  box-sizing: border-box;
   padding: 0 8px;
   overflow: hidden;
   color: var(--stats-muted);
   font-size: 13px;
   font-weight: 500;
   line-height: 1.2;
-  text-decoration: none;
   text-overflow: ellipsis;
   white-space: nowrap;
+  cursor: pointer;
+  outline: none;
 }
 
-[data-page="stats"] [data-slot="model-hero-option"]:hover,
-[data-page="stats"] [data-slot="model-hero-option"]:focus-visible {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-highlighted],
+[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-selected] {
   background: var(--stats-layer-2);
   color: var(--stats-text);
+}
+
+[data-page="stats"] a[data-slot="model-hero-crumb"]:hover,
+[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible {
+  color: var(--stats-text);
   outline: none;
   text-decoration: none;
 }
 
-[data-page="stats"] [data-slot="model-hero-option"][data-current="true"] {
-  background: var(--stats-layer-2);
-  color: var(--stats-text);
+[data-page="stats"] [data-slot="model-hero-crumb"] span {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+[data-page="stats"] [data-slot="model-hero-crumb"] svg {
+  flex: 0 0 auto;
+  width: 16px;
+  height: 16px;
+  color: currentColor;
 }
 
 [data-page="stats"] [data-slot="model-hero-separator"] {
@@ -5545,7 +5536,8 @@
   color: var(--stats-hero-muted);
 }
 
-[data-page="stats"] [data-slot="compare-home-crumb"][data-current="true"] {
+[data-page="stats"] [data-component="compare-home-breadcrumb"]
+  [data-slot="compare-home-crumb"][data-current="true"] {
   color: var(--stats-text);
 }
 
@@ -7892,6 +7884,11 @@
     max-width: min(100%, 240px);
   }
 
+  [data-page="stats"] [data-component="model-hero-breadcrumb"]
+    [data-slot="stats-breadcrumb-select-trigger"] {
+    max-width: min(100%, 240px);
+  }
+
   [data-page="stats"] [data-slot="model-hero-title-row"] {
     flex-wrap: wrap;
     gap: 16px;
@@ -7983,10 +7980,6 @@
     min-width: 48px;
   }
 
-  [data-page="stats"] [data-slot="lab-hero-options"] {
-    max-width: calc(100vw - 48px);
-  }
-
   [data-page="stats"] [data-component="lab-related-list"] {
     grid-auto-columns: minmax(320px, 392px);
     grid-auto-flow: column;