Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 114 additions & 27 deletions packages/ui/src/components/popover.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@

[data-component="popover-content"] {
z-index: 50;
min-width: 200px;
max-width: 320px;
border-radius: 14px;
background-color: var(--surface-raised);

border-radius: var(--radius-md);
background-color: var(--surface-base);
background-clip: padding-box;
box-shadow: var(--shadow-floating);
box-shadow: var(--ring-base), var(--shadow-floating);

transform-origin: var(--kb-popover-content-transform-origin);

Expand All @@ -19,17 +16,16 @@
}

&[data-closed] {
animation: popover-close 0.15s ease-out;
animation: popover-close var(--duration-base) ease-out;
}

&[data-expanded] {
animation: popover-open 0.15s ease-out;
animation: popover-open var(--duration-base) ease-out;
}

[data-slot="popover-header"] {
display: flex;
padding: 12px;
padding-bottom: 0;
padding: 10px 12px 8px;
justify-content: space-between;
align-items: center;
gap: 8px;
Expand All @@ -38,13 +34,7 @@
flex: 1;
color: var(--fg-strong);
margin: 0;

font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
font: var(--type-h3);
}

[data-slot="popover-close-button"] {
Expand All @@ -53,27 +43,124 @@
}

[data-slot="popover-description"] {
padding: 0 12px;
padding: 0 12px 8px;
margin: 0;
color: var(--fg-base);

font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
font: var(--type-body);
}

[data-slot="popover-body"] {
padding: 4px;
}
}

/* ── Embedded search slot (model picker / branch picker pattern) ── */

[data-slot="popover-search"] {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-weaker);

input,
[data-slot="popover-search-input"] {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--fg-strong);
font: var(--type-body);

&::placeholder {
color: var(--fg-weak);
}

&:focus-visible {
outline: none;
border-radius: 2px;
box-shadow: var(--shadow-xs-border-focus);
}
}
}

/* ── Menu item primitives (used by popover-body consumers) ── */

