refactor: migrate web Tabs to @makeplane/propel (#9752)

* refactor: migrate web Tabs to @makeplane/propel

Replace in-repo @plane/propel/tabs with @makeplane/propel/components/tabs
in analytics, the image picker popover, and the page navigation pane.

Published Tabs omit className on Tabs and TabsPanel, so fill-height
layouts use a flex/grid wrapper instead. Tabs.Trigger, Tabs.Content, and
Tabs.Indicator become Tab, TabsList, and TabsPanel.

* fix: restore cover picker tab strip to full width

Published TabsList is inline-flex, so Images/Upload hugged content
instead of spanning the 36rem panel. Stretch the list to the previous
full-width segmented bar.

* chore: sync apps/space lockfile to @makeplane/propel 0.3.0

preview's lockfile still resolved apps/space to 0.2.0 after the catalog
bump, so pnpm install --frozen-lockfile failed in CI.

* fix: restore cover picker tab panel mt-3 p-3 spacing

Match the previous scroll wrapper inset so thumbnail grid sizing stays
aligned with the 36rem picker layout.
This commit is contained in:
Anmol Singh Bhatia 2026-09-07 18:22:35 +05:30 committed by GitHub
parent 8803088086
commit 439fa3daea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 206 additions and 205 deletions

View file

@ -11,7 +11,7 @@ import { useRouter } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import { Tabs } from "@plane/propel/tabs";
import { Tab, Tabs, TabsList, TabsPanel } from "@makeplane/propel/components/tabs";
// components
import { cn } from "@plane/utils";
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
@ -73,45 +73,32 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
<>
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
<div className="flex h-full overflow-hidden">
<Tabs value={selectedTab} onValueChange={handleTabChange} className="h-full w-full">
<Tabs variant="contained" value={selectedTab} onValueChange={handleTabChange}>
<div className={"flex h-full w-full flex-col"}>
<div
className={cn(
"flex w-full items-center justify-between gap-4 overflow-hidden border-b border-subtle bg-surface-1 px-6 py-2"
)}
>
<Tabs.List className={"flex h-7 w-fit overflow-x-auto"}>
<TabsList>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Trigger
key={tab.key}
value={tab.key}
disabled={tab.isDisabled}
size="md"
className="h-6 px-3"
onClick={() => {
if (!tab.isDisabled) {
handleTabChange(tab.key);
}
}}
>
{tab.label}
</Tabs.Trigger>
<Tab key={tab.key} value={tab.key} disabled={tab.isDisabled} label={tab.label} />
))}
</Tabs.List>
</TabsList>
<div className="flex-shrink-0">
<AnalyticsFilterActions />
</div>
</div>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Content
key={tab.key}
value={tab.key}
className={"h-full overflow-hidden overflow-y-auto px-2"}
>
<tab.content />
</Tabs.Content>
))}
{/* Grid wrapper: Propel's TabsPanel omits className, so the single mounted panel
gets its fill height from a one-row grid instead. */}
<div className="grid min-h-0 w-full flex-1 grid-rows-1 overflow-x-hidden overflow-y-auto px-2">
{ANALYTICS_TABS.map((tab) => (
<TabsPanel key={tab.key} value={tab.key}>
<tab.content />
</TabsPanel>
))}
</div>
</div>
</Tabs>
</div>

View file

