Compare commits

...

7 Commits

Author SHA1 Message Date
Jayash Tripathy b4e7dcda41 chore: removed eslint config 2026-03-06 14:43:47 +05:30
Jayash Tripathy 5017534e5c fix: format 2026-03-06 14:19:25 +05:30
Jayash Tripathy 6e5031196b Merge branch 'preview' of https://github.com/makeplane/plane into refactor/tabs-implementation 2026-03-06 14:18:29 +05:30
Jayash Tripathy bcfefea323 refactor: update Tabs component usage across multiple files to use new structure with TabsList, TabsTrigger, and TabsContent 2025-12-31 17:40:16 +05:30
Jayash Tripathy 3927fbd0c7 Merge branch 'preview' of https://github.com/makeplane/plane into refactor/tabs-implementation 2025-12-31 17:36:22 +05:30
Jayash Tripathy 74320c1062 Merge branch 'preview' of https://github.com/makeplane/plane into refactor/tabs-implementation 2025-12-30 13:53:22 +05:30
Jayash Tripathy 2acba2980b refactor: migrate from Headless UI Tabs to custom Tabs component
- Replaced instances of Headless UI's Tab component with a new custom Tabs component across various components, including analytics modals, image pickers, and navigation panes.
- Updated tab handling logic to align with the new Tabs API, ensuring consistent behavior and styling.
- Removed unused Tab imports and cleaned up related code for improved maintainability.
- This refactor enhances the overall structure and consistency of tab navigation within the application.
2025-12-03 17:37:45 +05:30
18 changed files with 404 additions and 662 deletions
@@ -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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
// components
import { cn } from "@plane/utils";
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
@@ -80,9 +80,9 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
"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 className={"flex h-7 w-fit overflow-x-auto"}>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Trigger
<TabsTrigger
key={tab.key}
value={tab.key}
disabled={tab.isDisabled}
@@ -95,22 +95,22 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
}}
>
{tab.label}
</Tabs.Trigger>
</TabsTrigger>
))}
</Tabs.List>
</TabsList>
<div className="flex-shrink-0">
<AnalyticsFilterActions />
</div>
</div>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Content
<TabsContent
key={tab.key}
value={tab.key}
className={"h-full overflow-hidden overflow-y-auto px-2"}
>
<tab.content />
</Tabs.Content>
</TabsContent>
))}
</div>
</Tabs>
@@ -4,9 +4,8 @@
* See the LICENSE file for details.
*/
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
// plane package imports
import type { ICycle, IModule, IProject } from "@plane/types";
import { Spinner } from "@plane/ui";
@@ -75,13 +74,11 @@ export const WorkItemsModalMainContent = observer(function WorkItemsModalMainCon
);
return (
<Tab.Group as={React.Fragment}>
<div className="flex flex-col gap-14 overflow-y-auto p-6">
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
<CreatedVsResolved />
<CustomizedInsights peekView={!fullScreen} isEpic={isEpic} />
<WorkItemsInsightTable />
</div>
</Tab.Group>
<div className="flex flex-col gap-14 overflow-y-auto p-6">
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
<CreatedVsResolved />
<CustomizedInsights peekView={!fullScreen} isEpic={isEpic} />
<WorkItemsInsightTable />
</div>
);
});
@@ -11,11 +11,9 @@ import { useDropzone } from "react-dropzone";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
import useSWR from "swr";
import { Popover } from "@headlessui/react";
// plane imports
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";
@@ -27,6 +25,8 @@ import { useInstance } from "@/hooks/store/use-instance";
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
// services
import { FileService } from "@/services/file.service";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
import { Popover } from "@plane/propel/popover";
type TTabOption = {
key: string;
@@ -188,31 +188,27 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
useOutsideClickDetector(ref, handleClose);
return (
<Popover className="relative z-19" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Popover.Button className={getButtonStyling("secondary", "sm")} onClick={handleOnClick} disabled={disabled}>
{label}
</Popover.Button>
{isOpen && (
<Popover.Panel
className="absolute right-0 z-20 mt-2 rounded-md border border-subtle bg-surface-1 shadow-raised-200"
static
>
<Popover.Panel className="absolute right-0 z-20 mt-2 rounded-md border border-subtle bg-surface-1 shadow-raised-200">
<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-auto rounded border border-subtle bg-surface-1 p-2 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">
<Tabs>
<TabsList>
{tabOptions.map((tab) => (
<TabsTrigger key={tab.key} value={tab.key}>
{tab.title}
</Tabs.Trigger>
</TabsTrigger>
))}
<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">
</TabsList>
<div className="mt-1 flex-1 overflow-auto">
<TabsContent value="unsplash">
{(unsplashImages || !unsplashError) && (
<>
<div className="flex items-center gap-x-2">
@@ -234,11 +230,11 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
ref={ref}
placeholder="Search for images"
className="w-full text-13"
className="text-sm w-full"
/>
)}
/>
<Button variant="primary" size="xl" onClick={() => setSearchParams(formData.search)}>
<Button variant="primary" onClick={() => setSearchParams(formData.search)} size="xl">
Search
</Button>
</div>
@@ -257,13 +253,13 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
<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"
className="absolute top-0 left-0 h-full w-full cursor-pointer rounded object-cover"
/>
</div>
))}
</div>
) : (
<p className="pt-7 text-center text-11 text-secondary">No images found.</p>
<p className="text-xs text-custom-text-300 pt-7 text-center">No images found.</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4">
@@ -279,8 +275,8 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
)}
</>
)}
</Tabs.Content>
<Tabs.Content value="images" className="h-full w-full space-y-4">
</TabsContent>
<TabsContent value="images">
<div className="grid grid-cols-4 gap-4">
{Object.values(STATIC_COVER_IMAGES).map((imageUrl, index) => (
<div
@@ -296,8 +292,8 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
</div>
))}
</div>
</Tabs.Content>
<Tabs.Content value="upload" className="h-full w-full">
</TabsContent>
<TabsContent 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
@@ -364,7 +360,7 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
</Button>
</div>
</div>
</Tabs.Content>
</TabsContent>
</div>
</Tabs>
</div>
@@ -4,17 +4,15 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import { Fragment, useCallback, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import { CalendarCheck } from "lucide-react";
// headless ui
import { Tab } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
import { Tooltip } from "@plane/propel/tooltip";
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import type { ICycle } from "@plane/types";
@@ -70,18 +68,6 @@ export const ActiveCycleStats = observer(function ActiveCycleStats(props: Active
const assigneesResolvedPath = resolvedTheme === "light" ? lightAssigneeAsset : darkAssigneeAsset;
const labelsResolvedPath = resolvedTheme === "light" ? lightLabelAsset : darkLabelAsset;
const currentValue = (tab: string | null) => {
switch (tab) {
case "Priority-Issues":
return 0;
case "Assignees":
return 1;
case "Labels":
return 2;
default:
return 0;
}
};
const {
issues: { fetchNextActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
@@ -107,269 +93,207 @@ export const ActiveCycleStats = observer(function ActiveCycleStats(props: Active
return cycleId ? (
<div className="col-span-1 flex min-h-[17rem] flex-col gap-4 overflow-hidden rounded-lg border border-subtle bg-surface-1 p-4 lg:col-span-2 xl:col-span-1">
<Tab.Group
as={Fragment}
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
case 0:
return setTab("Priority-Issues");
case 1:
return setTab("Assignees");
case 2:
return setTab("Labels");
<Tabs value={tab ?? "Priority-Issues"} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="Priority-Issues">{t("project_cycles.active_cycle.priority_issue")}</TabsTrigger>
<TabsTrigger value="Assignees">{t("project_cycles.active_cycle.assignees")}</TabsTrigger>
<TabsTrigger value="Labels">{t("project_cycles.active_cycle.labels")}</TabsTrigger>
</TabsList>
default:
return setTab("Priority-Issues");
}
}}
>
<Tab.List
as="div"
className="relative grid rounded-sm border-[0.5px] border-subtle bg-layer-1 p-[1px]"
style={{
gridTemplateColumns: `repeat(3, 1fr)`,
}}
<TabsContent
value="Priority-Issues"
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] rounded-[3px] py-1.5 text-11 font-semibold text-placeholder transition duration-500 focus:outline-none",
{
"bg-surface-1 text-tertiary": selected,
"hover:text-tertiary": !selected,
}
)
}
<div
ref={issuesContainerRef}
className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col gap-1 overflow-y-auto"
>
{t("project_cycles.active_cycle.priority_issue")}
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] rounded-[3px] py-1.5 text-11 font-semibold text-placeholder transition duration-500 focus:outline-none",
{
"bg-surface-1 text-tertiary": selected,
"hover:text-tertiary": !selected,
}
)
}
>
{t("project_cycles.active_cycle.assignees")}
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] rounded-[3px] py-1.5 text-11 font-semibold text-placeholder transition duration-500 focus:outline-none",
{
"bg-surface-1 text-tertiary": selected,
"hover:text-tertiary": !selected,
}
)
}
>
{t("project_cycles.active_cycle.labels")}
</Tab>
</Tab.List>
{cycleIssueDetails && "issueIds" in cycleIssueDetails ? (
cycleIssueDetails.issueCount > 0 ? (
<>
{cycleIssueDetails.issueIds.map((issueId: string) => {
const issue = getIssueById(issueId);
<Tab.Panels as={Fragment}>
<Tab.Panel
as="div"
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
>
<div
ref={issuesContainerRef}
className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col gap-1 overflow-y-auto"
>
{cycleIssueDetails && "issueIds" in cycleIssueDetails ? (
cycleIssueDetails.issueCount > 0 ? (
<>
{cycleIssueDetails.issueIds.map((issueId: string) => {
const issue = getIssueById(issueId);
if (!issue) return null;
if (!issue) return null;
return (
<div
key={issue.id}
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md p-1 hover:bg-surface-2"
onClick={() => {
if (issue.id) {
setPeekIssue({
workspaceSlug,
projectId,
issueId: issue.id,
isArchived: !!issue.archived_at,
});
handleFiltersUpdate([
{ property: "priority", operator: "in", value: ["urgent", "high"] },
]);
}
}}
>
<div className="flex w-full min-w-24 flex-grow items-center gap-1.5 truncate">
<IssueIdentifier issueId={issue.id} projectId={projectId} size="xs" variant="secondary" />
<Tooltip position="top-start" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="truncate text-13 text-primary">{issue.name}</span>
</Tooltip>
</div>
<PriorityIcon priority={issue.priority} withContainer size={12} />
<div className="flex flex-shrink-0 items-center gap-1.5">
<StateDropdown
value={issue.state_id}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="background-with-text"
buttonContainerClassName="cursor-pointer max-w-24"
showTooltip
/>
{issue.target_date && (
<Tooltip
tooltipHeading="Target Date"
tooltipContent={renderFormattedDate(issue.target_date)}
>
<div className="flex h-full cursor-pointer items-center gap-1.5 truncate rounded-sm bg-layer-1 px-2 py-0.5 text-11 group-hover:bg-surface-1">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="truncate text-11">
{renderFormattedDateWithoutYear(issue.target_date)}
</span>
</div>
</Tooltip>
)}
</div>
</div>
);
})}
{(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && (
<div
ref={setIssueLoaderElement}
className={
"relative flex h-11 animate-pulse cursor-pointer items-center gap-3 bg-layer-1 p-3 text-13"
}
/>
)}
</>
) : (
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState
title={t("active_cycle.empty_state.priority_issue.title")}
assetPath={priorityResolvedPath}
/>
</div>
)
) : (
loaders
)}
</div>
</Tab.Panel>
<Tab.Panel
as="div"
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
>
{cycle && !isEmpty(cycle.distribution) ? (
cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
cycle.distribution?.assignees?.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar
name={assignee?.display_name ?? undefined}
src={getFileURL(assignee?.avatar_url ?? "")}
/>
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
<div
key={issue.id}
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md p-1 hover:bg-surface-2"
onClick={() => {
if (assignee.assignee_id) {
handleFiltersUpdate([
{ property: "assignee_id", operator: "in", value: [assignee.assignee_id] },
]);
if (issue.id) {
setPeekIssue({
workspaceSlug,
projectId,
issueId: issue.id,
isArchived: !!issue.archived_at,
});
handleFiltersUpdate([{ property: "priority", operator: "in", value: ["urgent", "high"] }]);
}
}}
/>
>
<div className="flex w-full min-w-24 flex-grow items-center gap-1.5 truncate">
<IssueIdentifier issueId={issue.id} projectId={projectId} size="xs" variant="secondary" />
<Tooltip position="top-start" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="truncate text-13 text-primary">{issue.name}</span>
</Tooltip>
</div>
<PriorityIcon priority={issue.priority} withContainer size={12} />
<div className="flex flex-shrink-0 items-center gap-1.5">
<StateDropdown
value={issue.state_id}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="background-with-text"
buttonContainerClassName="cursor-pointer max-w-24"
showTooltip
/>
{issue.target_date && (
<Tooltip
tooltipHeading="Target Date"
tooltipContent={renderFormattedDate(issue.target_date)}
>
<div className="flex h-full cursor-pointer items-center gap-1.5 truncate rounded-sm bg-layer-1 px-2 py-0.5 text-11 group-hover:bg-surface-1">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="truncate text-11">
{renderFormattedDateWithoutYear(issue.target_date)}
</span>
</div>
</Tooltip>
)}
</div>
</div>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-subtle bg-layer-1">
<img src={userImage} height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>{t("no_assignee")}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})
})}
{(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && (
<div
ref={setIssueLoaderElement}
className={
"relative flex h-11 animate-pulse cursor-pointer items-center gap-3 bg-layer-1 p-3 text-13"
}
/>
)}
</>
) : (
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState
title={t("active_cycle.empty_state.assignee.title")}
assetPath={assigneesResolvedPath}
title={t("active_cycle.empty_state.priority_issue.title")}
assetPath={priorityResolvedPath}
/>
</div>
)
) : (
loaders
)}
</Tab.Panel>
</div>
</TabsContent>
<Tab.Panel
as="div"
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
>
{cycle && !isEmpty(cycle.distribution) ? (
cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
cycle.distribution.labels?.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
title={
<div className="flex items-center gap-2 truncate">
<span
className="block h-3 w-3 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<span className="truncate text-11 text-ellipsis">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
onClick={
label.label_id
? () => {
if (label.label_id) {
handleFiltersUpdate([{ property: "label_id", operator: "in", value: [label.label_id] }]);
}
}
: undefined
}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState title={t("active_cycle.empty_state.label.title")} assetPath={labelsResolvedPath} />
</div>
)
<TabsContent
value="Assignees"
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
>
{cycle && !isEmpty(cycle.distribution) ? (
cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
cycle.distribution?.assignees?.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar
name={assignee?.display_name ?? undefined}
src={getFileURL(assignee?.avatar_url ?? "")}
/>
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
onClick={() => {
if (assignee.assignee_id) {
handleFiltersUpdate([
{ property: "assignee_id", operator: "in", value: [assignee.assignee_id] },
]);
}
}}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-subtle bg-layer-1">
<img src={userImage} height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>{t("no_assignee")}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})
) : (
loaders
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState
title={t("active_cycle.empty_state.assignee.title")}
assetPath={assigneesResolvedPath}
/>
</div>
)
) : (
loaders
)}
</TabsContent>
<TabsContent
value="Labels"
className="vertical-scrollbar flex scrollbar-sm h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
>
{cycle && !isEmpty(cycle.distribution) ? (
cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
cycle.distribution.labels?.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
title={
<div className="flex items-center gap-2 truncate">
<span
className="block h-3 w-3 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<span className="truncate text-11 text-ellipsis">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
onClick={
label.label_id
? () => {
if (label.label_id) {
handleFiltersUpdate([{ property: "label_id", operator: "in", value: [label.label_id] }]);
}
}
: undefined
}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState title={t("active_cycle.empty_state.label.title")} assetPath={labelsResolvedPath} />
</div>
)
) : (
loaders
)}
</TabsContent>
</Tabs>
</div>
) : (
<Loader className="col-span-1 flex min-h-[17rem] flex-col gap-4 overflow-hidden bg-surface-1 lg:col-span-2 xl:col-span-1">
@@ -147,9 +147,7 @@ export const CycleAnalyticsProgress = observer(function CycleAnalyticsProgress(p
cycleId
)}
isEditable={Boolean(!peekCycle) && cycleFilter !== undefined}
noBackground={false}
plotType={plotType}
roundedTab={false}
selectedFilters={{
assignees: selectedAssignees,
labels: selectedLabels,
@@ -5,12 +5,12 @@
*/
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import type { TCycleDistribution, TCycleEstimateDistribution, TCyclePlotType } from "@plane/types";
import { cn, toFilterArray } from "@plane/utils";
import { toFilterArray } from "@plane/utils";
// components
import type { TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
import { AssigneeStatComponent } from "@/components/core/sidebar/progress-stats/assignee";
@@ -30,9 +30,7 @@ type TCycleProgressStats = {
groupedIssues: Record<string, number>;
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
isEditable?: boolean;
noBackground?: boolean;
plotType: TCyclePlotType;
roundedTab?: boolean;
selectedFilters: TSelectedFilterProgressStats;
size?: "xs" | "sm";
totalIssuesCount: number;
@@ -45,9 +43,7 @@ export const CycleProgressStats = observer(function CycleProgressStats(props: TC
groupedIssues,
handleFiltersUpdate,
isEditable = false,
noBackground = false,
plotType,
roundedTab = false,
selectedFilters,
size = "sm",
totalIssuesCount,
@@ -59,8 +55,6 @@ export const CycleProgressStats = observer(function CycleProgressStats(props: TC
`cycle-analytics-tab-${cycleId}`,
"stat-assignees"
);
// derived values
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
const currentDistribution = distribution as TCycleDistribution;
const currentEstimateDistribution = distribution as TCycleEstimateDistribution;
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
@@ -121,34 +115,16 @@ export const CycleProgressStats = observer(function CycleProgressStats(props: TC
return (
<div>
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
<Tab.List
as="div"
className={cn(
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
roundedTab ? `rounded-3xl` : `rounded-md`,
noBackground ? `` : `bg-layer-2`,
size === "xs" ? `text-11` : `text-13`
)}
>
<Tabs defaultValue={currentTab ?? "stat-assignees"} onValueChange={(value) => setCycleTab(value)}>
<TabsList>
{PROGRESS_STATS.map((stat) => (
<Tab
className={cn(
`w-full cursor-pointer p-1 text-primary transition-all outline-none focus:outline-none`,
roundedTab ? `rounded-3xl border border-subtle` : `rounded-sm`,
stat.key === currentTab
? "bg-layer-transparent-active text-secondary"
: "text-placeholder hover:text-secondary"
)}
key={stat.key}
onClick={() => setCycleTab(stat.key)}
>
<TabsTrigger key={stat.key} value={stat.key}>
{t(stat.i18n_title)}
</Tab>
</TabsTrigger>
))}
</Tab.List>
<Tab.Panels className="py-3 text-secondary">
<Tab.Panel key={"stat-states"}>
</TabsList>
<div className="py-3">
<TabsContent value="stat-states">
<StateGroupStatComponent
distribution={distributionStateData}
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
@@ -156,25 +132,25 @@ export const CycleProgressStats = observer(function CycleProgressStats(props: TC
selectedStateGroups={selectedStateGroups}
totalIssuesCount={totalIssuesCount}
/>
</Tab.Panel>
<Tab.Panel key={"stat-assignees"}>
</TabsContent>
<TabsContent value="stat-assignees">
<AssigneeStatComponent
distribution={distributionAssigneeData}
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
isEditable={isEditable}
selectedAssigneeIds={selectedAssigneeIds}
/>
</Tab.Panel>
<Tab.Panel key={"stat-labels"}>
</TabsContent>
<TabsContent value="stat-labels">
<LabelStatComponent
distribution={distributionLabelData}
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
isEditable={isEditable}
selectedLabelIds={selectedLabelIds}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</TabsContent>
</div>
</Tabs>
</div>
);
});
@@ -7,8 +7,8 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { CheckCircle } from "lucide-react";
import { Tab } from "@headlessui/react";
// plane imports
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
// helpers
import type { EProductSubscriptionEnum, TBillingFrequency, TSubscriptionPrice } from "@plane/types";
import { cn, getBaseSubscriptionName, getSubscriptionName } from "@plane/utils";
@@ -41,31 +41,20 @@ export const BasePaidPlanCard = observer(function BasePaidPlanCard(props: TBaseP
const planeName = getSubscriptionName(planVariant);
return (
<div className="flex flex-col rounded-xl border border-subtle bg-layer-2 px-3 py-6">
<Tab.Group selectedIndex={selectedPlan === "month" ? 0 : 1}>
<div className="flex h-9 w-full justify-center">
<Tab.List className="flex w-60 space-x-1 rounded-md bg-layer-3 p-0.5">
<div className="flex flex-col rounded-xl bg-layer-1 px-3 py-6">
<Tabs value={selectedPlan} onValueChange={(value) => setSelectedPlan(value as TBillingFrequency)}>
<div className="flex w-full justify-center">
<TabsList>
{prices.map((price: TSubscriptionPrice) => (
<Tab
key={price.key}
className={({ selected }) =>
cn(
"w-full rounded-sm py-1 text-caption-md-medium leading-5",
selected
? "border border-subtle-1 bg-layer-2 text-primary shadow-raised-100"
: "text-tertiary hover:text-secondary"
)
}
onClick={() => setSelectedPlan(price.recurring)}
>
<TabsTrigger key={price.key} value={price.recurring}>
{renderPriceContent(price)}
</Tab>
</TabsTrigger>
))}
</Tab.List>
</TabsList>
</div>
<Tab.Panels>
<div>
{prices.map((price: TSubscriptionPrice) => (
<Tab.Panel key={price.key}>
<TabsContent key={price.key} value={price.recurring}>
<div className="pt-6 text-center">
<div className="text-h4-medium">Plane {planeName}</div>
{renderActionButton(price)}
@@ -89,10 +78,10 @@ export const BasePaidPlanCard = observer(function BasePaidPlanCard(props: TBaseP
</ul>
{extraFeatures && <div>{extraFeatures}</div>}
</div>
</Tab.Panel>
</TabsContent>
))}
</Tab.Panels>
</Tab.Group>
</div>
</Tabs>
</div>
);
});
@@ -214,15 +214,12 @@ export const ModuleAnalyticsProgress = observer(function ModuleAnalyticsProgress
)}
isEditable={Boolean(!peekModule) && moduleFilter !== undefined}
moduleId={moduleId}
noBackground={false}
plotType={plotType}
roundedTab={false}
selectedFilters={{
assignees: selectedAssignees,
labels: selectedLabels,
stateGroups: selectedStateGroups,
}}
size="xs"
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
/>
</div>
@@ -5,11 +5,11 @@
*/
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import type { TModuleDistribution, TModuleEstimateDistribution, TModulePlotType } from "@plane/types";
import { cn, toFilterArray } from "@plane/utils";
import { toFilterArray } from "@plane/utils";
// components
import type { TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
import { AssigneeStatComponent } from "@/components/core/sidebar/progress-stats/assignee";
@@ -28,11 +28,8 @@ type TModuleProgressStats = {
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
isEditable?: boolean;
moduleId: string;
noBackground?: boolean;
plotType: TModulePlotType;
roundedTab?: boolean;
selectedFilters: TSelectedFilterProgressStats;
size?: "xs" | "sm";
totalIssuesCount: number;
};
@@ -43,11 +40,8 @@ export const ModuleProgressStats = observer(function ModuleProgressStats(props:
handleFiltersUpdate,
isEditable = false,
moduleId,
noBackground = false,
plotType,
roundedTab = false,
selectedFilters,
size = "sm",
totalIssuesCount,
} = props;
// plane imports
@@ -57,8 +51,6 @@ export const ModuleProgressStats = observer(function ModuleProgressStats(props:
`module-analytics-tab-${moduleId}`,
"stat-assignees"
);
// derived values
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
const currentDistribution = distribution as TModuleDistribution;
const currentEstimateDistribution = distribution as TModuleEstimateDistribution;
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
@@ -119,60 +111,40 @@ export const ModuleProgressStats = observer(function ModuleProgressStats(props:
return (
<div>
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
<Tab.List
as="div"
className={cn(
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
roundedTab ? `rounded-3xl` : `rounded-md`,
noBackground ? `` : `bg-layer-2`,
size === "xs" ? `text-11` : `text-13`
)}
>
<Tabs defaultValue={currentTab ?? "stat-assignees"} onValueChange={(value) => setModuleTab(value)}>
<TabsList>
{PROGRESS_STATS.map((stat) => (
<Tab
className={cn(
`w-full cursor-pointer p-1 text-primary transition-all outline-none focus:outline-none`,
roundedTab ? `rounded-3xl border border-subtle` : `rounded-sm`,
stat.key === currentTab
? "bg-layer-transparent-active text-secondary"
: "text-placeholder hover:text-secondary"
)}
key={stat.key}
onClick={() => setModuleTab(stat.key)}
>
<TabsTrigger key={stat.key} value={stat.key}>
{t(stat.i18n_title)}
</Tab>
</TabsTrigger>
))}
</Tab.List>
<Tab.Panels className="py-3 text-secondary">
<Tab.Panel key={"stat-assignees"}>
<AssigneeStatComponent
distribution={distributionAssigneeData}
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
isEditable={isEditable}
selectedAssigneeIds={selectedAssigneeIds}
/>
</Tab.Panel>
<Tab.Panel key={"stat-labels"}>
<LabelStatComponent
distribution={distributionLabelData}
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
isEditable={isEditable}
selectedLabelIds={selectedLabelIds}
/>
</Tab.Panel>
<Tab.Panel key={"stat-states"}>
<StateGroupStatComponent
distribution={distributionStateData}
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
isEditable={isEditable}
selectedStateGroups={selectedStateGroups}
totalIssuesCount={totalIssuesCount}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</TabsList>
<TabsContent value="stat-assignees">
<AssigneeStatComponent
distribution={distributionAssigneeData}
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
isEditable={isEditable}
selectedAssigneeIds={selectedAssigneeIds}
/>
</TabsContent>
<TabsContent value="stat-labels">
<LabelStatComponent
distribution={distributionLabelData}
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
isEditable={isEditable}
selectedLabelIds={selectedLabelIds}
/>
</TabsContent>
<TabsContent value="stat-states">
<StateGroupStatComponent
distribution={distributionStateData}
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
isEditable={isEditable}
selectedStateGroups={selectedStateGroups}
totalIssuesCount={totalIssuesCount}
/>
</TabsContent>
</Tabs>
</div>
);
});
@@ -4,13 +4,13 @@
* See the LICENSE file for details.
*/
import React, { useCallback } from "react";
import { ArrowRightCircle } from "lucide-react";
import { observer } from "mobx-react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRightCircle } from "lucide-react";
import { Tab } from "@headlessui/react";
import { useCallback } from "react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tabs } from "@plane/propel/tabs";
import { Tooltip } from "@plane/propel/tooltip";
// hooks
import { useQueryParams } from "@/hooks/use-query-params";
@@ -26,7 +26,6 @@ import { PageNavigationPaneTabsList } from "./tabs-list";
import type { INavigationPaneExtension } from "./types/extensions";
import {
PAGE_NAVIGATION_PANE_TAB_KEYS,
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM,
PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM,
PAGE_NAVIGATION_PANE_WIDTH,
@@ -55,7 +54,6 @@ export const PageNavigationPaneRoot = observer(function PageNavigationPaneRoot(p
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM
) as TPageNavigationPaneTab | null;
const activeTab: TPageNavigationPaneTab = navigationPaneQueryParam || "outline";
const selectedIndex = PAGE_NAVIGATION_PANE_TAB_KEYS.indexOf(activeTab);
// Check if any extension is currently active based on query parameters
const ActiveExtension = extensions.find((extension) => {
@@ -75,8 +73,8 @@ export const PageNavigationPaneRoot = observer(function PageNavigationPaneRoot(p
const { t } = useTranslation();
const handleTabChange = useCallback(
(index: number) => {
const updatedTab = PAGE_NAVIGATION_PANE_TAB_KEYS[index];
(value: string) => {
const updatedTab = value as TPageNavigationPaneTab;
const isUpdatedTabInfo = updatedTab === "info";
const updatedRoute = updateQueryParams({
paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: updatedTab },
@@ -108,14 +106,14 @@ export const PageNavigationPaneRoot = observer(function PageNavigationPaneRoot(p
</Tooltip>
</div>
<div className="animate-slide-in-right flex flex-1 flex-col overflow-hidden">
<div className="animate-slide-in-right flex flex-1 flex-col overflow-hidden px-3">
{ActiveExtension ? (
<ActiveExtension.component page={page} extensionData={ActiveExtension.data} storeType={storeType} />
) : showNavigationTabs ? (
<Tab.Group as={React.Fragment} selectedIndex={selectedIndex} onChange={handleTabChange}>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<PageNavigationPaneTabsList />
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} />
</Tab.Group>
</Tabs>
) : null}
</div>
</aside>
@@ -4,6 +4,8 @@
* See the LICENSE file for details.
*/
// plane imports
import { TabsContent } from "@plane/propel/tabs";
// components
import type { TPageRootHandlers } from "@/components/pages/editor/page-root";
// plane web imports
@@ -15,7 +17,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";
type Props = {
page: TPageInstance;
@@ -28,12 +29,16 @@ export function PageNavigationPaneTabPanelsRoot(props: Props) {
return (
<>
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
<Tabs.Content key={tab.key} value={tab.key} className="flex-1 overflow-hidden py-2">
<TabsContent
key={tab.key}
value={tab.key}
className="vertical-scrollbar scrollbar-sm size-full overflow-y-auto outline-none"
>
{tab.key === "outline" && <PageNavigationPaneOutlineTabPanel page={page} />}
{tab.key === "info" && <PageNavigationPaneInfoTabPanel page={page} versionHistory={versionHistory} />}
{tab.key === "assets" && <PageNavigationPaneAssetsTabPanel page={page} />}
<PageNavigationPaneAdditionalTabPanelsRoot activeTab={tab.key} page={page} />
</Tabs.Content>
</TabsContent>
))}
</>
);
@@ -4,9 +4,9 @@
* See the LICENSE file for details.
*/
import { Tab } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TabsList, TabsTrigger } from "@plane/propel/tabs";
// plane web components
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane";
@@ -15,29 +15,12 @@ export function PageNavigationPaneTabsList() {
const { t } = useTranslation();
return (
<Tab.List className="relative mx-3.5 flex items-center rounded-md bg-layer-3 p-0.5">
{({ selectedIndex }) => (
<>
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
<Tab
key={tab.key}
type="button"
className="relative z-[1] flex-1 py-1.5 text-13 font-semibold outline-none"
>
{t(tab.i18n_label)}
</Tab>
))}
{/* active tab indicator */}
<div
className="pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-sm bg-layer-3-selected transition-all duration-500 ease-in-out"
style={{
left: `calc(${(selectedIndex / ORDERED_PAGE_NAVIGATION_TABS_LIST.length) * 100}% + 2px)`,
height: "calc(100% - 4px)",
width: `calc(${100 / ORDERED_PAGE_NAVIGATION_TABS_LIST.length}% - 4px)`,
}}
/>
</>
)}
</Tab.List>
<TabsList>
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
<TabsTrigger key={tab.key} value={tab.key}>
{t(tab.i18n_label)}
</TabsTrigger>
))}
</TabsList>
);
}
+2 -2
View File
@@ -17,9 +17,9 @@ export const buttonVariants = cva(
"error-fill":
"bg-danger-primary text-on-color hover:bg-danger-primary-hover active:bg-danger-primary-active disabled:bg-layer-disabled disabled:text-disabled",
"error-outline":
"bg-layer-2 hover:bg-danger-subtle active:bg-danger-subtle-hover disabled:bg-layer-2 text-danger-secondary disabled:text-disabled border border-danger-strong disabled:border-subtle-1",
"border border-danger-strong bg-layer-2 text-danger-secondary hover:bg-danger-subtle active:bg-danger-subtle-hover disabled:border-subtle-1 disabled:bg-layer-2 disabled:text-disabled",
secondary:
"bg-layer-2 hover:bg-layer-2-hover active:bg-layer-2-active disabled:bg-layer-transparent text-secondary disabled:text-disabled border border-strong disabled:border-subtle-1 shadow-raised-100",
"border border-strong bg-layer-2 text-secondary shadow-raised-100 hover:bg-layer-2-hover active:bg-layer-2-active disabled:border-subtle-1 disabled:bg-layer-transparent disabled:text-disabled",
tertiary:
"bg-layer-3 text-secondary hover:bg-layer-3-hover active:bg-layer-3-active disabled:bg-layer-transparent disabled:text-disabled",
ghost:
+98
View File
@@ -1,3 +1,54 @@
/**
* Tabs Component
*
* A flexible tab navigation component built on top of Base UI's Tabs primitive.
*
* @example
* ```tsx
* import { Tabs } from "@plane/propel/tabs";
*
* // Basic usage
* <Tabs defaultValue="tab1">
* <Tabs.List>
* <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
* <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
* <Tabs.Trigger value="tab3">Tab 3</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="tab1">Content for Tab 1</Tabs.Content>
* <Tabs.Content value="tab2">Content for Tab 2</Tabs.Content>
* <Tabs.Content value="tab3">Content for Tab 3</Tabs.Content>
* </Tabs>
*
* // With variant and size options
* <Tabs defaultValue="overview" variant="contained">
* <Tabs.List background="contained">
* <Tabs.Trigger value="overview" size="sm">Overview</Tabs.Trigger>
* <Tabs.Trigger value="settings" size="sm">Settings</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="overview">Overview content</Tabs.Content>
* <Tabs.Content value="settings">Settings content</Tabs.Content>
* </Tabs>
*
* // Controlled usage
* const [activeTab, setActiveTab] = useState("tab1");
*
* <Tabs value={activeTab} onValueChange={setActiveTab}>
* <Tabs.List>
* <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
* <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="tab1">Content 1</Tabs.Content>
* <Tabs.Content value="tab2">Content 2</Tabs.Content>
* </Tabs>
* ```
*
* @props
* - `Tabs` (Root): Accepts all props from Base UI's Tabs.Root plus `variant`
* - `Tabs.List`: Container for triggers. `background?: "contained"` adds bg color
* - `Tabs.Trigger`: Tab button. `size?: "sm" | "md" | "lg"` controls text size
* - `Tabs.Content`: Tab panel content
* - `Tabs.Indicator`: Optional animated indicator element
*/
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
@@ -138,6 +189,53 @@ const TabsIndicator = React.forwardRef(function TabsIndicator(
);
});
/**
* A compound Tabs component built on Base UI primitives.
*
* @example
* ```tsx
* import { Tabs } from "@plane/propel";
*
* // Basic usage
* <Tabs defaultValue="tab1">
* <Tabs.List>
* <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
* <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
* <Tabs.Trigger value="tab3">Tab 3</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="tab1">Content for Tab 1</Tabs.Content>
* <Tabs.Content value="tab2">Content for Tab 2</Tabs.Content>
* <Tabs.Content value="tab3">Content for Tab 3</Tabs.Content>
* </Tabs>
*
* // With size variants (sm | md | lg)
* <Tabs defaultValue="tab1">
* <Tabs.List>
* <Tabs.Trigger value="tab1" size="sm">Small</Tabs.Trigger>
* <Tabs.Trigger value="tab2" size="md">Medium</Tabs.Trigger>
* <Tabs.Trigger value="tab3" size="lg">Large</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="tab1">Small tab content</Tabs.Content>
* <Tabs.Content value="tab2">Medium tab content</Tabs.Content>
* <Tabs.Content value="tab3">Large tab content</Tabs.Content>
* </Tabs>
*
* // With contained background variant
* <Tabs defaultValue="tab1" variant="contained">
* <Tabs.List background="contained">
* <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
* <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
* </Tabs.List>
* <Tabs.Content value="tab1">Content 1</Tabs.Content>
* <Tabs.Content value="tab2">Content 2</Tabs.Content>
* </Tabs>
* ```
*
* @property {Tabs.List} List - Container for tab triggers with optional background variant
* @property {Tabs.Trigger} Trigger - Individual tab button with size variants (sm, md, lg)
* @property {Tabs.Content} Content - Panel content associated with each tab
* @property {Tabs.Indicator} Indicator - Animated indicator for active tab selection
*/
export const Tabs = Object.assign(TabsRoot, {
List: TabsList,
Trigger: TabsTrigger,
-1
View File
@@ -31,7 +31,6 @@ export * from "./scroll-area";
export * from "./sortable";
export * from "./spinners";
export * from "./tables";
export * from "./tabs";
export * from "./tag";
export * from "./tooltip";
export * from "./typography";
-8
View File
@@ -1,8 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./tabs";
export * from "./tab-list";
-86
View File
@@ -1,86 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Tab } from "@headlessui/react";
import type { LucideProps } from "lucide-react";
import type { FC } from "react";
import React from "react";
// helpers
import { cn } from "../utils";
export type TabListItem = {
key: string;
icon?: FC<LucideProps>;
label?: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
};
type TTabListProps = {
tabs: TabListItem[];
tabListClassName?: string;
tabClassName?: string;
size?: "sm" | "md" | "lg";
selectedTab?: string;
autoWrap?: boolean;
onTabChange?: (key: string) => void;
};
export function TabList({ autoWrap = true, ...props }: TTabListProps) {
return autoWrap ? (
<Tab.Group>
<TabListInner {...props} />
</Tab.Group>
) : (
<TabListInner {...props} />
);
}
function TabListInner({ tabs, tabListClassName, tabClassName, size = "md", selectedTab, onTabChange }: TTabListProps) {
return (
<Tab.List
as="div"
className={cn(
"flex w-full min-w-fit items-center justify-between gap-1.5 rounded-md bg-layer-1 p-0.5 text-13",
tabListClassName
)}
>
{tabs.map((tab) => (
<Tab
className={({ selected }) =>
cn(
"flex w-full min-w-fit cursor-pointer items-center justify-center rounded-sm p-1 font-medium text-primary transition-all outline-none focus:outline-none",
(selectedTab ? selectedTab === tab.key : selected)
? "shadow-sm bg-layer-transparent-active text-primary"
: tab.disabled
? "cursor-not-allowed text-placeholder"
: "text-placeholder hover:bg-layer-transparent-hover hover:text-tertiary",
{
"text-11": size === "sm",
"text-13": size === "md",
"text-14": size === "lg",
},
tabClassName
)
}
key={tab.key}
onClick={() => {
if (!tab.disabled) {
onTabChange?.(tab.key);
tab.onClick?.();
}
}}
disabled={tab.disabled}
>
{tab.icon && (
<tab.icon className={cn({ "size-3": size === "sm", "size-4": size === "md", "size-5": size === "lg" })} />
)}
{tab.label}
</Tab>
))}
</Tab.List>
);
}
-96
View File
@@ -1,96 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Tab } from "@headlessui/react";
import type { FC } from "react";
import React, { Fragment, useEffect, useState } from "react";
// helpers
import { useLocalStorage } from "@plane/hooks";
import { cn } from "../utils";
// types
import type { TabListItem } from "./tab-list";
import { TabList } from "./tab-list";
export type TabContent = {
content: React.ReactNode;
};
export type TabItem = TabListItem & TabContent;
type TTabsProps = {
tabs: TabItem[];
storageKey?: string;
actions?: React.ReactNode;
defaultTab?: string;
containerClassName?: string;
tabListContainerClassName?: string;
tabListClassName?: string;
tabClassName?: string;
tabPanelClassName?: string;
size?: "sm" | "md" | "lg";
storeInLocalStorage?: boolean;
};
export function Tabs(props: TTabsProps) {
const {
tabs,
storageKey,
actions,
defaultTab = tabs[0]?.key,
containerClassName = "",
tabListContainerClassName = "",
tabListClassName = "",
tabClassName = "",
tabPanelClassName = "",
size = "md",
storeInLocalStorage = true,
} = props;
// local storage
const { storedValue, setValue } = useLocalStorage(
storeInLocalStorage && storageKey ? `tab-${storageKey}` : `tab-${tabs[0]?.key}`,
defaultTab
);
// state
const [selectedTab, setSelectedTab] = useState(storedValue ?? defaultTab);
useEffect(() => {
if (storeInLocalStorage) {
setValue(selectedTab);
}
}, [selectedTab, setValue, storeInLocalStorage, storageKey]);
const currentTabIndex = (tabKey: string): number => tabs.findIndex((tab) => tab.key === tabKey);
const handleTabChange = (key: string) => {
setSelectedTab(key);
};
return (
<div className="flex h-full w-full flex-col">
<Tab.Group defaultIndex={currentTabIndex(selectedTab)}>
<div className={cn("flex h-full w-full flex-col gap-2", containerClassName)}>
<div className={cn("flex w-full items-center gap-4", tabListContainerClassName)}>
<TabList
tabs={tabs}
tabListClassName={tabListClassName}
tabClassName={tabClassName}
size={size}
onTabChange={handleTabChange}
/>
{actions && <div className="flex-grow">{actions}</div>}
</div>
<Tab.Panels as={Fragment}>
{tabs.map((tab) => (
<Tab.Panel key={tab.key} as="div" className={cn("relative outline-none", tabPanelClassName)}>
{tab.content}
</Tab.Panel>
))}
</Tab.Panels>
</div>
</Tab.Group>
</div>
);
}