Skip to content
Open
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
11 changes: 9 additions & 2 deletions packages/toolbar/__tests__/ai-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,11 @@ describe('getTools', () => {
type: 'ais.autocomplete',
container: '#search',
placement: 'before',
parameters: { container: '#other', placement: 'after', showRecent: true },
parameters: {
container: '#other',
placement: 'after',
showRecent: true,
},
},
{ toolCallId: 'tc1', messages: [] }
);
Expand Down Expand Up @@ -656,7 +660,10 @@ describe('getTools', () => {

expect(result).toMatchObject({
success: true,
applied: ['cssVariables.primary-color-rgb', 'cssVariables.secondary-color'],
applied: [
'cssVariables.primary-color-rgb',
'cssVariables.secondary-color',
],
});
expect(callbacks.onCssVariableChange).toHaveBeenCalledTimes(2);
});
Expand Down
95 changes: 95 additions & 0 deletions packages/toolbar/__tests__/compute-tooltip-position.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';

import { computeTooltipPosition } from '../src/utils/compute-tooltip-position';

describe('computeTooltipPosition', () => {
it('centers the tooltip horizontally on the trigger', () => {
const result = computeTooltipPosition({
triggerRect: { left: 200, top: 100, width: 20 },
tooltipWidth: 100,
tooltipHeight: 30,
hostRect: { left: 0, right: 1000 },
});

// idealLeft = 200 + 10 - 50 = 160, no clamping needed
expect(result.left).toBe(160);
});

it('positions the tooltip above the trigger with a gap', () => {
const result = computeTooltipPosition({
triggerRect: { left: 200, top: 100, width: 20 },
tooltipWidth: 100,
tooltipHeight: 30,
hostRect: { left: 0, right: 1000 },
});

// top = 100 - 30 - 6 = 64
expect(result.top).toBe(64);
});

it('clamps to the left edge when the tooltip overflows left', () => {
const result = computeTooltipPosition({
triggerRect: { left: 10, top: 100, width: 20 },
tooltipWidth: 100,
tooltipHeight: 30,
hostRect: { left: 0, right: 1000 },
});

// idealLeft = 10 + 10 - 50 = -30, minLeft = 0 + 8 = 8
expect(result.left).toBe(8);
});

it('clamps to the right edge when the tooltip overflows right', () => {
const result = computeTooltipPosition({
triggerRect: { left: 480, top: 100, width: 20 },
tooltipWidth: 100,
tooltipHeight: 30,
hostRect: { left: 0, right: 500 },
});

// idealLeft = 480 + 10 - 50 = 440, maxRight - tooltipWidth = 500 - 8 - 100 = 392
expect(result.left).toBe(392);
});

it('accounts for a non-zero host left offset', () => {
const result = computeTooltipPosition({
triggerRect: { left: 110, top: 200, width: 20 },
tooltipWidth: 100,
tooltipHeight: 30,
hostRect: { left: 100, right: 900 },
});

// idealLeft = 110 + 10 - 50 = 70, minLeft = 100 + 8 = 108
expect(result.left).toBe(108);
});

it('uses viewport fallback when hostRect is null', () => {
// jsdom default window.innerWidth is 1024
const result = computeTooltipPosition({
triggerRect: { left: 500, top: 100, width: 20 },
tooltipWidth: 80,
tooltipHeight: 24,
hostRect: null,
});

// idealLeft = 500 + 10 - 40 = 470
// minLeft = 0 + 8 = 8, maxRight = 1024 - 8 = 1016
// 470 is within [8, 1016 - 80 = 936], so no clamping
expect(result.left).toBe(470);
expect(result.top).toBe(70);
});

it('handles a narrow host where both edges clamp', () => {
const result = computeTooltipPosition({
triggerRect: { left: 55, top: 100, width: 10 },
tooltipWidth: 80,
tooltipHeight: 20,
hostRect: { left: 50, right: 150 },
});

// idealLeft = 55 + 5 - 40 = 20
// minLeft = 50 + 8 = 58, maxRight - tooltipWidth = 150 - 8 - 80 = 62
// clamp: max(58, min(20, 62)) = max(58, 20) = 58
expect(result.left).toBe(58);
});
});
4 changes: 4 additions & 0 deletions packages/toolbar/src/components/block-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function BlockEditor({
const widgetType = WIDGET_TYPES[type];
const overrides = widgetType?.fieldOverrides ?? {};
const paramLabels = widgetType?.paramLabels ?? {};
const paramDescriptions = widgetType?.paramDescriptions ?? {};

const paramKeys = widgetType?.fieldOrder
? widgetType.fieldOrder.filter((key) => {
Expand Down Expand Up @@ -81,6 +82,7 @@ export function BlockEditor({
<TextField
key={key}
label={paramLabels[key] ?? key}
description={paramDescriptions[key]}
value={typeof value === 'string' ? value : JSON.stringify(value)}
onInput={(text) => {
return onParameterChange(key, text);
Expand All @@ -95,6 +97,7 @@ export function BlockEditor({
<SwitchField
key={key}
label={override.label}
description={paramDescriptions[key]}
checked={Boolean(value)}
onToggle={(checked) => {
return onParameterChange(key, checked);
Expand All @@ -110,6 +113,7 @@ export function BlockEditor({
<ObjectField
key={key}
label={override.label}
description={paramDescriptions[key]}
enabled={enabled}
value={objectValue}
defaultValue={override.defaultValue}
Expand Down
26 changes: 26 additions & 0 deletions packages/toolbar/src/components/fields/info-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Tooltip } from '../ui/tooltip';

type InfoTooltipProps = {
content: string;
class?: string;
};

export function InfoTooltip({ content, class: className }: InfoTooltipProps) {
return (
<Tooltip content={content} class={className}>
<svg
class="size-3.5 text-muted-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</Tooltip>
);
}
8 changes: 7 additions & 1 deletion packages/toolbar/src/components/fields/object-field.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useId, useState } from 'preact/hooks';

import { InfoTooltip } from './info-tooltip';
import { CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
import { TextField } from './text-field';

type ObjectFieldProps = {
label: string;
description?: string;
enabled: boolean;
value: Record<string, unknown>;
defaultValue: Record<string, unknown>;
Expand All @@ -17,6 +19,7 @@ type ObjectFieldProps = {

export function ObjectField({
label,
description,
enabled,
value,
defaultValue,
Expand All @@ -30,7 +33,10 @@ export function ObjectField({
return (
<div>
<div class="flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
<Label htmlFor={id}>
{label}
{description && <InfoTooltip content={description} class="mt-0.5" />}
</Label>
<Switch
id={id}
checked={enabled}
Expand Down
14 changes: 12 additions & 2 deletions packages/toolbar/src/components/fields/switch-field.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import { useId } from 'preact/hooks';

import { InfoTooltip } from './info-tooltip';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';

type SwitchFieldProps = {
label: string;
description?: string;
checked: boolean;
onToggle: (checked: boolean) => void;
};

export function SwitchField({ label, checked, onToggle }: SwitchFieldProps) {
export function SwitchField({
label,
description,
checked,
onToggle,
}: SwitchFieldProps) {
const id = useId();

return (
<div class="flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
<Label htmlFor={id}>
{label}
{description && <InfoTooltip content={description} class="mt-0.5" />}
</Label>
<Switch id={id} checked={checked} onCheckedChange={onToggle} />
</div>
);
Expand Down
15 changes: 13 additions & 2 deletions packages/toolbar/src/components/fields/text-field.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
import { useId } from 'preact/hooks';

import { InfoTooltip } from './info-tooltip';
import { Input } from '../ui/input';
import { Label } from '../ui/label';

type TextFieldProps = {
label: string;
description?: string;
value: string;
onInput: (value: string) => void;
readOnly?: boolean;
};

export function TextField({ label, value, onInput, readOnly }: TextFieldProps) {
export function TextField({
label,
description,
value,
onInput,
readOnly,
}: TextFieldProps) {
const id = useId();

return (
<div class="group space-y-1">
<Label htmlFor={id}>{label}</Label>
<Label htmlFor={id}>
{label}
{description && <InfoTooltip content={description} class="mt-0.5" />}
</Label>
<Input
id={id}
value={value}
Expand Down
87 changes: 87 additions & 0 deletions packages/toolbar/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { ComponentChildren } from 'preact';
import { useCallback, useId, useRef, useState } from 'preact/hooks';

import { cn } from '../../lib/utils';
import { computeTooltipPosition } from '../../utils/compute-tooltip-position';

type TooltipProps = {
content: ComponentChildren;
children: ComponentChildren;
class?: string;
};

type Position = { top: number; left: number };

function Tooltip({ content, children, class: className }: TooltipProps) {
const id = useId();
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<Position | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLSpanElement>(null);

const reposition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;

// Use the Shadow DOM host as the boundary for clamping. Falls back to
// the viewport width if rendered outside a Shadow DOM (e.g., in tests).
const root = trigger.getRootNode();
const host = root instanceof ShadowRoot ? (root.host as HTMLElement) : null;
const hostRect = host?.getBoundingClientRect() ?? null;
const triggerRect = trigger.getBoundingClientRect();

setPosition(
computeTooltipPosition({
triggerRect,
tooltipWidth: tooltip.offsetWidth,
tooltipHeight: tooltip.offsetHeight,
hostRect,
})
);
}, []);

const show = useCallback(() => {
setOpen(true);
requestAnimationFrame(reposition);
}, [reposition]);

const hide = useCallback(() => {
setOpen(false);
setPosition(null);
}, []);

return (
<span class={cn('inline-flex', className)}>
<button
ref={triggerRef}
type="button"
class="inline-flex cursor-help"
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
aria-describedby={open ? id : undefined}
>
{children}
</button>
{open && (
<span
id={id}
ref={tooltipRef}
role="tooltip"
class="bg-background text-foreground border-border fixed z-50 w-max max-w-56 rounded-md border px-2.5 py-1.5 text-xs shadow-md"
style={
position
? { top: `${position.top}px`, left: `${position.left}px` }
: { visibility: 'hidden' }
}
>
{content}
</span>
)}
</span>
);
}

export { Tooltip };
3 changes: 2 additions & 1 deletion packages/toolbar/src/toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@
background: none;
padding: 0;
}
ul, ol {
ul,
ol {
margin: 0.25em 0;
padding-left: 1.25em;
}
Expand Down
Loading