@ -14,9 +14,9 @@ import useSWR from "swr";
import { Popover } from "@headlessui/react";
// plane imports
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { Tab, Tabs, TabsList, TabsPanel } from "@makeplane/propel/components/tabs";
import { ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
import { Tabs } from "@plane/propel/tabs";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
@ -204,175 +204,183 @@ function ImagePickerPopoverComponent<TFieldValues extends FieldValues = FieldVal
>
<div
ref={imagePickerRef}
className="flex h-96 w-80 flex-col overflow-auto rounded border border-subtle bg-surface-1 shadow-raised-200 md:h-[36rem] md:w-[36rem]"
className="flex h-96 w-80 flex-col overflow-hidden rounded border border-subtle bg-surface-1 shadow-raised-200 md:h-[36rem] md:w-[36rem]"
>
<Tabs defaultValue={enabledTabs[0]?.key || "images"} className="flex h-full flex-col p-3">
<Tabs.List className="flex rounded bg-layer-3 p-1">
{enabledTabs.map((tab) => (
<Tabs.Trigger key={tab.key} value={tab.key} size="md">
{tab.title}
</Tabs.Trigger>
))}
<Tabs.Indicator />
</Tabs.List>
<div className="vertical-scrollbar mt-3 scrollbar-sm flex-1 overflow-x-hidden overflow-y-auto p-3">
<Tabs.Content value="unsplash" className="h-full w-full space-y-4">
{(unsplashImages || !unsplashError) && (
<>
<div className="flex items-center gap-x-2">
<Controller
control={control}
name={"search" as FieldPath<TFieldValues>}
render={({ field: { value, ref } }) => (
<InputGroup size="2xl">
<Input
size="2xl"
id="search"
name="search"
type="text"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
setSearchParams(formData.search);
}
}}
value={value}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
ref={ref}
placeholder="Search for images"
/>
</InputGroup>
)}
/>
<Button variant="primary" size="xl" onClick={() => setSearchParams(formData.search)}>
Search
</Button>
</div>
{unsplashImages ? (
unsplashImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{unsplashImages.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
>
<img
src={image.urls.small}
alt={image.alt_description}
className="absolute top-0 left-0 h-full w-full cursor-pointer rounded-sm object-cover"
/>
</div>
))}
</div>
) : (
<p className="pt-7 text-center text-11 text-secondary">No images found.</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</>
)}
</Tabs.Content>
<Tabs.Content value="images" className="h-full w-full space-y-4">
<div className="grid grid-cols-4 gap-4">
{Object.values(STATIC_COVER_IMAGES).map((imageUrl, index) => (
<div
key={imageUrl}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => handleStaticImageSelect(imageUrl)}
>
<img
src={imageUrl}
alt={`Cover image ${index + 1}`}
className="absolute top-0 left-0 h-full w-full cursor-pointer rounded-sm object-cover transition-opacity hover:opacity-80"
/>
</div>
))}
{/* Row wrapper: published Tabs omits className, so fill height comes from stretch. */}
<div className="flex h-full min-h-0 w-full p-3">
<Tabs variant="contained" defaultValue={enabledTabs[0]?.key || "images"}>
<div className="flex h-full min-h-0 w-full flex-col">
{/* Published TabsList is inline-flex; stretch it to the previous full-width bar. */}
<div className="w-full min-w-0 [&_[role=tab]]:min-w-0 [&_[role=tab]]:flex-1 [&_[role=tablist]]:flex [&_[role=tablist]]:w-full">
<TabsList>
{enabledTabs.map((tab) => (
<Tab key={tab.key} value={tab.key} label={tab.title} />
))}
</TabsList>
</div>
</Tabs.Content>
<Tabs.Content value="upload" className="h-full w-full">
<div className="flex h-full w-full flex-col gap-y-2">
<div className="flex w-full flex-1 items-center gap-3">
<div
{...getRootProps()}
className={`relative grid h-full w-full cursor-pointer place-items-center rounded-lg p-12 text-center focus:ring-2 focus:ring-accent-strong focus:ring-offset-2 focus:outline-none ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-subtle hover:bg-surface-2"
: ""
}`}
>
<button
type="button"
className="absolute top-0 right-0 z-40 -translate-y-1/2 rounded-sm bg-surface-2 px-2 py-0.5 text-11 font-medium text-secondary"
>
Edit
</button>
{image !== null || (value && value !== "") ? (
{/* Grid wrapper: published TabsPanel omits className, so fill height comes from a one-row grid. */}
<div className="vertical-scrollbar mt-3 scrollbar-sm grid min-h-0 w-full flex-1 grid-rows-1 overflow-x-hidden overflow-y-auto p-3">
<TabsPanel value="unsplash">
<div className="space-y-4">
{(unsplashImages || !unsplashError) && (
<>
<img
src={image ? URL.createObjectURL(image) : getCoverImageDisplayURL(value, "")}
alt="image"
className="h-full w-full rounded-lg object-cover"
/>
<div className="flex items-center gap-x-2">
<Controller
control={control}
name={"search" as FieldPath<TFieldValues>}
render={({ field: { value, ref } }) => (
<InputGroup size="2xl">
<Input
size="2xl"
id="search"
name="search"
type="text"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
setSearchParams(formData.search);
}
}}
value={value}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
ref={ref}
placeholder="Search for images"
/>
</InputGroup>
)}
/>
<Button variant="primary" size="xl" onClick={() => setSearchParams(formData.search)}>
Search
</Button>
</div>
{unsplashImages ? (
unsplashImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{unsplashImages.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
>
<img
src={image.urls.small}
alt={image.alt_description}
className="absolute top-0 left-0 h-full w-full cursor-pointer rounded-sm object-cover"
/>
</div>
))}
</div>
) : (
<p className="pt-7 text-center text-11 text-secondary">No images found.</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</>
) : (
<div>
<span className="mt-2 block text-13 font-medium text-secondary">
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
</span>
)}
</div>
</TabsPanel>
<TabsPanel value="images">
<div className="grid grid-cols-4 gap-4">
{Object.values(STATIC_COVER_IMAGES).map((imageUrl, index) => (
<div
key={imageUrl}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => handleStaticImageSelect(imageUrl)}
>
<img
src={imageUrl}
alt={`Cover image ${index + 1}`}
className="absolute top-0 left-0 h-full w-full cursor-pointer rounded-sm object-cover transition-opacity hover:opacity-80"
/>
</div>
))}
</div>
</TabsPanel>
<TabsPanel value="upload">
<div className="flex h-full w-full flex-col gap-y-2">
<div className="flex w-full flex-1 items-center gap-3">
<div
{...getRootProps()}
className={`relative grid h-full w-full cursor-pointer place-items-center rounded-lg p-12 text-center focus:ring-2 focus:ring-accent-strong focus:ring-offset-2 focus:outline-none ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-subtle hover:bg-surface-2"
: ""
}`}
>
<button
type="button"
className="absolute top-0 right-0 z-40 -translate-y-1/2 rounded-sm bg-surface-2 px-2 py-0.5 text-11 font-medium text-secondary"
>
Edit
</button>
{image !== null || (value && value !== "") ? (
<>
<img
src={image ? URL.createObjectURL(image) : getCoverImageDisplayURL(value, "")}
alt="image"
className="h-full w-full rounded-lg object-cover"
/>
</>
) : (
<div>
<span className="mt-2 block text-13 font-medium text-secondary">
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
</span>
</div>
)}
<input {...getInputProps()} />
</div>
</div>
{fileRejections.length > 0 && (
<p className="text-13 text-danger-primary">
{fileRejections[0].errors[0].code === "file-too-large"
? "The image size cannot exceed 5 MB."
: "Please upload a file in a valid format."}
</p>
)}
<input {...getInputProps()} />
<p className="text-13 text-secondary">File formats supported- .jpeg, .jpg, .png, .webp</p>
<div className="flex h-12 items-start justify-end gap-2">
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
setImage(null);
}}
>
Cancel
</Button>
<Button
variant="primary"
className="w-full"
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading" : "Upload & Save"}
</Button>
</div>
</div>
</div>
{fileRejections.length > 0 && (
<p className="text-13 text-danger-primary">
{fileRejections[0].errors[0].code === "file-too-large"
? "The image size cannot exceed 5 MB."
: "Please upload a file in a valid format."}
</p>
)}
<p className="text-13 text-secondary">File formats supported- .jpeg, .jpg, .png, .webp</p>
<div className="flex h-12 items-start justify-end gap-2">
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
setImage(null);
}}
>
Cancel
</Button>
<Button
variant="primary"
className="w-full"
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading" : "Upload & Save"}
</Button>
</div>
</TabsPanel>
</div>
</Tabs.Content>
</div>
</Tabs>
</div>
</Tabs>
</div>
</div>
</Popover.Panel>
)}

