);
diff --git a/apps/web/core/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx b/apps/web/core/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx
index cdca18b8..c38a06b6 100644
--- a/apps/web/core/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx
+++ b/apps/web/core/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx
@@ -6,7 +6,7 @@
import React from "react";
// ui
-import { Loader } from "@pace/ui";
+import { Skeleton } from "@pace/propel/skeleton";
import { InboxSidebarLoader } from "./inbox-sidebar-loader";
export function InboxLayoutLoader() {
@@ -16,15 +16,15 @@ export function InboxLayoutLoader() {
-
+
-
-
-
-
+
+
+
+
-
-
+
+
);
diff --git a/apps/web/core/components/workspace-notifications/sidebar/header/options/root.tsx b/apps/web/core/components/workspace-notifications/sidebar/header/options/root.tsx
index 74692f35..4afcf2ec 100644
--- a/apps/web/core/components/workspace-notifications/sidebar/header/options/root.tsx
+++ b/apps/web/core/components/workspace-notifications/sidebar/header/options/root.tsx
@@ -10,7 +10,7 @@ import { CheckDoneOutline, RefreshOutline } from "@makeplane/propel/icons";
import { ENotificationLoader, ENotificationQueryParamType } from "@pace/constants";
import { useTranslation } from "@pace/i18n";
import { Tooltip } from "@makeplane/propel/components/tooltip";
-import { Spinner } from "@pace/ui";
+import { Spinner } from "@pace/propel/spinners";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { usePlatformOS } from "@/hooks/use-platform-os";
diff --git a/apps/web/core/components/workspace/sidebar/projects-list.tsx b/apps/web/core/components/workspace/sidebar/projects-list.tsx
index 5680a2eb..8dac3486 100644
--- a/apps/web/core/components/workspace/sidebar/projects-list.tsx
+++ b/apps/web/core/components/workspace/sidebar/projects-list.tsx
@@ -18,7 +18,7 @@ import { useTranslation } from "@pace/i18n";
import { IconButton } from "@pace/propel/icon-button";
import { TOAST_TYPE, setToast } from "@pace/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
-import { Loader } from "@pace/ui";
+import { Skeleton } from "@pace/propel/skeleton";
import { copyUrlToClipboard, cn, orderJoinedProjects } from "@pace/utils";
// components
import { CreateProjectModal } from "@/components/project/create-project-modal";
@@ -228,11 +228,11 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
leaveTo="transform scale-95 opacity-0"
>
{loader === "init-loader" && (
-
+
{Array.from({ length: 4 }).map((_, index) => (
-
+
))}
-
+
)}
{isAllProjectsListOpen && (
diff --git a/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx
index d3214d6b..ea9c67e9 100644
--- a/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx
+++ b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx
@@ -15,7 +15,7 @@ import { Menu, Transition } from "@headlessui/react";
import { useTranslation } from "@pace/i18n";
import { TOAST_TYPE, setToast } from "@pace/propel/toast";
import type { IWorkspace } from "@pace/types";
-import { Loader } from "@pace/ui";
+import { Skeleton } from "@pace/propel/skeleton";
import { orderWorkspacesList, cn } from "@pace/utils";
// helpers
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
@@ -178,10 +178,10 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
) : (
-
-
-
-
+
+
+
+
)}
diff --git a/apps/web/package.json b/apps/web/package.json
index cd55c38a..22923ab4 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "web",
- "version": "1.4.39",
+ "version": "1.4.40",
"private": true,
"license": "AGPL-3.0",
"type": "module",
diff --git a/package.json b/package.json
index 6291a891..b95c88b7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pace",
- "version": "1.4.39",
+ "version": "1.4.40",
"private": true,
"description": "Project management that keeps pace with the work",
"license": "AGPL-3.0",
diff --git a/packages/codemods/package.json b/packages/codemods/package.json
index e20d849a..4cae1104 100644
--- a/packages/codemods/package.json
+++ b/packages/codemods/package.json
@@ -1,6 +1,6 @@
{
"name": "@pace/codemods",
- "version": "1.4.39",
+ "version": "1.4.40",
"private": true,
"scripts": {
"check:format": "oxfmt --check .",
diff --git a/packages/codemods/tests/ui-loader-to-propel-skeleton.spec.ts b/packages/codemods/tests/ui-loader-to-propel-skeleton.spec.ts
new file mode 100644
index 00000000..619e8985
--- /dev/null
+++ b/packages/codemods/tests/ui-loader-to-propel-skeleton.spec.ts
@@ -0,0 +1,183 @@
+/**
+ * Copyright (c) 2023-present Plane Software, Inc. and contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * See the LICENSE file for details.
+ */
+
+import { describe, it, expect } from "vitest";
+import { applyTransform } from "@hypermod/utils";
+import * as transformer from "../ui-loader-to-propel-skeleton";
+
+const apply = (source: string) =>
+ applyTransform(transformer, source, { parser: "tsx" });
+
+describe("ui-loader-to-propel-skeleton", () => {
+ it("moves a sole-specifier import and renames the element and its .Item", async () => {
+ const result = await apply(`
+ import { Loader } from "@pace/ui";
+
+ export const Placeholder = () => (
+
+
+
+ );
+ `);
+
+ expect(result).toContain(
+ `import { Skeleton } from "@pace/propel/skeleton";`
+ );
+ expect(result).not.toContain("@pace/ui");
+ expect(result).toContain(``);
+ expect(result).toContain(``);
+ expect(result).toContain("");
+ });
+
+ it("splits Loader out and leaves the other @pace/ui specifiers in place", async () => {
+ const result = await apply(`
+ import { Loader, CustomMenu, Row } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toContain(
+ `import { Skeleton } from "@pace/propel/skeleton";`
+ );
+ expect(result).toContain(`import { CustomMenu, Row } from "@pace/ui";`);
+ });
+
+ it("removes a trailing Loader specifier without leaving a dangling comma", async () => {
+ const result = await apply(`
+ import { CustomMenu, Loader } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toContain(`import { CustomMenu } from "@pace/ui";`);
+ expect(result).not.toMatch(/,\s*\}/);
+ });
+
+ it("keeps the leading comment above both statements", async () => {
+ const result = await apply(`
+ // ui
+ import { Loader, Row } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toMatch(
+ /\/\/ ui\n\s*import \{ Skeleton \} from "@pace\/propel\/skeleton";\n\s*import \{ Row \} from "@pace\/ui";/
+ );
+ });
+
+ it("handles a multi-line specifier list", async () => {
+ const result = await apply(`
+ import {
+ CustomMenu,
+ Loader,
+ Row,
+ } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toContain("CustomMenu,");
+ expect(result).toContain("Row,");
+ expect(result).not.toMatch(/\bLoader\b/);
+ });
+
+ it("leaves a same-named local component alone when nothing comes from @pace/ui", async () => {
+ const result = await apply(`
+ import { Row } from "@pace/ui";
+
+ function Loader() {
+ return ;
+ }
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).not.toContain("@pace/propel/skeleton");
+ expect(result).toContain("function Loader()");
+ expect(result).toContain("");
+ });
+
+ it("leaves a file that imports no UI library untouched", async () => {
+ const result = await apply(`
+ import { Loader } from "@/components/loader";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).not.toContain("@pace/propel/skeleton");
+ expect(result).toContain(`import { Loader } from "@/components/loader";`);
+ });
+
+ it("does not rename a reference shadowed inside a function", async () => {
+ const result = await apply(`
+ import { Loader } from "@pace/ui";
+
+ export function render() {
+ const Loader = () => ;
+ return ;
+ }
+ `);
+
+ expect(result).toContain(
+ `import { Skeleton } from "@pace/propel/skeleton";`
+ );
+ expect(result).toContain("const Loader = () => ;");
+ expect(result).toContain("return ;");
+ });
+
+ it("keeps an aliased local name and renames nothing", async () => {
+ const result = await apply(`
+ import { Loader as UiLoader } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toContain(
+ `import { Skeleton as UiLoader } from "@pace/propel/skeleton";`
+ );
+ expect(result).toContain("");
+ });
+
+ it("preserves the type-only modifier", async () => {
+ const result = await apply(`
+ import type { Loader } from "@pace/ui";
+
+ export type Props = { loader: typeof Loader };
+ `);
+
+ expect(result).toContain(
+ `import type { Skeleton } from "@pace/propel/skeleton";`
+ );
+ expect(result).toContain("typeof Skeleton");
+ });
+
+ it("reprints nothing but the lines it edits, JSX comments included", async () => {
+ // This is the shape recast mangled on an earlier sweep: an oxlint directive inside the JSX, a
+ // self-closing element next to a text node, and blank lines between siblings.
+ const source = `import { Loader } from "@pace/ui";
+
+export const Placeholder = () => (
+
+ {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions */}
+
+ label
+
+
+
+
+);
+`;
+
+ // applyTransform trims what it returns, hence the trim on the expectation rather than on the input.
+ expect(await apply(source)).toBe(
+ source
+ .replaceAll("Loader", "Skeleton")
+ .replace(`from "@pace/ui"`, `from "@pace/propel/skeleton"`)
+ .trim()
+ );
+ });
+});
diff --git a/packages/codemods/ui-loader-to-propel-skeleton.ts b/packages/codemods/ui-loader-to-propel-skeleton.ts
new file mode 100644
index 00000000..ccc903fa
--- /dev/null
+++ b/packages/codemods/ui-loader-to-propel-skeleton.ts
@@ -0,0 +1,152 @@
+/**
+ * Copyright (c) 2023-present Plane Software, Inc. and contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * See the LICENSE file for details.
+ */
+
+import type {
+ API,
+ ASTPath,
+ FileInfo,
+ ImportDeclaration,
+ ImportSpecifier,
+} from "jscodeshift";
+
+/**
+ * `Loader` from `@pace/ui` -> `Skeleton` from `@pace/propel/skeleton`.
+ *
+ * The two are the same component under two names: both render `animate-pulse` + `role="status"` around their children and expose an `.Item` taking `height`/`width`/`className` with an `"auto"` default, and the class expressions are identical. Skeleton additionally sets `data-slot` and an `aria-label`, which is additive — no selector in the repo targets the old markup (`plane-ui-loader` appears only on the deleted component's own `displayName`).
+ *
+ * Everything keys off the import source, never off the name. `Loader` is a name several app files define for themselves, and one of them (`packages/editor/src/components/editors/document/loader.tsx`) both defines a `DocumentContentLoader` and imports the @pace/ui `Loader`. A transform that matched `` in JSX would rewrite first-party components in the files that never imported anything. The headlessui-v2-default-tags transform makes the same guard for the same reason.
+ *
+ * The AST is used only to locate character offsets; the replacement is spliced into the original text and the source string is returned instead of `root.toSource()`. Reprinting a mutated AST through recast is not byte-preserving — on a 531-file sweep in this repo it rewrote unrelated JSX in 7 files, because recast reprints a whole subtree when it cannot reconcile comment attachment and several files carry `// oxlint-disable-next-line` comments inside their JSX. Splicing keeps every hunk in the 80 files to the import lines and the bare identifier renames.
+ *
+ * Deliberately not done:
+ *
+ * - No merging into an existing `@pace/propel/skeleton` import, and no reordering. A file keeping other @pace/ui symbols gains a second import statement above the one it had; that is what the icons sweep does too, and both oxlint and oxfmt accept it.
+ * - An aliased import (`Loader as X`) keeps its local name — the new import becomes `Skeleton as X` and no reference is touched. There is none in the repo today, but renaming references would otherwise have to prove the alias is free at every use site.
+ * - A reference whose scope resolves to something other than the module-level import is left alone, which is what makes a shadowing `const Loader` inside a function safe.
+ */
+const FROM_SOURCE = "@pace/ui";
+const TO_SOURCE = "@pace/propel/skeleton";
+const FROM_NAME = "Loader";
+const TO_NAME = "Skeleton";
+
+type Splice = { start: number; end: number; text: string };
+
+/** Babel records these; ast-types does not declare them on the node types. */
+type Ranged = { start?: number; end?: number };
+
+const rangeOf = (node: unknown): [number, number] | undefined => {
+ const { start, end } = (node ?? {}) as Ranged;
+ return typeof start === "number" && typeof end === "number"
+ ? [start, end]
+ : undefined;
+};
+
+export default function transform(file: FileInfo, api: API) {
+ const j = api.jscodeshift;
+ const root = j(file.source);
+
+ const splices: Splice[] = [];
+ /** Local names bound to @pace/ui's `Loader` that are safe to rename at their use sites. */
+ const renamedLocals = new Set();
+
+ root
+ .find(j.ImportDeclaration, { source: { value: FROM_SOURCE } })
+ .forEach((path: ASTPath) => {
+ const specifiers = path.node.specifiers ?? [];
+ const index = specifiers.findIndex(
+ (specifier) =>
+ specifier.type === "ImportSpecifier" &&
+ // ast-types types both names as `string | IdentifierKind`; only the string form can be compared.
+ typeof specifier.imported?.name === "string" &&
+ specifier.imported.name === FROM_NAME
+ );
+ if (index === -1) return;
+
+ const specifier = specifiers[index] as ImportSpecifier;
+ const specifierRange = rangeOf(specifier);
+ const declarationRange = rangeOf(path.node);
+ if (!specifierRange || !declarationRange) return;
+
+ const localName =
+ typeof specifier.local?.name === "string"
+ ? specifier.local.name
+ : FROM_NAME;
+ // An alias only has to survive when it differs from both names; `Loader as Skeleton` already binds the target name.
+ const binding =
+ localName === FROM_NAME || localName === TO_NAME
+ ? TO_NAME
+ : `${TO_NAME} as ${localName}`;
+ // `import type { Loader }` binds no runtime value; the replacement has to keep the modifier or the emitted import stops type-checking.
+ const keyword =
+ path.node.importKind === "type" ? "import type" : "import";
+ const declaration = `${keyword} { ${binding} } from "${TO_SOURCE}";`;
+
+ if (specifiers.length === 1) {
+ // Nothing else came from @pace/ui, so the statement itself moves and the file loses the dependency.
+ splices.push({
+ start: declarationRange[0],
+ end: declarationRange[1],
+ text: declaration,
+ });
+ } else {
+ // Cutting to the next specifier's start (or back from the previous one's end when this is the last) takes the separating comma and whitespace with it, whatever the formatting.
+ const neighbour =
+ index + 1 < specifiers.length
+ ? rangeOf(specifiers[index + 1])
+ : rangeOf(specifiers[index - 1]);
+ if (!neighbour) return;
+ const [cutStart, cutEnd] =
+ index + 1 < specifiers.length
+ ? [specifierRange[0], neighbour[0]]
+ : [neighbour[1], specifierRange[1]];
+ splices.push({ start: cutStart, end: cutEnd, text: "" });
+ // Inserted at the `import` keyword, so a leading `// ui` style comment stays above both statements.
+ splices.push({
+ start: declarationRange[0],
+ end: declarationRange[0],
+ text: `${declaration}\n`,
+ });
+ }
+
+ if (localName === FROM_NAME) renamedLocals.add(localName);
+ });
+
+ if (splices.length === 0) return file.source;
+
+ // The scope hangs off Program, not off the File node the root path points at — `root.get().scope` is null, and comparing against null would have matched every unresolved identifier instead of none.
+ const moduleScope = root.find(j.Program).paths()[0]?.scope;
+
+ if (renamedLocals.size > 0 && moduleScope) {
+ const rename = (path: ASTPath<{ name?: unknown }>) => {
+ const name = path.node.name;
+ if (typeof name !== "string" || !renamedLocals.has(name)) return;
+ // The import specifier's own identifiers sit inside a range already being replaced.
+ if (path.parent?.node?.type === "ImportSpecifier") return;
+ // A JSX member expression's property (`Loader.Item` -> the `Item`) must not be touched.
+ if (
+ path.parent?.node?.type === "JSXMemberExpression" &&
+ path.parent.node.property === path.node
+ )
+ return;
+ if (path.scope?.lookup(name) !== moduleScope) return;
+ const range = rangeOf(path.node);
+ if (range)
+ splices.push({ start: range[0], end: range[1], text: TO_NAME });
+ };
+
+ // One pass, not two: ast-types declares JSXIdentifier as a subtype of Identifier, so this collection already contains the JSX element names. Searching both types would splice every one of them twice.
+ root.find(j.Identifier).forEach(rename);
+ }
+
+ // Back to front, so an earlier splice never invalidates a later offset.
+ let output = file.source;
+ for (const splice of splices.toSorted((a, b) => b.start - a.start)) {
+ output =
+ output.slice(0, splice.start) + splice.text + output.slice(splice.end);
+ }
+
+ return output;
+}
diff --git a/packages/constants/package.json b/packages/constants/package.json
index b58b4f76..a6bb2ffb 100644
--- a/packages/constants/package.json
+++ b/packages/constants/package.json
@@ -1,6 +1,6 @@
{
"name": "@pace/constants",
- "version": "1.4.39",
+ "version": "1.4.40",
"private": true,
"license": "AGPL-3.0",
"type": "module",
diff --git a/packages/editor/package.json b/packages/editor/package.json
index cc755645..b03cf9c6 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@pace/editor",
- "version": "1.4.39",
+ "version": "1.4.40",
"private": true,
"description": "Core Editor that powers Pace",
"keywords": [
diff --git a/packages/editor/src/components/editors/document/loader.tsx b/packages/editor/src/components/editors/document/loader.tsx
index a37dddf8..61461027 100644
--- a/packages/editor/src/components/editors/document/loader.tsx
+++ b/packages/editor/src/components/editors/document/loader.tsx
@@ -5,7 +5,7 @@
*/
// pace imports
-import { Loader } from "@pace/ui";
+import { Skeleton } from "@pace/propel/skeleton";
import { cn } from "@pace/utils";
type Props = {
@@ -17,41 +17,41 @@ export function DocumentContentLoader(props: Props) {
return (
-
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
);
}
diff --git a/packages/hooks/package.json b/packages/hooks/package.json
index a465e16a..17722550 100644
--- a/packages/hooks/package.json
+++ b/packages/hooks/package.json
@@ -1,6 +1,6 @@
{
"name": "@pace/hooks",
- "version": "1.4.39",
+ "version": "1.4.40",
"private": true,
"description": "React hooks that are shared across multiple apps internally",
"license": "AGPL-3.0",
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index afd8f563..bd192ae2 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@pace/i18n",
- "version": "1.4.39",
+ "version": "1.4.40",
"private": true,
"description": "I18n shared across multiple apps internally",
"license": "AGPL-3.0",
diff --git a/packages/propel/package.json b/packages/propel/package.json
index 1a404e58..be472ef3 100644
--- a/packages/propel/package.json
+++ b/packages/propel/package.json
@@ -1,9 +1,10 @@
{
"name": "@pace/propel",
- "version": "1.4.39",
+ "version": "1.4.40",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+ "sideEffects": false,
"exports": {
"./accordion": "./dist/accordion/index.js",
"./animated-counter": "./dist/animated-counter/index.js",
@@ -36,6 +37,7 @@
"./popover": "./dist/popover/index.js",
"./portal": "./dist/portal/index.js",
"./scrollarea": "./dist/scrollarea/index.js",
+ "./separator": "./dist/separator/index.js",
"./skeleton": "./dist/skeleton/index.js",
"./spinners": "./dist/spinners/index.js",
"./switch": "./dist/switch/index.js",
@@ -63,7 +65,7 @@
"build-storybook": "storybook build"
},
"dependencies": {
- "@base-ui-components/react": "catalog:",
+ "@base-ui/react": "catalog:",
"@makeplane/propel": "catalog:",
"@pace/constants": "workspace:*",
"@pace/hooks": "workspace:*",
diff --git a/packages/propel/src/accordion/accordion.tsx b/packages/propel/src/accordion/accordion.tsx
index ac5071cf..979c18f1 100644
--- a/packages/propel/src/accordion/accordion.tsx
+++ b/packages/propel/src/accordion/accordion.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { Accordion as BaseAccordion } from "@base-ui-components/react";
+import { Accordion as BaseAccordion } from "@base-ui/react";
import { AddOutline } from "@makeplane/propel/icons";
@@ -39,7 +39,7 @@ export interface AccordionContentProps {
function AccordionRoot({ defaultValue = [], allowMultiple = false, className = "", children }: AccordionRootProps) {
return (
-
+
{children}
);
diff --git a/packages/ui/src/avatar/avatar-group.tsx b/packages/propel/src/avatar/avatar-group.tsx
similarity index 94%
rename from packages/ui/src/avatar/avatar-group.tsx
rename to packages/propel/src/avatar/avatar-group.tsx
index 2b8162b5..edd416b5 100644
--- a/packages/ui/src/avatar/avatar-group.tsx
+++ b/packages/propel/src/avatar/avatar-group.tsx
@@ -6,12 +6,12 @@
import React from "react";
// ui
-import { Tooltip } from "@pace/propel/tooltip";
+import { Tooltip } from "../tooltip";
// helpers
import { cn } from "../utils";
// types
-import type { TAvatarSize } from "./helper";
-import { getSizeInfo, isAValidNumber } from "./helper";
+import type { TAvatarSize } from "./avatar";
+import { getSizeInfo, isAValidNumber } from "./avatar";
type Props = {
/**
diff --git a/packages/propel/src/avatar/avatar.tsx b/packages/propel/src/avatar/avatar.tsx
index 3f59a692..dc81d690 100644
--- a/packages/propel/src/avatar/avatar.tsx
+++ b/packages/propel/src/avatar/avatar.tsx
@@ -5,7 +5,8 @@
*/
import React from "react";
-import { Avatar as AvatarPrimitive } from "@base-ui-components/react/avatar";
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
+import { Tooltip } from "../tooltip";
import { cn } from "../utils/classname";
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
@@ -15,7 +16,7 @@ type Props = {
fallbackBackgroundColor?: string; //The background color if the avatar image fails to load
fallbackText?: string;
fallbackTextColor?: string; //The text color if the avatar image fails to load
- showTooltip?: boolean;
+ showTooltip?: boolean; //Whether the name is surfaced as a hover tooltip. @default true
size?: TAvatarSize; //The size of the avatars
shape?: "circle" | "square";
src?: string; //The source of the avatar image
@@ -91,6 +92,7 @@ export function Avatar(props: Props) {
fallbackBackgroundColor,
fallbackText,
fallbackTextColor,
+ showTooltip = true,
size = "md",
shape = "circle",
src,
@@ -102,32 +104,41 @@ export function Avatar(props: Props) {
const fallbackLetter = name?.[0]?.toUpperCase() ?? fallbackText ?? "?";
return (
-
-
-
-
- {fallbackLetter}
-
-
-
+ // The tooltip is how an avatar says whose it is: the label is the only text the component renders beyond a single initial. `disabled` rather than a conditional wrapper keeps the DOM identical either way, so turning the tooltip off cannot reflow a row of avatars.
+
+
+
+ {/* `alt` carries the name for anyone not hovering: the tooltip is pointer-only, and an avatar image with no alt text reads as its URL. */}
+
+
+ {fallbackLetter}
+
+
+
+
);
}
diff --git a/packages/propel/src/avatar/index.ts b/packages/propel/src/avatar/index.ts
index 522264e6..af722d71 100644
--- a/packages/propel/src/avatar/index.ts
+++ b/packages/propel/src/avatar/index.ts
@@ -5,3 +5,4 @@
*/
export * from "./avatar";
+export * from "./avatar-group";
diff --git a/packages/propel/src/collapsible/collapsible.tsx b/packages/propel/src/collapsible/collapsible.tsx
index a92b1646..5802c45e 100644
--- a/packages/propel/src/collapsible/collapsible.tsx
+++ b/packages/propel/src/collapsible/collapsible.tsx
@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect, useCallback, createContext, useContext } from "react";
-import { Collapsible as BaseCollapsible } from "@base-ui-components/react/collapsible";
+import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible";
import clsx from "clsx";
// Types
diff --git a/packages/propel/src/combobox/combobox.tsx b/packages/propel/src/combobox/combobox.tsx
index c4e83c05..dbe29cd4 100644
--- a/packages/propel/src/combobox/combobox.tsx
+++ b/packages/propel/src/combobox/combobox.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox";
+import { Combobox as BaseCombobox } from "@base-ui/react/combobox";
import { SearchOutline } from "@makeplane/propel/icons";
import { cn } from "../utils/classname";
@@ -73,11 +73,13 @@ function ComboboxRoot({
onOpenChange,
children,
}: ComboboxProps) {
+ // Base UI 1.x reports a cleared selection as `null`, which the public `onValueChange` contract does not
+ // carry. Map it back to the empty value of whichever mode is active so callers keep seeing one shape.
const handleValueChange = React.useCallback(
- (newValue: string | string[]) => {
- onValueChange?.(newValue);
+ (newValue: string | string[] | null) => {
+ onValueChange?.(newValue ?? (multiSelect ? [] : ""));
},
- [onValueChange]
+ [onValueChange, multiSelect]
);
return (
diff --git a/packages/propel/src/context-menu/context-menu.tsx b/packages/propel/src/context-menu/context-menu.tsx
index e5d0211a..193bbf05 100644
--- a/packages/propel/src/context-menu/context-menu.tsx
+++ b/packages/propel/src/context-menu/context-menu.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { ContextMenu as ContextMenuPrimitive } from "@base-ui-components/react/context-menu";
+import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
import { cn } from "../utils";
export interface ContextMenuProps extends React.ComponentProps {
diff --git a/packages/propel/src/dialog/root.tsx b/packages/propel/src/dialog/root.tsx
index 3a917b2c..ef6b94c9 100644
--- a/packages/propel/src/dialog/root.tsx
+++ b/packages/propel/src/dialog/root.tsx
@@ -5,7 +5,7 @@
*/
import { forwardRef, memo, useMemo } from "react";
-import { Dialog as BaseDialog } from "@base-ui-components/react";
+import { Dialog as BaseDialog } from "@base-ui/react";
import { cn } from "../utils/classname";
// enums
diff --git a/packages/propel/src/emoji-icon-picker/emoji-picker.tsx b/packages/propel/src/emoji-icon-picker/emoji-picker.tsx
index 9fc78127..40c4b09d 100644
--- a/packages/propel/src/emoji-icon-picker/emoji-picker.tsx
+++ b/packages/propel/src/emoji-icon-picker/emoji-picker.tsx
@@ -5,7 +5,7 @@
*/
import { useMemo, useCallback } from "react";
-import { Tabs } from "@base-ui-components/react";
+import { Tabs } from "@base-ui/react";
import { Popover } from "../popover";
import { cn } from "../utils/classname";
import { convertPlacementToSideAndAlign } from "../utils/placement";
@@ -131,10 +131,10 @@ export function EmojiPicker(props: TCustomEmojiPicker) {
+ className={({ active }) =>
cn("rounded-sm border border-subtle bg-layer-1 py-1 text-13", {
- "bg-surface-1 text-primary": selected,
- "text-placeholder hover:bg-layer-1/60 hover:text-tertiary": !selected,
+ "bg-surface-1 text-primary": active,
+ "text-placeholder hover:bg-layer-1/60 hover:text-tertiary": !active,
})
}
>
diff --git a/packages/propel/src/input/input.tsx b/packages/propel/src/input/input.tsx
index e6b8c094..1edb8adf 100644
--- a/packages/propel/src/input/input.tsx
+++ b/packages/propel/src/input/input.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { Input as BaseInput } from "@base-ui-components/react/input";
+import { Input as BaseInput } from "@base-ui/react/input";
// helpers
import { cn } from "../utils";
diff --git a/packages/propel/src/menu/menu.tsx b/packages/propel/src/menu/menu.tsx
index a7446ded..135fa349 100644
--- a/packages/propel/src/menu/menu.tsx
+++ b/packages/propel/src/menu/menu.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { Menu as BaseMenu } from "@base-ui-components/react/menu";
+import { Menu as BaseMenu } from "@base-ui/react/menu";
import { ChevronDownOutline, ChevronRightOutline, MoreHorizontalOutline } from "@makeplane/propel/icons";
import { cn } from "../utils/classname";
import type { TMenuProps, TSubMenuProps, TMenuItemProps } from "./types";
@@ -128,10 +128,12 @@ function Menu(props: TMenuProps) {
};
return (
-
+ // Base UI 1.x owns `openOnHover` on the trigger, not the root, so that one root can serve several detached triggers.
+
{customButton ? (
;
+
+const PopoverHoverContext = createContext({});
+
+export interface PopoverProps extends React.ComponentProps, TPopoverHoverProps {}
+
export interface PopoverContentProps extends React.ComponentProps {
placement?: TPlacement;
align?: TAlign;
@@ -52,7 +64,8 @@ const PopoverContent = memo(function PopoverContent({
// wrapper components
const PopoverTrigger = memo(function PopoverTrigger(props: React.ComponentProps) {
- return ;
+ const hoverProps = useContext(PopoverHoverContext);
+ return ;
});
const PopoverPortal = memo(function PopoverPortal(props: React.ComponentProps) {
@@ -65,8 +78,13 @@ const PopoverPositioner = memo(function PopoverPositioner(props: React.Component
// compound components
const Popover = Object.assign(
- memo(function Popover(props: React.ComponentProps) {
- return ;
+ memo(function Popover({ openOnHover, delay, closeDelay, ...props }: PopoverProps) {
+ const hoverProps = useMemo(() => ({ openOnHover, delay, closeDelay }), [openOnHover, delay, closeDelay]);
+ return (
+
+
+
+ );
}),
{
Button: PopoverTrigger,
diff --git a/packages/propel/src/scrollarea/scrollarea.tsx b/packages/propel/src/scrollarea/scrollarea.tsx
index 4c52e98d..14543223 100644
--- a/packages/propel/src/scrollarea/scrollarea.tsx
+++ b/packages/propel/src/scrollarea/scrollarea.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { ScrollArea as BaseScrollArea } from "@base-ui-components/react/scroll-area";
+import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area";
import { cn } from "../utils/classname";
diff --git a/packages/ui/src/card/index.ts b/packages/propel/src/separator/index.ts
similarity index 84%
rename from packages/ui/src/card/index.ts
rename to packages/propel/src/separator/index.ts
index 4260f850..b83063bf 100644
--- a/packages/ui/src/card/index.ts
+++ b/packages/propel/src/separator/index.ts
@@ -4,4 +4,4 @@
* See the LICENSE file for details.
*/
-export * from "./card";
+export * from "./separator";
diff --git a/packages/propel/src/separator/separator.tsx b/packages/propel/src/separator/separator.tsx
index 22030e2a..a6ebcec2 100644
--- a/packages/propel/src/separator/separator.tsx
+++ b/packages/propel/src/separator/separator.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { Separator as SeparatorPrimitive } from "@base-ui-components/react/separator";
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "../utils";
interface SeparatorProps extends React.ComponentProps {
diff --git a/packages/propel/src/spinners/circular-spinner.tsx b/packages/propel/src/spinners/circular-spinner.tsx
index e64f8c08..ae742696 100644
--- a/packages/propel/src/spinners/circular-spinner.tsx
+++ b/packages/propel/src/spinners/circular-spinner.tsx
@@ -6,7 +6,7 @@
import * as React from "react";
// helpers
-import clsx from "clsx";
+import { cn } from "../utils/classname";
export interface ISpinner extends React.SVGAttributes {
height?: string;
@@ -21,7 +21,7 @@ export function Spinner({ height = "32px", width = "32px", className = "" }: ISp
aria-hidden="true"
height={height}
width={width}
- className={clsx("animate-spin text-secondary", className)}
+ className={cn("animate-spin fill-accent-primary text-secondary", className)}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -31,8 +31,8 @@ export function Spinner({ height = "32px", width = "32px", className = "" }: ISp
fill="currentColor"
/>
Loading...
diff --git a/packages/propel/src/switch/root.tsx b/packages/propel/src/switch/root.tsx
index 0c3e87c2..7dfedd02 100644
--- a/packages/propel/src/switch/root.tsx
+++ b/packages/propel/src/switch/root.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { Switch as BaseSwitch } from "@base-ui-components/react/switch";
+import { Switch as BaseSwitch } from "@base-ui/react/switch";
import { cn } from "../utils/classname";
export interface IToggleSwitchProps {
diff --git a/packages/propel/src/tabs/tabs.tsx b/packages/propel/src/tabs/tabs.tsx
index 56c55a98..13b34445 100644
--- a/packages/propel/src/tabs/tabs.tsx
+++ b/packages/propel/src/tabs/tabs.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { Tabs as TabsPrimitive } from "@base-ui-components/react/tabs";
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { cn } from "../utils/classname";
type TabsVariant = "contained";
@@ -93,7 +93,7 @@ const TabsTrigger = React.forwardRef(function TabsTrigger(
data-slot="tabs-trigger"
className={cn(
"flex w-full min-w-fit cursor-pointer items-center justify-center rounded-md border border-transparent p-1 font-medium text-primary transition-all duration-200 ease-in-out outline-none focus:outline-none",
- "data-[selected]:shadow-sm data-[selected]:raised-200 data-[selected]:border data-[selected]:border-subtle-1 data-[selected]:bg-layer-2 data-[selected]:text-primary",
+ "data-[active]:shadow-sm data-[active]:raised-200 data-[active]:border data-[active]:border-subtle-1 data-[active]:bg-layer-2 data-[active]:text-primary",
"text-placeholder hover:bg-layer-transparent-hover hover:text-tertiary",
"disabled:cursor-not-allowed disabled:text-placeholder",
{
diff --git a/packages/propel/src/toast/toast.tsx b/packages/propel/src/toast/toast.tsx
index 9985261f..59d8e699 100644
--- a/packages/propel/src/toast/toast.tsx
+++ b/packages/propel/src/toast/toast.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { Toast as BaseToast } from "@base-ui-components/react/toast";
+import { Toast as BaseToast } from "@base-ui/react/toast";
import { CloseOutline, InfoOutline, TickOutline, WarningTriangleOutline } from "@makeplane/propel/icons";
// spinner
import { CircularBarSpinner } from "../spinners/circular-bar-spinner";
diff --git a/packages/propel/src/tooltip/root.tsx b/packages/propel/src/tooltip/root.tsx
index 19f06fc7..da3c675e 100644
--- a/packages/propel/src/tooltip/root.tsx
+++ b/packages/propel/src/tooltip/root.tsx
@@ -5,7 +5,7 @@
*/
import * as React from "react";
-import { Tooltip as BaseTooltip } from "@base-ui-components/react/tooltip";
+import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
import { cn } from "../utils";
import type { TPlacement, TSide, TAlign } from "../utils/placement";
import { convertPlacementToSideAndAlign } from "../utils/placement";
@@ -31,12 +31,13 @@ export function Tooltip(props: ITooltipProps) {
const {
tooltipHeading,
tooltipContent,
- position = "top",
+ position,
children,
disabled = false,
className = "",
openDelay = 200,
- side = "bottom",
+ // `position` wins when it is given, so it must not be defaulted: with `position = "top"` in this list the branch below always took it and `side`/`align` were declared props that could never reach the positioner. The defaults live here instead, and "top"/"center" is exactly what `convertPlacementToSideAndAlign("top")` returned, so every existing call site that passes neither is placed where it was.
+ side = "top",
align = "center",
sideOffset = 10,
closeDelay,
@@ -52,8 +53,9 @@ export function Tooltip(props: ITooltipProps) {
// No `BaseTooltip.Provider` here on purpose: it exists to share one delay group across many tooltips, so one provider per tooltip is a `FloatingDelayGroup` of one that can never hand off to a neighbour. Mount it once per app root to get the adjacent-instant-open behaviour; the open delay below is passed per tooltip and wins over a provider either way.
return (
-
-
+ // Base UI 1.x owns the hover delays on the trigger, not the root, so that one root can serve several detached triggers with different delays.
+
+ = {
- title: "Avatar",
- component: Avatar,
-};
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {
- args: { name: "John Doe" },
-};
-
-export const Large: Story = {
- args: { name: "John Doe" },
-};
diff --git a/packages/ui/src/avatar/avatar.tsx b/packages/ui/src/avatar/avatar.tsx
deleted file mode 100644
index 1d29b047..00000000
--- a/packages/ui/src/avatar/avatar.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * Copyright (c) 2023-present Plane Software, Inc. and contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- * See the LICENSE file for details.
- */
-
-// ui
-import { Tooltip } from "@pace/propel/tooltip";
-// helpers
-import { cn } from "../utils";
-import type { TAvatarSize } from "./helper";
-import { getBorderRadius, getSizeInfo, isAValidNumber } from "./helper";
-
-type Props = {
- /**
- * The name of the avatar which will be displayed on the tooltip
- */
- name?: string;
- /**
- * The background color if the avatar image fails to load
- */
- fallbackBackgroundColor?: string;
- /**
- * The text to display if the avatar image fails to load
- */
- fallbackText?: string;
- /**
- * The text color if the avatar image fails to load
- */
- fallbackTextColor?: string;
- /**
- * Whether to show the tooltip or not
- * @default true
- */
- showTooltip?: boolean;
- /**
- * The size of the avatars
- * Possible values: "sm", "md", "base", "lg"
- * @default "md"
- */
- size?: TAvatarSize;
- /**
- * The shape of the avatar
- * Possible values: "circle", "square"
- * @default "circle"
- */
- shape?: "circle" | "square";
- /**
- * The source of the avatar image
- */
- src?: string;
- /**
- * The custom CSS class name to apply to the component
- */
- className?: string;
-};
-
-export function Avatar(props: Props) {
- const {
- name,
- fallbackBackgroundColor,
- fallbackText,
- fallbackTextColor,
- showTooltip = true,
- size = "md",
- shape = "circle",
- src,
- className = "",
- } = props;
-
- // get size details based on the size prop
- const sizeInfo = getSizeInfo(size);
-
- return (
-
-