[data-slot="popover-item"] {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 8px;
border-radius: var(--radius-sm);
cursor: default;
color: var(--fg-strong);
font: var(--type-body);
white-space: nowrap;
user-select: none;

&:hover,
&[data-force-state="hover"] {
background: var(--bg-cream);
}

&[data-active],
&[data-force-state="active"] {
background: var(--bg-cream);
opacity: 0.8;
}

[data-slot="popover-arrow"] {
fill: var(--surface-raised);
&:focus-visible,
&[data-force-state="focus-visible"] {
outline: none;
background: var(--bg-cream);
}

&[data-selected],
&[data-force-state="selected"] {
background: var(--bg-cream);
}
Comment thread
Astro-Han marked this conversation as resolved.

&[data-disabled],
&[data-force-state="disabled"] {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
}

[data-slot="popover-item-icon"] {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
color: var(--icon-base);
}

[data-slot="popover-item-shortcut"] {
margin-left: auto;
font: var(--type-kbd);
color: var(--fg-weak);
}

[data-slot="popover-separator"] {
height: 1px;
margin: 4px 0;
background: var(--border-weaker);
}

[data-slot="popover-item"][data-variant="danger"] {
color: var(--error);

&:hover,
&[data-force-state="hover"] {
background: var(--error-bg);
}
}

/* ── Animations ── */

@keyframes popover-open {
from {
opacity: 0;
Expand Down
159 changes: 121 additions & 38 deletions packages/ui/src/components/popover.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,13 @@
// @ts-nocheck
/**
* Hover / active / focus-visible states are captured statically via
* data-force-state="hover|active|focus-visible|selected|disabled" +
* matching CSS rules in popover.css.
*/
import { createSignal } from "solid-js"
import * as mod from "./popover"
import { create } from "../storybook/scaffold"

const docs = `### Overview
Composable popover with optional title, description, and close button.

Use for small contextual details; avoid long forms.

### API
- \`trigger\` and \`children\` define the anchor and content.
- Optional: \`title\`, \`description\`, \`portal\`, \`open\`, \`defaultOpen\`.

### Variants and states
- Supports controlled and uncontrolled open state.

### Behavior
- Closes on outside click or Escape by default.

### Accessibility
- TODO: confirm focus management from Kobalte.

### Theming/tokens
- Uses \`data-component="popover-content"\` and related slots.

`

const story = create({
title: "UI/Popover",
mod,
Expand All @@ -43,25 +25,10 @@ export default {
id: "components-popover",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}

export const Basic = story.Basic

export const NoHeader = {
args: {
title: undefined,
description: undefined,
children: "Popover body only",
},
}

export const Inline = {
args: {
portal: false,
Expand All @@ -85,3 +52,119 @@ export const Controlled = {
)
},
}

/**
* All item states side-by-side (light + dark).
* Hover/focus/selected captured via data-force-state.
*/
export const MenuMatrix = {
render: () => (
<div style={{ display: "flex", gap: "16px", "align-items": "flex-start" }}>
{/* Light */}
<div
style={{
background: "var(--surface-base)",
"border-radius": "var(--radius-md)",
"box-shadow": "var(--ring-base), var(--shadow-floating)",
padding: "4px",
"min-width": "184px",
}}
>
<div data-slot="popover-item">Default item</div>
<div data-slot="popover-item" data-force-state="hover">
Hovered item
</div>
<div data-slot="popover-item" data-force-state="active">
Active item
</div>
<div data-slot="popover-item" data-force-state="focus-visible">
Focused item
</div>
<div data-slot="popover-item" data-force-state="selected">
Selected item
</div>
<div data-slot="popover-item" data-force-state="disabled">
Disabled item
</div>
<div data-slot="popover-item">
<span data-slot="popover-item-icon">⌘</span>
With icon
<span data-slot="popover-item-shortcut">⌘K</span>
</div>
<div data-slot="popover-separator" />
<div data-slot="popover-item" data-variant="danger">
Delete
</div>
</div>

{/* Dark */}
<div
data-color-scheme="dark"
style={{
background: "var(--surface-base)",
"border-radius": "var(--radius-md)",
"box-shadow": "var(--ring-base), var(--shadow-floating)",
padding: "4px",
"min-width": "184px",
}}
>
<div data-slot="popover-item">Default item</div>
<div data-slot="popover-item" data-force-state="hover">
Hovered item
</div>
<div data-slot="popover-item" data-force-state="selected">
Selected item
</div>
<div data-slot="popover-item" data-force-state="disabled">
Disabled item
</div>
<div data-slot="popover-separator" />
<div data-slot="popover-item" data-variant="danger" data-force-state="hover">
Delete (hover)
</div>
</div>
</div>
),
}

/**
* Embedded search slot — no border, no radius, 1px --border-weaker bottom divider.
* autofocus triggers static :focus-visible ring screenshot.
*/
export const WithSearch = {
render: () => (
<div
style={{
background: "var(--surface-base)",
"border-radius": "var(--radius-md)",
"box-shadow": "var(--ring-base), var(--shadow-floating)",
"min-width": "184px",
}}
>
<div data-slot="popover-search">
<input
data-slot="popover-search-input"
placeholder="Search…"
autofocus
/>
</div>
<div style={{ padding: "4px" }}>
<div data-slot="popover-item">Result one</div>
<div data-slot="popover-item">Result two</div>
</div>
</div>
),
}

export const WithDanger = {
render: () => (
<mod.Popover trigger="Open" defaultOpen portal={false}>
<div data-slot="popover-item">Rename</div>
<div data-slot="popover-item">Export…</div>
<div data-slot="popover-separator" />
<div data-slot="popover-item" data-variant="danger">
Delete
</div>
</mod.Popover>
),
}
2 changes: 1 addition & 1 deletion packages/ui/src/components/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
)

return (
<Kobalte gutter={4} {...rest} open={opened()} onOpenChange={onOpenChange} modal={local.modal ?? false}>
<Kobalte gutter={8} {...rest} open={opened()} onOpenChange={onOpenChange} modal={local.modal ?? false}>
<Kobalte.Trigger
ref={(el: HTMLElement) => setState("triggerRef", el)}
as={local.triggerAs ?? "div"}
Expand Down
Loading
Loading