View file

@ -10,7 +10,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRightCircle } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tabs } from "@plane/propel/tabs";
import { Tabs } from "@makeplane/propel/components/tabs";
import { Tooltip } from "@makeplane/propel/components/tooltip";
// hooks
import { useQueryParams } from "@/hooks/use-query-params";
@ -110,10 +110,16 @@ export const PageNavigationPaneRoot = observer(function PageNavigationPaneRoot(p
{ActiveExtension ? (
<ActiveExtension.component page={page} extensionData={ActiveExtension.data} storeType={storeType} />
) : showNavigationTabs ? (
<Tabs value={activeTab} onValueChange={handleTabChange}>
<PageNavigationPaneTabsList />
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} />
</Tabs>
// Row wrapper: Propel's Tabs root omits className, so it takes its full height by
// stretching as a flex item.
<div className="flex h-full w-full">
<Tabs variant="contained" value={activeTab} onValueChange={handleTabChange}>
<div className="flex h-full w-full flex-col">
<PageNavigationPaneTabsList />
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} />
</div>
</Tabs>
</div>
) : null}
</div>
</aside>

View file

@ -4,6 +4,8 @@
* See the LICENSE file for details.
*/
// plane imports
import { TabsPanel } from "@makeplane/propel/components/tabs";
// components
import type { TPageRootHandlers } from "@/components/pages/editor/page-root";
// store
@ -12,7 +14,6 @@ import type { TPageInstance } from "@/store/pages/base-page";
import { PageNavigationPaneAssetsTabPanel } from "./assets";
import { PageNavigationPaneInfoTabPanel } from "./info/root";
import { PageNavigationPaneOutlineTabPanel } from "./outline";
import { Tabs } from "@plane/propel/tabs";
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from ".";
type Props = {
@ -24,14 +25,16 @@ export function PageNavigationPaneTabPanelsRoot(props: Props) {
const { page, versionHistory } = props;
return (
<>
// Grid wrapper: Propel's TabsPanel omits className, so the single mounted panel gets its
// fill height from a one-row grid instead.
<div className="grid min-h-0 w-full flex-1 grid-rows-1 overflow-hidden py-2">
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
<Tabs.Content key={tab.key} value={tab.key} className="flex-1 overflow-hidden py-2">
<TabsPanel key={tab.key} value={tab.key}>
{tab.key === "outline" && <PageNavigationPaneOutlineTabPanel page={page} />}
{tab.key === "info" && <PageNavigationPaneInfoTabPanel page={page} versionHistory={versionHistory} />}
{tab.key === "assets" && <PageNavigationPaneAssetsTabPanel page={page} />}
</Tabs.Content>
</TabsPanel>
))}
</>
</div>
);
}

View file

@ -5,8 +5,8 @@
*/
// plane imports
import { Tab, TabsList } from "@makeplane/propel/components/tabs";
import { useTranslation } from "@plane/i18n";
import { Tabs } from "@plane/propel/tabs";
// plane web components
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/components/pages/navigation-pane/tab-panels";
@ -16,14 +16,11 @@ export function PageNavigationPaneTabsList() {
return (
<div className="mx-3.5">
<Tabs.List>
<TabsList>
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
<Tabs.Trigger key={tab.key} value={tab.key}>
{t(tab.i18n_label)}
</Tabs.Trigger>
<Tab key={tab.key} value={tab.key} label={t(tab.i18n_label)} />
))}
<Tabs.Indicator />
</Tabs.List>
</TabsList>
</div>
);
}