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
53 changes: 39 additions & 14 deletions src/components/plugins/PluginToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function PluginFiltersPopover({
const id = useId();
const filtersTitleId = `${id}-title`;
const hookTriggerId = `${id}-hook`;
const tagsLabelId = `${id}-tags-label`;

return (
<Popover>
Expand All @@ -94,7 +95,7 @@ function PluginFiltersPopover({
type="button"
variant="ghost"
size="sm"
className="w-fit gap-2 self-center text-xs text-secondary-foreground"
className="w-fit shrink-0 gap-2 text-xs text-secondary-foreground"
aria-label={intl.formatMessage(
{ id: "plugins.catalog.filtersActive" },
{ count: activeFilterCount },
Expand All @@ -109,8 +110,14 @@ function PluginFiltersPopover({
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 space-y-4" aria-labelledby={filtersTitleId}>
<div className="flex items-center justify-between">
{/* End-aligned because the trigger sits at the toolbar's right edge, where a
start-aligned panel would expand past the viewport. */}
<PopoverContent
align="end"
className="@container flex w-[calc(100vw-2rem)] flex-col gap-4 md:w-[18rem] lg:w-[24rem] xl:w-[32rem]"
aria-labelledby={filtersTitleId}
>
<div className="flex shrink-0 items-center justify-between">
<h2 id={filtersTitleId} className="text-sm font-semibold">
{intl.formatMessage({ id: "plugins.catalog.filters" })}
</h2>
Expand All @@ -121,7 +128,7 @@ function PluginFiltersPopover({
)}
</div>

<div className="space-y-2">
<div className="shrink-0 space-y-2">
<Label htmlFor={hookTriggerId} className="text-xs">
{intl.formatMessage({ id: "plugins.catalog.hook" })}
</Label>
Expand All @@ -148,28 +155,46 @@ function PluginFiltersPopover({
</div>

{availableTags.length > 0 && (
<fieldset className="space-y-2">
<legend className="text-xs font-medium">
// role=group rather than fieldset: a rendered legend is not subtracted
// from the height flex assigns its fieldset, so the box overflows it.
<div
role="group"
aria-labelledby={tagsLabelId}
className="flex min-h-0 flex-1 flex-col gap-2"
>
<span id={tagsLabelId} className="text-xs font-medium">
{intl.formatMessage({ id: "plugins.catalog.tags" })}
</legend>
<div className="max-h-40 space-y-2 overflow-y-auto rounded-md border p-2">
</span>
{/* A grid rather than CSS columns: a height-capped multi-column box
overflows sideways into new columns instead of scrolling down.

Container queries measure the panel's content box, which is 2rem
of padding and 2px of border narrower than the widths set above:
18/24/32rem of panel leave 15.875/21.875/29.875rem to query. The
thresholds have to sit inside those, so they read a step lower
than the panel width that triggers them. */}
<div className="scrollbar-thin grid max-h-120 min-h-0 flex-1 grid-cols-1 gap-x-4 gap-y-1 overflow-y-auto rounded-md border p-2 pl-3 @xs:grid-cols-2 @md:grid-cols-3">
{availableTags.map((tag, index) => {
const checkboxId = `${id}-tag-${index}`;
return (
<div key={tag} className="flex items-center gap-2">
<div key={tag} className="flex min-w-0 items-center gap-2">
<Checkbox
id={checkboxId}
checked={selectedTags.includes(tag)}
onCheckedChange={(checked) => onToggleTag(tag, checked === true)}
/>
<Label htmlFor={checkboxId} className="cursor-pointer text-sm font-normal">
{/* Full-height label so the tap target clears 44px on touch. */}
<Label
htmlFor={checkboxId}
className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center break-words text-sm font-normal sm:min-h-8"
Comment thread
a-effort marked this conversation as resolved.
>
{tag}
</Label>
</div>
);
})}
</div>
</fieldset>
</div>
)}
</PopoverContent>
</Popover>
Expand All @@ -189,14 +214,14 @@ export function PluginToolbar({
<div className="flex flex-col gap-4 py-6 lg:flex-row lg:items-center lg:justify-between">
<PluginViewToggle enabledOnly={enabledOnly} onChange={onEnabledOnlyChange} />

<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
<div className="flex w-full items-center justify-end gap-2 lg:w-auto">
<ListSearch
value={search}
onChange={onSearchChange}
ariaLabel={intl.formatMessage({ id: "plugins.catalog.searchLabel" })}
placeholder={intl.formatMessage({ id: "plugins.catalog.searchPlaceholder" })}
className="w-full sm:w-auto"
expandedWidthClassName="w-full sm:w-[432px]"
className="min-w-0 flex-1 justify-end lg:flex-none"
expandedWidthClassName="min-w-0 flex-1 lg:w-[432px] lg:flex-none"
/>

<PluginFiltersPopover {...filterProps} />
Expand Down
8 changes: 4 additions & 4 deletions src/components/server-catalog/CatalogToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ function CatalogFiltersDialog({
type="button"
variant="ghost"
size="sm"
className="w-fit gap-2 self-center text-xs text-secondary-foreground"
className="w-fit shrink-0 gap-2 text-xs text-secondary-foreground"
aria-label={intl.formatMessage(
{ id: "mcpServer.catalog.filtersActive" },
{ count: activeFilterCount },
Expand Down Expand Up @@ -324,14 +324,14 @@ export function CatalogToolbar({
<div className="flex flex-col gap-4 py-6 lg:flex-row lg:items-center lg:justify-between">
<CatalogViewToggle installedOnly={installedOnly} onChange={onInstalledChange} />

<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
<div className="flex w-full items-center justify-end gap-2 lg:w-auto">
<ListSearch
value={search}
onChange={onSearchChange}
ariaLabel={intl.formatMessage({ id: "mcpServer.catalog.searchLabel" })}
placeholder={intl.formatMessage({ id: "mcpServer.catalog.searchPlaceholder" })}
className="w-full sm:w-auto"
expandedWidthClassName="w-full sm:w-[432px]"
className="min-w-0 flex-1 justify-end lg:flex-none"
expandedWidthClassName="min-w-0 flex-1 lg:w-[432px] lg:flex-none"
/>

<CatalogFiltersDialog {...filterProps} />
Expand Down
6 changes: 5 additions & 1 deletion src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function PopoverContent({
className,
align = "center",
sideOffset = 4,
collisionPadding = 16,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
Expand All @@ -23,8 +24,11 @@ function PopoverContent({
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
// Clamped to the space Radix measured between the trigger and the
// viewport edge, so a wide or tall panel can never run off screen.
"scrollbar-thin z-50 max-h-(--radix-popover-content-available-height) w-72 max-w-[calc(100vw-2rem)] origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
Expand Down
17 changes: 10 additions & 7 deletions src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
// Collapses to a sheet below Tailwind's `lg`. Must stay in step with the `lg:`
// utilities that show the docked sidebar, or both would render at once.
const SIDEBAR_COLLAPSE_BREAKPOINT = 1024;
Comment thread
a-effort marked this conversation as resolved.

type SidebarContextProps = {
state: "expanded" | "collapsed";
Expand Down Expand Up @@ -59,7 +62,7 @@ function SidebarProvider({
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const isMobile = useIsMobile(SIDEBAR_COLLAPSE_BREAKPOINT);
Comment thread
a-effort marked this conversation as resolved.
const [openMobile, setOpenMobile] = React.useState(false);

// This is the internal state of the sidebar.
Expand Down Expand Up @@ -197,7 +200,7 @@ function Sidebar({

return (
<div
className="group peer hidden text-sidebar-foreground md:block"
className="group peer hidden text-sidebar-foreground lg:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
Expand All @@ -220,7 +223,7 @@ function Sidebar({
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] lg:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
Expand Down Expand Up @@ -293,7 +296,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-main md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
"relative flex w-full flex-1 flex-col bg-main lg:peer-data-[variant=inset]:m-2 lg:peer-data-[variant=inset]:ml-0 lg:peer-data-[variant=inset]:rounded-xl lg:peer-data-[variant=inset]:shadow-sm lg:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
Expand Down Expand Up @@ -402,7 +405,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 lg:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
)}
{...props}
Expand Down Expand Up @@ -531,9 +534,9 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 lg:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 lg:opacity-0",
className,
)}
{...props}
Expand Down
50 changes: 37 additions & 13 deletions src/hooks/use-mobile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useIsMobile } from "./use-mobile";
describe("useIsMobile", () => {
let addEventListenerMock: ReturnType<typeof vi.fn>;
let removeEventListenerMock: ReturnType<typeof vi.fn>;
let matchMediaMock: ReturnType<typeof vi.fn>;
let changeHandler: (() => void) | null = null;

beforeEach(() => {
Expand All @@ -15,19 +16,18 @@ describe("useIsMobile", () => {
});
removeEventListenerMock = vi.fn();

vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: addEventListenerMock,
removeEventListener: removeEventListenerMock,
dispatchEvent: vi.fn(),
})),
);
matchMediaMock = vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: addEventListenerMock,
removeEventListener: removeEventListenerMock,
dispatchEvent: vi.fn(),
}));

vi.stubGlobal("matchMedia", matchMediaMock);
});

afterEach(() => {
Expand Down Expand Up @@ -58,6 +58,30 @@ describe("useIsMobile", () => {
expect(result.current).toBe(true);
});

it("queries and compares against a custom breakpoint", () => {
vi.stubGlobal("innerWidth", 900);
const { result } = renderHook(() => useIsMobile(1024));

// 900 is desktop against the 768 default but mobile against 1024, so this
// fails if the parameter is ignored.
expect(result.current).toBe(true);
expect(matchMediaMock).toHaveBeenCalledWith("(max-width: 1023px)");
});

it("resubscribes when the breakpoint changes", () => {
vi.stubGlobal("innerWidth", 900);
const { result, rerender } = renderHook(({ breakpoint }) => useIsMobile(breakpoint), {
initialProps: { breakpoint: 1024 },
});
expect(result.current).toBe(true);

rerender({ breakpoint: 768 });

expect(removeEventListenerMock).toHaveBeenCalled();
expect(matchMediaMock).toHaveBeenCalledWith("(max-width: 767px)");
expect(result.current).toBe(false);
});

it("should clean up event listener on unmount", () => {
const { unmount } = renderHook(() => useIsMobile());
expect(addEventListenerMock).toHaveBeenCalled();
Expand Down
10 changes: 5 additions & 5 deletions src/hooks/use-mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import * as React from "react";

const MOBILE_BREAKPOINT = 768;

export function useIsMobile() {
export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT) {
Comment thread
a-effort marked this conversation as resolved.
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);

React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
setIsMobile(window.innerWidth < breakpoint);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
setIsMobile(window.innerWidth < breakpoint);
return () => mql.removeEventListener("change", onChange);
}, []);
}, [breakpoint]);

return !!isMobile;
}
14 changes: 14 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@

/* Light theme */
:root {
/* Tells the browser which palette to paint native UI with — scrollbars, form
controls, the caret. Without it the .dark class swaps our custom properties
but leaves that chrome light. */
color-scheme: light;

--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);

Expand Down Expand Up @@ -146,6 +151,8 @@
---break---
*/
.dark {
color-scheme: dark;

--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
Expand Down Expand Up @@ -186,6 +193,13 @@
/* Status-headline icon accent (brand cyan) — bright on the dark background. */
--status-icon: #70f9ff;
}
/* A transparent track so the scrollbar doesn't paint a solid bar over the
rounded corners of the box it scrolls in. */
@utility scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--muted-foreground) transparent;
}

/*
---break---
*/
Expand Down
Loading