From 98b66b462d8a5bc4db21644301499cee5faba0db Mon Sep 17 00:00:00 2001 From: Ilya Zayats Date: Sun, 30 Aug 2026 23:53:31 +0200 Subject: [PATCH] refactor(react): trim high-usage bundle boundaries --- .../react/.scripts/check-consumer-bundle.ts | 71 +- .../check-published-runtime-dependencies.ts | 30 +- .../.scripts/consumer-bundle-baseline.json | 674 +++++++++++++++--- packages/react/package.json | 4 + .../F0Button/__stories__/F0Button.mdx | 6 + .../F0Button/__stories__/F0Button.stories.tsx | 2 +- .../F0Button/__tests__/F0Button.test.tsx | 66 +- .../F0Button/components/ButtonLabel.tsx | 53 ++ .../components/__tests__/ButtonLabel.test.tsx | 28 + .../src/components/F0Button/internal.tsx | 20 +- .../src/components/F0Card/CardInternal.tsx | 2 +- .../components/F0Card/__tests__/Card.test.tsx | 12 +- .../F0Card/components/CardMetadata.tsx | 53 +- packages/react/src/components/F0Card/types.ts | 6 +- .../src/components/F0InputField/AppendTag.tsx | 2 +- .../F0InputField/components/Label.tsx | 2 +- .../src/components/F0Select/F0Select.tsx | 4 +- .../F0Select/components/SelectAll.tsx | 2 +- .../F0Select/components/SelectItem.tsx | 2 +- .../F0Select/components/SelectedItems.tsx | 2 +- .../F0Select/components/SelectionPreview.tsx | 2 +- .../avatars/F0AvatarFlag/F0AvatarFlag.tsx | 4 +- .../tags/internal/BaseTag/index.tsx | 2 +- .../deprecated/EntitySelect/Trigger/index.tsx | 2 +- .../F0TableOfContent/TOCFooter/index.tsx | 2 +- .../components/FunnelChart/FunnelChart.tsx | 2 +- .../Banners/F0AiBanner/AiBannerInternal.tsx | 2 +- .../ai/Banners/F0Callout/CalloutInternal.tsx | 2 +- .../markdownRenderers/components/Table.tsx | 2 +- .../kits/ai/F0AiChatHeader/F0AiChatHeader.tsx | 2 +- .../components/CreditsPopover.tsx | 2 +- .../components/EmployeeCreditsPopover.tsx | 2 +- .../ai/F0AiChatHistory/F0AiChatHistory.tsx | 2 +- .../F0AiChatHistory/components/ThreadItem.tsx | 2 +- .../ai/F0AiChatTextArea/F0AiChatTextArea.tsx | 2 +- .../components/MentionPopover.tsx | 2 +- .../kits/ai/F0AiTableCard/F0AiTableCard.tsx | 2 +- .../components/StepHeader.tsx | 2 +- .../ai/canvas/F0CanvasCard/F0CanvasCard.tsx | 2 +- .../src/lib/OneEllipsis/OneEllipsis.test.tsx | 54 +- .../react/src/lib/OneEllipsis/OneEllipsis.tsx | 79 +- .../lib/OneEllipsis/PlainEllipsis.test.tsx | 179 +++++ .../src/lib/OneEllipsis/PlainEllipsis.tsx | 91 +++ .../__stories__/OneEllipsis.stories.tsx | 21 + packages/react/src/lib/OneEllipsis/index.tsx | 1 + packages/react/src/lib/OneEllipsis/types.ts | 15 + .../lib/OneEllipsis/use-ellipsis-overflow.ts | 59 ++ packages/react/src/lib/emoji-confetti.ts | 37 + packages/react/src/lib/emojis.tsx | 36 - packages/react/src/lib/exports.ts | 1 + .../DashboardItem/DashboardItem.tsx | 2 +- .../Sidebar/Chats/SidebarChatItem.tsx | 2 +- .../Sidebar/CompanySelector/index.tsx | 2 +- .../Navigation/Sidebar/Footer/index.tsx | 2 +- .../Navigation/Sidebar/Menu/index.tsx | 2 +- .../ActionBar/OneDataCollectionActionBar.tsx | 2 +- .../collection/List/components/ItemTeaser.tsx | 2 +- .../SortAndHideList/SortAndHideList.tsx | 2 +- .../OneFilterPicker/components/FilterList.tsx | 2 +- .../filterTypes/InFilter/InFilter.tsx | 2 +- .../components/InFilterFlatOption.tsx | 2 +- .../InFilter/components/InFilterOptionRow.tsx | 2 +- .../Home/ClockIn/ClockInControls/index.tsx | 2 +- .../components/ChatEmojiAutocomplete.tsx | 2 +- .../F0Chat/components/ChatMentionPopover.tsx | 2 +- .../F0Chat/components/ChatReactionPill.tsx | 3 +- .../src/sds/social/Reactions/reaction.tsx | 3 +- .../value-display/types/country/country.tsx | 2 +- .../value-display/types/longText/longText.tsx | 2 +- .../ui/value-display/types/person/person.tsx | 2 +- .../src/ui/value-display/types/text/text.tsx | 2 +- packages/react/vite.config.ts | 10 +- 72 files changed, 1407 insertions(+), 301 deletions(-) create mode 100644 packages/react/src/components/F0Button/components/ButtonLabel.tsx create mode 100644 packages/react/src/components/F0Button/components/__tests__/ButtonLabel.test.tsx create mode 100644 packages/react/src/lib/OneEllipsis/PlainEllipsis.test.tsx create mode 100644 packages/react/src/lib/OneEllipsis/PlainEllipsis.tsx create mode 100644 packages/react/src/lib/OneEllipsis/types.ts create mode 100644 packages/react/src/lib/OneEllipsis/use-ellipsis-overflow.ts create mode 100644 packages/react/src/lib/emoji-confetti.ts diff --git a/packages/react/.scripts/check-consumer-bundle.ts b/packages/react/.scripts/check-consumer-bundle.ts index 5e7f2266f1..4773c9a783 100644 --- a/packages/react/.scripts/check-consumer-bundle.ts +++ b/packages/react/.scripts/check-consumer-bundle.ts @@ -55,9 +55,18 @@ export default function App() { `, f0Box: retainRootExport("F0Box"), f0Text: retainRootExport("F0Text"), + f0Icon: retainRootExport("F0Icon"), + f0Alert: retainRootExport("F0Alert"), + f0Heading: retainRootExport("F0Heading"), + f0Card: retainRootExport("F0Card"), + f0Link: retainRootExport("F0Link"), + f0AvatarPerson: retainRootExport("F0AvatarPerson"), f0Dialog: retainRootExport("F0Dialog"), f0Select: retainRootExport("F0Select"), + f0FormField: retainRootExport("f0FormField"), + useEmojiConfetti: retainRootExport("useEmojiConfetti"), f0Form: retainRootExport("F0Form"), + stack: retainExport("Stack", "@factorialco/f0-react/dist/experimental"), oneDataCollection: retainExport( "OneDataCollection", "@factorialco/f0-react/dist/experimental" @@ -68,7 +77,7 @@ export default function App() { const CLOUDFLARE_PROBES = { f0Button: { - maxBytes: 700_000, + maxBytes: 420_000, source: ` import React from "react" import { createRoot } from "react-dom/client" @@ -82,7 +91,7 @@ createRoot(document.createElement("div")).render() `, }, f0ButtonFromRoot: { - maxBytes: 700_000, + maxBytes: 430_000, source: ` import React from "react" import { createRoot } from "react-dom/client" @@ -93,13 +102,32 @@ function App() { } createRoot(document.createElement("div")).render() +`, + }, + stackFromLegacyExperimentalPath: { + maxBytes: 180_000, + source: ` +import React from "react" +import { createRoot } from "react-dom/client" +import { Stack } from "@factorialco/f0-react/dist/experimental" + +function App() { + return Content +} + +createRoot(document.createElement("div")).render() +`, + }, + tableOfContentFromLegacyExperimentalPath: { + maxBytes: 2_900_000, + source: ` +import { F0TableOfContent } from "@factorialco/f0-react/dist/experimental" + +console.log(F0TableOfContent) `, }, f0DialogWithButton: { - // F0Dialog's public resourceHeader feature currently retains the complete - // F0Avatar flag map. Keep that real boundary visible while preventing the - // root barrel and unrelated document/map/AI features from returning. - maxBytes: 4_200_000, + maxBytes: 3_900_000, source: ` import React from "react" import { createRoot } from "react-dom/client" @@ -119,6 +147,17 @@ createRoot(document.createElement("div")).render() }, } as const +const INITIAL_BROTLI_BUDGETS: Partial> = { + f0Button: 120_000, + f0Card: 780_000, + f0Dialog: 850_000, + f0FormField: 85_000, + f0Select: 820_000, + oneDataCollection: 1_050_000, + stack: 55_000, + useEmojiConfetti: 50_000, +} + interface CloudflareProbeMetric { bytes: number inputCount: number @@ -508,10 +547,24 @@ function formatBytes(bytes: number): string { async function main(): Promise { const { cloudflareProbes, report } = await measureBundle() + const outputJson = process.argv.includes("--json") - if (process.argv.includes("--json")) { + if (outputJson) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) - } else { + } + + for (const [variantName, maxBytes] of Object.entries( + INITIAL_BROTLI_BUDGETS + )) { + const variant = report.variants[variantName] + if (variant && variant.assets.initialJs.brotli > maxBytes) { + throw new Error( + `${variantName} initial JS is ${variant.assets.initialJs.brotli} Brotli bytes; budget is ${maxBytes}` + ) + } + } + + if (!outputJson) { for (const [probeName, probe] of Object.entries(cloudflareProbes)) { process.stdout.write( `cloudflare/${probeName}: ${formatBytes(probe.bytes)} raw, ${probe.inputCount} inputs\n` @@ -549,7 +602,7 @@ async function main(): Promise { process.exit(1) } - if (!process.argv.includes("--json")) { + if (!outputJson) { process.stdout.write("Consumer bundle baseline passed\n") } } diff --git a/packages/react/.scripts/check-published-runtime-dependencies.ts b/packages/react/.scripts/check-published-runtime-dependencies.ts index c34075816a..5806e0d776 100644 --- a/packages/react/.scripts/check-published-runtime-dependencies.ts +++ b/packages/react/.scripts/check-published-runtime-dependencies.ts @@ -1,6 +1,7 @@ #!/usr/bin/env tsx import { existsSync, readFileSync, readdirSync, statSync } from "node:fs" import { dirname, relative, resolve } from "node:path" +import { fileURLToPath } from "node:url" import ts from "typescript" interface PackageManifest { @@ -68,6 +69,7 @@ const declaredRuntimePackages = new Set([ ...Object.keys(packageManifest.optionalDependencies ?? {}), ]) const undeclaredImports = new Set() +const unresolvableImports = new Set() const invalidRelativeImports = new Set() for (const filePath of walkJavaScriptFiles(esmRoot)) { @@ -93,6 +95,22 @@ for (const filePath of walkJavaScriptFiles(esmRoot)) { `${relative(packageRoot, filePath)} -> ${specifier}` ) } + try { + const resolvedUrl = import.meta.resolve(specifier) + if ( + resolvedUrl.startsWith("file:") && + (!existsSync(fileURLToPath(resolvedUrl)) || + statSync(fileURLToPath(resolvedUrl)).isDirectory()) + ) { + unresolvableImports.add( + `${relative(packageRoot, filePath)} -> ${specifier}` + ) + } + } catch { + unresolvableImports.add( + `${relative(packageRoot, filePath)} -> ${specifier}` + ) + } } } @@ -112,4 +130,14 @@ if (undeclaredImports.size > 0) { ) } -process.stdout.write("Preserved ESM runtime imports are declared\n") +if (unresolvableImports.size > 0) { + throw new Error( + `Preserved ESM contains imports that native ESM cannot resolve:\n${[ + ...unresolvableImports, + ].join("\n")}` + ) +} + +process.stdout.write( + "Preserved ESM runtime imports are declared and resolvable\n" +) diff --git a/packages/react/.scripts/consumer-bundle-baseline.json b/packages/react/.scripts/consumer-bundle-baseline.json index e465766b85..320f086311 100644 --- a/packages/react/.scripts/consumer-bundle-baseline.json +++ b/packages/react/.scripts/consumer-bundle-baseline.json @@ -23,14 +23,14 @@ "f0Button": { "assets": { "js": { - "raw": 582841, - "gzip": 186054, - "brotli": 160410 + "raw": 394508, + "gzip": 128232, + "brotli": 111221 }, "initialJs": { - "raw": 582841, - "gzip": 186054, - "brotli": 160410 + "raw": 394508, + "gzip": 128232, + "brotli": 111221 }, "css": { "raw": 0, @@ -44,13 +44,11 @@ "experimental/Overlays", "i18n-provider-defaults.js", "icons/app", - "lib/OneEllipsis", "lib/component", "lib/data-testid", "lib/emojis.js", "lib/experimental.js", "lib/linkHandler.js", - "lib/markdown.js", "lib/providers", "lib/recipes", "lib/strip-native-title.js", @@ -67,14 +65,14 @@ "f0ButtonWithGlobalStyles": { "assets": { "js": { - "raw": 582841, - "gzip": 186055, - "brotli": 160600 + "raw": 394508, + "gzip": 128235, + "brotli": 111175 }, "initialJs": { - "raw": 582841, - "gzip": 186055, - "brotli": 160600 + "raw": 394508, + "gzip": 128235, + "brotli": 111175 }, "css": { "raw": 3024315, @@ -88,13 +86,11 @@ "experimental/Overlays", "i18n-provider-defaults.js", "icons/app", - "lib/OneEllipsis", "lib/component", "lib/data-testid", "lib/emojis.js", "lib/experimental.js", "lib/linkHandler.js", - "lib/markdown.js", "lib/providers", "lib/recipes", "lib/strip-native-title.js", @@ -137,14 +133,14 @@ "f0Text": { "assets": { "js": { - "raw": 389681, - "gzip": 123242, - "brotli": 107181 + "raw": 389772, + "gzip": 123385, + "brotli": 107187 }, "initialJs": { - "raw": 389681, - "gzip": 123242, - "brotli": 107181 + "raw": 389772, + "gzip": 123385, + "brotli": 107187 }, "css": { "raw": 0, @@ -163,17 +159,268 @@ "ui/tooltip.js" ] }, + "f0Icon": { + "assets": { + "js": { + "raw": 165974, + "gzip": 53574, + "brotli": 47068 + }, + "initialJs": { + "raw": 165974, + "gzip": 53574, + "brotli": 47068 + }, + "css": { + "raw": 0, + "gzip": 0, + "brotli": 0 + } + }, + "retainedF0Modules": [ + "components/F0Icon", + "lib/component", + "lib/data-testid", + "lib/providers", + "lib/utils.js", + "lib/xray.js" + ] + }, + "f0Alert": { + "assets": { + "js": { + "raw": 402431, + "gzip": 130340, + "brotli": 113003 + }, + "initialJs": { + "raw": 402431, + "gzip": 130340, + "brotli": 113003 + }, + "css": { + "raw": 0, + "gzip": 0, + "brotli": 0 + } + }, + "retainedF0Modules": [ + "components/F0Alert", + "components/F0Button", + "components/F0Icon", + "components/F0Link", + "components/avatars", + "experimental/Overlays", + "i18n-provider-defaults.js", + "icons/app", + "lib/component", + "lib/data-testid", + "lib/emojis.js", + "lib/experimental.js", + "lib/linkHandler.js", + "lib/providers", + "lib/recipes", + "lib/strip-native-title.js", + "lib/text.js", + "lib/utils.js", + "lib/xray.js", + "ui/Action", + "ui/Counter", + "ui/Shortcut", + "ui/skeleton.js", + "ui/tooltip.js" + ] + }, + "f0Heading": { + "assets": { + "js": { + "raw": 389769, + "gzip": 123384, + "brotli": 107199 + }, + "initialJs": { + "raw": 389769, + "gzip": 123384, + "brotli": 107199 + }, + "css": { + "raw": 0, + "gzip": 0, + "brotli": 0 + } + }, + "retainedF0Modules": [ + "components/F0Heading", + "lib/OneEllipsis", + "lib/data-testid", + "lib/markdown.js", + "lib/providers", + "lib/utils.js", + "ui/Text", + "ui/tooltip.js" + ] + }, + "f0Card": { + "assets": { + "js": { + "raw": 3215108, + "gzip": 981773, + "brotli": 757415 + }, + "initialJs": { + "raw": 3215108, + "gzip": 981773, + "brotli": 757415 + }, + "css": { + "raw": 0, + "gzip": 0, + "brotli": 0 + } + }, + "retainedF0Modules": [ + "_virtual/_rolldown", + "components/F0Button", + "components/F0Card", + "components/F0Checkbox", + "components/F0Icon", + "components/F0Link", + "components/OneCalendar", + "components/avatars", + "components/tags", + "experimental/Navigation", + "experimental/Overlays", + "flags/components", + "flags/flagsMap.js", + "i18n-provider-defaults.js", + "icons/app", + "icons/modules", + "kits/Charts", + "lib/OneEllipsis", + "lib/component", + "lib/data-testid", + "lib/date.js", + "lib/emojis.js", + "lib/experimental.js", + "lib/imageHandler.js", + "lib/linkHandler.js", + "lib/numeric", + "lib/providers", + "lib/recipes", + "lib/skeleton.js", + "lib/strip-native-title.js", + "lib/text.js", + "lib/tooltip-wrapper.js", + "lib/utils.js", + "lib/xray.js", + "ui/Action", + "ui/Avatar", + "ui/Card", + "ui/Counter", + "ui/IconBadge", + "ui/OverflowList", + "ui/Shortcut", + "ui/checkbox.js", + "ui/drawer.js", + "ui/dropdown-menu.js", + "ui/hover-card.js", + "ui/popover.js", + "ui/progress.js", + "ui/scrollarea.js", + "ui/skeleton.js", + "ui/tooltip.js", + "ui/value-display" + ] + }, + "f0Link": { + "assets": { + "js": { + "raw": 376337, + "gzip": 123649, + "brotli": 107767 + }, + "initialJs": { + "raw": 376337, + "gzip": 123649, + "brotli": 107767 + }, + "css": { + "raw": 0, + "gzip": 0, + "brotli": 0 + } + }, + "retainedF0Modules": [ + "components/F0Icon", + "components/F0Link", + "experimental/Overlays", + "i18n-provider-defaults.js", + "icons/app", + "lib/component", + "lib/data-testid", + "lib/experimental.js", + "lib/linkHandler.js", + "lib/providers", + "lib/recipes", + "lib/strip-native-title.js", + "lib/utils.js", + "lib/xray.js", + "ui/Action", + "ui/Shortcut", + "ui/skeleton.js", + "ui/tooltip.js" + ] + }, + "f0AvatarPerson": { + "assets": { + "js": { + "raw": 323278, + "gzip": 110349, + "brotli": 94384 + }, + "initialJs": { + "raw": 323278, + "gzip": 110349, + "brotli": 94384 + }, + "css": { + "raw": 0, + "gzip": 0, + "brotli": 0 + } + }, + "retainedF0Modules": [ + "components/F0Icon", + "components/avatars", + "experimental/Overlays", + "i18n-provider-defaults.js", + "icons/app", + "icons/modules", + "lib/component", + "lib/data-testid", + "lib/experimental.js", + "lib/imageHandler.js", + "lib/providers", + "lib/strip-native-title.js", + "lib/utils.js", + "lib/xray.js", + "ui/Avatar", + "ui/IconBadge", + "ui/Shortcut", + "ui/tooltip.js" + ] + }, "f0Dialog": { "assets": { "js": { - "raw": 3784352, - "gzip": 1148809, - "brotli": 893437 + "raw": 3524511, + "gzip": 1065502, + "brotli": 824615 }, "initialJs": { - "raw": 3784352, - "gzip": 1148809, - "brotli": 893437 + "raw": 3524511, + "gzip": 1065502, + "brotli": 824615 }, "css": { "raw": 0, @@ -222,7 +469,6 @@ "lib/experimental.js", "lib/imageHandler.js", "lib/linkHandler.js", - "lib/markdown.js", "lib/objectPaths.js", "lib/promise-to-observable.js", "lib/providers", @@ -271,14 +517,14 @@ "f0Select": { "assets": { "js": { - "raw": 3656929, - "gzip": 1111668, - "brotli": 866521 + "raw": 3397086, + "gzip": 1028885, + "brotli": 797274 }, "initialJs": { - "raw": 3656929, - "gzip": 1111668, - "brotli": 866521 + "raw": 3397086, + "gzip": 1028885, + "brotli": 797274 }, "css": { "raw": 0, @@ -321,7 +567,6 @@ "lib/experimental.js", "lib/imageHandler.js", "lib/linkHandler.js", - "lib/markdown.js", "lib/objectPaths.js", "lib/promise-to-observable.js", "lib/providers", @@ -357,17 +602,57 @@ "ui/tooltip.js" ] }, + "f0FormField": { + "assets": { + "js": { + "raw": 323856, + "gzip": 88632, + "brotli": 76806 + }, + "initialJs": { + "raw": 323856, + "gzip": 88632, + "brotli": 76806 + }, + "css": { + "raw": 0, + "gzip": 0, + "brotli": 0 + } + }, + "retainedF0Modules": ["experimental/Forms", "patterns/F0Form"] + }, + "useEmojiConfetti": { + "assets": { + "js": { + "raw": 154292, + "gzip": 50299, + "brotli": 44084 + }, + "initialJs": { + "raw": 154292, + "gzip": 50299, + "brotli": 44084 + }, + "css": { + "raw": 0, + "gzip": 0, + "brotli": 0 + } + }, + "retainedF0Modules": ["lib/a11y.js", "lib/emoji-confetti.js"] + }, "f0Form": { "assets": { "js": { - "raw": 5951771, - "gzip": 1754134, - "brotli": 1361762 + "raw": 5942754, + "gzip": 1751930, + "brotli": 1360517 }, "initialJs": { - "raw": 5951771, - "gzip": 1754134, - "brotli": 1361762 + "raw": 5942754, + "gzip": 1751930, + "brotli": 1360517 }, "css": { "raw": 15198, @@ -380,6 +665,54 @@ "F0Dialog.js", "F0Form.js", "F0Select.js", + "_embedded/B-6-8PVE.js", + "_embedded/B5LPVUq1.js", + "_embedded/B8gLyjB1.js", + "_embedded/B9CZOQC1.js", + "_embedded/BRDVhECK.js", + "_embedded/BUPKAWbS.js", + "_embedded/BeMnDuG8.js", + "_embedded/BjMpEeL-.js", + "_embedded/BjXuHMwL.js", + "_embedded/BmFjcMV4.js", + "_embedded/BvQYpWPk.js", + "_embedded/BwFnfaFO.js", + "_embedded/BxyqP70i.js", + "_embedded/ByZiS3Em.js", + "_embedded/C0tUD91s.js", + "_embedded/CHDKGsf0.js", + "_embedded/CHwnzGs7.js", + "_embedded/CI8AaMmu.js", + "_embedded/CJTYDVZ3.js", + "_embedded/CTMA7tag.js", + "_embedded/CjYirikk.js", + "_embedded/CjyrMVaI.js", + "_embedded/Cky57_ZF.js", + "_embedded/CoabSTQT.js", + "_embedded/D3ikuXMl.js", + "_embedded/D4NwFgfW.js", + "_embedded/D6FkPvY_.js", + "_embedded/D98X8G83.js", + "_embedded/DCgcmaLB.js", + "_embedded/DFonUhaZ.js", + "_embedded/DIDIj-mF.js", + "_embedded/DI_QKvND.js", + "_embedded/DU2SymKH.js", + "_embedded/DWu2PEWn.js", + "_embedded/D_zg4WzL.js", + "_embedded/DhiT9YYf.js", + "_embedded/DjbvCfWF.js", + "_embedded/DjdiB0gs.js", + "_embedded/Dm3w5r8r.js", + "_embedded/DtVbFn0D.js", + "_embedded/DyCG63P1.js", + "_embedded/NOfjVzyW.js", + "_embedded/auabBO2e.js", + "_embedded/bMm-Atj-.js", + "_embedded/d9horkIK.js", + "_embedded/hK6-IYUN.js", + "_embedded/jHzB3eW5.js", + "_embedded/pQKmRnrc.js", "_virtual/_rolldown", "components/CardSelectable", "components/F0ActionBar", @@ -513,17 +846,42 @@ "ui/value-display" ] }, + "stack": { + "assets": { + "js": { + "raw": 166763, + "gzip": 53797, + "brotli": 47153 + }, + "initialJs": { + "raw": 166763, + "gzip": 53797, + "brotli": 47153 + }, + "css": { + "raw": 0, + "gzip": 0, + "brotli": 0 + } + }, + "retainedF0Modules": [ + "experimental/Utilities", + "lib/component", + "lib/utils.js", + "lib/xray.js" + ] + }, "oneDataCollection": { "assets": { "js": { - "raw": 4400359, - "gzip": 1344654, - "brotli": 1043277 + "raw": 4319933, + "gzip": 1321404, + "brotli": 1020809 }, "initialJs": { - "raw": 4400359, - "gzip": 1344654, - "brotli": 1043277 + "raw": 4319933, + "gzip": 1321404, + "brotli": 1020809 }, "css": { "raw": 1890, @@ -532,56 +890,184 @@ } }, "retainedF0Modules": [ - "CheckCircle.js", - "Cross.js", - "Download.js", - "F0Avatar.js", - "F0AvatarIcon.js", - "F0Button.js", - "F0Card.js", - "F0Checkbox.js", "F0DatePicker.js", "F0Dialog.js", - "F0Link.js", - "F0NumberInput.js", "F0Select.js", - "F0TextInput.js", - "Link.js", - "Maximize.js", - "Minimize.js", - "OneEllipsis.js", - "Placeholder.js", - "Reset.js", - "chevron-right.js", - "clsx.js", - "component.js", - "dist.js", - "dropdown-menu.js", + "_embedded/B-6-8PVE.js", + "_embedded/B5LPVUq1.js", + "_embedded/B8gLyjB1.js", + "_embedded/B9CZOQC1.js", + "_embedded/BRDVhECK.js", + "_embedded/BUPKAWbS.js", + "_embedded/BeMnDuG8.js", + "_embedded/BjMpEeL-.js", + "_embedded/BjXuHMwL.js", + "_embedded/BmFjcMV4.js", + "_embedded/BvQYpWPk.js", + "_embedded/BwFnfaFO.js", + "_embedded/BxyqP70i.js", + "_embedded/ByZiS3Em.js", + "_embedded/C0tUD91s.js", + "_embedded/CHDKGsf0.js", + "_embedded/CHwnzGs7.js", + "_embedded/CI8AaMmu.js", + "_embedded/CJTYDVZ3.js", + "_embedded/CTMA7tag.js", + "_embedded/CjYirikk.js", + "_embedded/CjyrMVaI.js", + "_embedded/Cky57_ZF.js", + "_embedded/CoabSTQT.js", + "_embedded/D3ikuXMl.js", + "_embedded/D4NwFgfW.js", + "_embedded/D6FkPvY_.js", + "_embedded/D98X8G83.js", + "_embedded/DCgcmaLB.js", + "_embedded/DFonUhaZ.js", + "_embedded/DIDIj-mF.js", + "_embedded/DI_QKvND.js", + "_embedded/DU2SymKH.js", + "_embedded/DWu2PEWn.js", + "_embedded/D_zg4WzL.js", + "_embedded/DhiT9YYf.js", + "_embedded/DjbvCfWF.js", + "_embedded/DjdiB0gs.js", + "_embedded/Dm3w5r8r.js", + "_embedded/DtVbFn0D.js", + "_embedded/DyCG63P1.js", + "_embedded/NOfjVzyW.js", + "_embedded/auabBO2e.js", + "_embedded/bMm-Atj-.js", + "_embedded/d9horkIK.js", + "_embedded/hK6-IYUN.js", + "_embedded/jHzB3eW5.js", + "_embedded/pQKmRnrc.js", + "_virtual/_rolldown", + "components/F0ActionBar", + "components/F0Button", + "components/F0ButtonDropdown", + "components/F0ButtonToggle", + "components/F0Card", + "components/F0Checkbox", + "components/F0DatePicker", + "components/F0Icon", + "components/F0InputField", + "components/F0Link", + "components/F0NumberInput", + "components/F0SearchInput", + "components/F0Select", + "components/F0TextAreaInput", + "components/F0TextInput", + "components/OneCalendar", + "components/OneChip", + "components/OneEmptyState", + "components/avatars", + "components/tags", + "experimental/Actions", + "experimental/F0ProgressSeries", + "experimental/Forms", + "experimental/Information", + "experimental/Navigation", + "experimental/OneTable", + "experimental/Overlays", + "flags/components", + "flags/flagsMap.js", + "hooks/datasource", + "hooks/useDebouncedState.js", "i18n-provider-defaults.js", - "input.js", - "internal.js", - "popover.js", - "progress.js", - "rolldown-runtime.js", - "skeleton.js", - "tooltip.js", - "useDataCollectionSource.js", - "utils.js", - "value.js", - "variants.js" + "icons/animated", + "icons/app", + "icons/modules", + "kits/Charts", + "kits/F0DataChart", + "layouts/LayoutProvider.js", + "lib/Await", + "lib/InfoHint", + "lib/OneEllipsis", + "lib/RenderErrorBoundary.js", + "lib/a11y.js", + "lib/component", + "lib/data-testid", + "lib/date.js", + "lib/dnd", + "lib/emojis.js", + "lib/experimental.js", + "lib/field-input-icons.js", + "lib/imageHandler.js", + "lib/linkHandler.js", + "lib/markdown.js", + "lib/numeric", + "lib/objectPaths.js", + "lib/promise-to-observable.js", + "lib/providers", + "lib/recipes", + "lib/skeleton.js", + "lib/strip-native-title.js", + "lib/text.js", + "lib/toArray.js", + "lib/tooltip-wrapper.js", + "lib/useDebounceBoolean.js", + "lib/utils.js", + "lib/xray.js", + "patterns/F0Dialog", + "patterns/F0FilterPickerContent", + "patterns/F0Graph", + "patterns/Navigation", + "patterns/OneDataCollection", + "patterns/OneDateNavigator", + "patterns/OneFilterPicker", + "sds/UpsellingKit", + "ui/Action", + "ui/Avatar", + "ui/ButtonCopy", + "ui/Card", + "ui/ChevronToggle", + "ui/Counter", + "ui/DatePickerPopup", + "ui/Dialog", + "ui/GroupHeader", + "ui/IconBadge", + "ui/Kanban", + "ui/Lane", + "ui/OnePagination", + "ui/OnePreset", + "ui/OverflowList", + "ui/Select", + "ui/Shortcut", + "ui/Spinner", + "ui/Text", + "ui/ToggleGroup", + "ui/breadcrumb.js", + "ui/calendar.js", + "ui/checkbox.js", + "ui/drawer.js", + "ui/dropdown-menu.js", + "ui/hover-card.js", + "ui/input.js", + "ui/pagination.js", + "ui/popover.js", + "ui/progress.js", + "ui/scrollarea.js", + "ui/separator.js", + "ui/skeleton.js", + "ui/switch.js", + "ui/tab-navigation.js", + "ui/table.js", + "ui/textarea.js", + "ui/tooltip.js", + "ui/value-display" ] }, "f0AiChat": { "assets": { "js": { - "raw": 612238, - "gzip": 195428, - "brotli": 168202 + "raw": 434453, + "gzip": 141519, + "brotli": 122434 }, "initialJs": { - "raw": 612238, - "gzip": 195428, - "brotli": 168202 + "raw": 434453, + "gzip": 141519, + "brotli": 122434 }, "css": { "raw": 0, @@ -596,7 +1082,6 @@ "i18n-provider-defaults.js", "icons/app", "kits/ai", - "lib/OneEllipsis", "lib/a11y.js", "lib/component", "lib/data-testid", @@ -605,7 +1090,6 @@ "lib/experimental.js", "lib/linkHandler.js", "lib/local-storage.js", - "lib/markdown.js", "lib/providers", "lib/recipes", "lib/strip-native-title.js", @@ -622,14 +1106,14 @@ "f0PdfViewer": { "assets": { "js": { - "raw": 6303848, - "gzip": 1876382, - "brotli": 1450538 + "raw": 6198507, + "gzip": 1840944, + "brotli": 1426300 }, "initialJs": { - "raw": 5786742, - "gzip": 1710261, - "brotli": 1309388 + "raw": 5505234, + "gzip": 1621099, + "brotli": 1238072 }, "css": { "raw": 436, diff --git a/packages/react/package.json b/packages/react/package.json index 8bb2256c66..ccca49dd83 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -40,6 +40,10 @@ "types": "./dist/component-status.d.ts", "import": "./dist/esm/component-status.js" }, + "./dist/experimental": { + "types": "./dist/experimental.d.ts", + "import": "./dist/esm/experimental.js" + }, "./i18n-provider-defaults": { "types": "./dist/lib/providers/i18n/i18n-provider-defaults.d.ts", "import": "./dist/esm/i18n-provider-defaults.js" diff --git a/packages/react/src/components/F0Button/__stories__/F0Button.mdx b/packages/react/src/components/F0Button/__stories__/F0Button.mdx index 438cd759c4..d87fe15734 100644 --- a/packages/react/src/components/F0Button/__stories__/F0Button.mdx +++ b/packages/react/src/components/F0Button/__stories__/F0Button.mdx @@ -283,6 +283,12 @@ Counters surface a quantity tied to the action — items to review, pending appr ### Behavior +**Truncated labels** + +When the visible label does not fit, the button truncates it and uses the full label as an automatic tooltip. Set `noAutoTooltip` only when surrounding context already communicates the complete action. + + + **Async action** If the `onClick` handler returns a Promise, the button automatically enters the `loading` state until the promise resolves — no need to manage loading state manually. diff --git a/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx b/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx index 5f6fc11e60..2b43aca333 100644 --- a/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx +++ b/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx @@ -99,7 +99,7 @@ const meta = { tooltip: { control: "text", description: - "The tooltip to show when the button is hovered. If not provided, when the hideLabel is true, the tooltip will be generated based on the label property.", + "The tooltip to show when the button is hovered. Without one, the label supplies an automatic tooltip when hidden or visibly truncated unless noAutoTooltip is enabled.", }, href: { control: "text", diff --git a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx index f13980f387..7c4a7403ca 100644 --- a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx +++ b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx @@ -1,5 +1,5 @@ import { userEvent } from "@testing-library/user-event" -import { describe, expect, it, vi } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { Add } from "@/icons/app" import { zeroRender as render, screen } from "@/testing/test-utils" @@ -7,6 +7,10 @@ import { zeroRender as render, screen } from "@/testing/test-utils" import { F0Button } from "../index" describe("F0Button", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + it("should call the onClick handler when clicked", async () => { const onClick = vi.fn() @@ -63,6 +67,66 @@ describe("F0Button", () => { expect(label).toHaveTextContent("Add Item") }) + it("keeps focus when overflow measurement enables the automatic tooltip", async () => { + vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(200) + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100) + + render() + const user = userEvent.setup() + const button = screen.getByRole("button") + button.focus() + await user.hover(button) + + expect( + await screen.findByRole("tooltip", undefined, { timeout: 2_000 }) + ).toHaveTextContent("A long button label") + expect(button).toHaveFocus() + expect(screen.getByRole("button")).toBe(button) + }) + + it("prefers an explicit tooltip over the overflowing label", async () => { + vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(200) + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100) + + render() + await userEvent.hover(screen.getByRole("button")) + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Explicit guidance" + ) + }) + + it("keeps the hidden-label automatic tooltip", async () => { + render() + await userEvent.hover(screen.getByRole("button")) + + expect(await screen.findByRole("tooltip")).toHaveTextContent("Add item") + }) + + it("does not show an overflow tooltip when noAutoTooltip is set", async () => { + vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(200) + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100) + + render() + await userEvent.hover(screen.getByRole("button")) + + await expect( + screen.findByRole("tooltip", undefined, { timeout: 500 }) + ).rejects.toThrow() + }) + + it("does not show an automatic tooltip when the visible label fits", async () => { + vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(100) + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100) + + render() + await userEvent.hover(screen.getByRole("button")) + + await expect( + screen.findByRole("tooltip", undefined, { timeout: 500 }) + ).rejects.toThrow() + }) + it("should show loading state", () => { render() expect(screen.getByRole("button")).toBeDisabled() diff --git a/packages/react/src/components/F0Button/components/ButtonLabel.tsx b/packages/react/src/components/F0Button/components/ButtonLabel.tsx new file mode 100644 index 0000000000..f1389158e8 --- /dev/null +++ b/packages/react/src/components/F0Button/components/ButtonLabel.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef } from "react" + +import { cn } from "@/lib/utils" + +interface ButtonLabelProps { + className?: string + label: string + onOverflowChange: (overflows: boolean) => void +} + +export function ButtonLabel({ + className, + label, + onOverflowChange, +}: ButtonLabelProps) { + const labelRef = useRef(null) + + useEffect( + function observeLabelOverflow() { + const element = labelRef.current + if (!element) return + + const updateOverflow = () => { + onOverflowChange(element.scrollWidth > element.clientWidth) + } + + updateOverflow() + const animationFrame = requestAnimationFrame(updateOverflow) + const timeout = setTimeout(updateOverflow, 100) + const resizeObserver = new ResizeObserver(updateOverflow) + resizeObserver.observe(element) + + return () => { + cancelAnimationFrame(animationFrame) + clearTimeout(timeout) + resizeObserver.disconnect() + } + }, + [label, onOverflowChange] + ) + + return ( + + {label} + + ) +} diff --git a/packages/react/src/components/F0Button/components/__tests__/ButtonLabel.test.tsx b/packages/react/src/components/F0Button/components/__tests__/ButtonLabel.test.tsx new file mode 100644 index 0000000000..02afd21ade --- /dev/null +++ b/packages/react/src/components/F0Button/components/__tests__/ButtonLabel.test.tsx @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { waitFor, zeroRender as render } from "@/testing/test-utils" + +import { ButtonLabel } from "../ButtonLabel" + +describe("ButtonLabel", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("reports when the visible label is truncated", async () => { + vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(200) + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100) + const onOverflowChange = vi.fn() + + render( + + ) + + await waitFor(() => { + expect(onOverflowChange).toHaveBeenCalledWith(true) + }) + }) +}) diff --git a/packages/react/src/components/F0Button/internal.tsx b/packages/react/src/components/F0Button/internal.tsx index 1e6ce15dd6..7c9ca8655d 100644 --- a/packages/react/src/components/F0Button/internal.tsx +++ b/packages/react/src/components/F0Button/internal.tsx @@ -3,7 +3,6 @@ import { forwardRef, useState } from "react" import { F0Icon } from "@/components/F0Icon" import { EmojiImage } from "@/lib/emojis" -import { OneEllipsis } from "@/lib/OneEllipsis" import { useTextFormatEnforcer } from "@/lib/text" import { cn } from "@/lib/utils" import { Action } from "@/ui/Action" @@ -11,6 +10,7 @@ import { Counter } from "@/ui/Counter" import { ButtonInternalProps } from "./internal-types" import { fontSizeVariants } from "./variants" +import { ButtonLabel } from "./components/ButtonLabel" const IconMotion = motion.create(F0Icon) @@ -56,6 +56,7 @@ const ButtonInternal = forwardRef< const [loading, setLoading] = useState(false) const [isHovered, setIsHovered] = useState(false) + const [labelOverflows, setLabelOverflows] = useState(false) const handleClick = async ( event: React.MouseEvent @@ -87,6 +88,9 @@ const ButtonInternal = forwardRef< const counterIsDark = variant === "default" || (variant === "critical" && isHovered) const buttonFontSize = fontSize ?? size + const autoTooltip = noAutoTooltip + ? undefined + : (hideLabel && label) || (labelOverflows && label) || "" const iconNode = icon ? ( iconRotate ? ( @@ -127,7 +131,10 @@ const ButtonInternal = forwardRef< disabled={disabled || isLoading} ref={ref} {...props} - tooltip={tooltip ?? (!noAutoTooltip && hideLabel && label)} + tooltip={ + tooltip ?? + (autoTooltip !== undefined ? { description: autoTooltip } : undefined) + } onClick={handleClick} loading={isLoading} className={cn( @@ -179,15 +186,14 @@ const ButtonInternal = forwardRef< /> )} {!shouldHideLabel ? ( - - {buttonLabel} - + label={buttonLabel} + onOverflowChange={setLabelOverflows} + /> ) : ( {buttonLabel} )} diff --git a/packages/react/src/components/F0Card/CardInternal.tsx b/packages/react/src/components/F0Card/CardInternal.tsx index 33e47249cc..42e867a3f7 100644 --- a/packages/react/src/components/F0Card/CardInternal.tsx +++ b/packages/react/src/components/F0Card/CardInternal.tsx @@ -13,7 +13,7 @@ import { } from "@/ui/Card" import { Skeleton } from "@/ui/skeleton" -import { OneEllipsis } from "@/lib/OneEllipsis/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { CardActions, type CardPrimaryAction, diff --git a/packages/react/src/components/F0Card/__tests__/Card.test.tsx b/packages/react/src/components/F0Card/__tests__/Card.test.tsx index 2ac5988623..32fc50bee5 100644 --- a/packages/react/src/components/F0Card/__tests__/Card.test.tsx +++ b/packages/react/src/components/F0Card/__tests__/Card.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest" -import "@testing-library/jest-dom/vitest" + import { Briefcase } from "@/icons/app" import { zeroRender as render, @@ -7,12 +7,22 @@ import { userEvent, waitFor, } from "@/testing/test-utils" +import { valueDisplayRenderers } from "@/ui/value-display" import type { CardSecondaryLink } from "../components/CardActions" +import { cardPropertyRenderers } from "../components/CardMetadata" import { F0Card } from "../F0Card" describe("F0Card Component", () => { + it("keeps every supported metadata renderer aligned with the shared registry", () => { + for (const [type, renderer] of Object.entries(cardPropertyRenderers)) { + expect(renderer).toBe( + valueDisplayRenderers[type as keyof typeof cardPropertyRenderers] + ) + } + }) + it("renders title and description correctly", () => { render( diff --git a/packages/react/src/components/F0Card/components/CardMetadata.tsx b/packages/react/src/components/F0Card/components/CardMetadata.tsx index 3ea79d324f..67fe015526 100644 --- a/packages/react/src/components/F0Card/components/CardMetadata.tsx +++ b/packages/react/src/components/F0Card/components/CardMetadata.tsx @@ -1,28 +1,43 @@ -import React from "react" +import type React from "react" import { F0Icon } from "@/components/F0Icon" import { Tooltip } from "@/experimental/Overlays/Tooltip" -import { valueDisplayRenderers } from "@/ui/value-display" +import { AlertTagCell } from "@/ui/value-display/types/alertTag" +import { AmountCell } from "@/ui/value-display/types/amount" +import { AvatarListCell } from "@/ui/value-display/types/avatarList" +import { CompanyCell } from "@/ui/value-display/types/company" +import { DateCell } from "@/ui/value-display/types/date" +import { DotTagCell } from "@/ui/value-display/types/dotTag" +import { FileCell } from "@/ui/value-display/types/file" +import { FolderCell } from "@/ui/value-display/types/folder" +import { NumberCell } from "@/ui/value-display/types/number" +import { PersonCell } from "@/ui/value-display/types/person" +import { ProgressBarCell } from "@/ui/value-display/types/progressBar" +import { StatusCell } from "@/ui/value-display/types/status" +import { TagCell } from "@/ui/value-display/types/tag" +import { TagListCell } from "@/ui/value-display/types/tagList" +import { TeamCell } from "@/ui/value-display/types/team" +import { TextCell } from "@/ui/value-display/types/text" -import { CardMetadata as CardMetadataType } from "../types" +import type { CardMetadata as CardMetadataType } from "../types" export const cardPropertyRenderers = { - text: valueDisplayRenderers.text, - number: valueDisplayRenderers.number, - date: valueDisplayRenderers.date, - amount: valueDisplayRenderers.amount, - person: valueDisplayRenderers.person, - company: valueDisplayRenderers.company, - team: valueDisplayRenderers.team, - status: valueDisplayRenderers.status, - tag: valueDisplayRenderers.tag, - avatarList: valueDisplayRenderers.avatarList, - tagList: valueDisplayRenderers.tagList, - alertTag: valueDisplayRenderers.alertTag, - dotTag: valueDisplayRenderers.dotTag, - file: valueDisplayRenderers.file, - folder: valueDisplayRenderers.folder, - progressBar: valueDisplayRenderers.progressBar, + text: TextCell, + number: NumberCell, + date: DateCell, + amount: AmountCell, + person: PersonCell, + company: CompanyCell, + team: TeamCell, + status: StatusCell, + tag: TagCell, + avatarList: AvatarListCell, + tagList: TagListCell, + alertTag: AlertTagCell, + dotTag: DotTagCell, + file: FileCell, + folder: FolderCell, + progressBar: ProgressBarCell, } as const export type CardPropertyType = keyof typeof cardPropertyRenderers diff --git a/packages/react/src/components/F0Card/types.ts b/packages/react/src/components/F0Card/types.ts index 720227c1e8..55ca4944cb 100644 --- a/packages/react/src/components/F0Card/types.ts +++ b/packages/react/src/components/F0Card/types.ts @@ -1,7 +1,7 @@ -import { IconType } from "@/components/F0Icon" -import { valueDisplayRenderers } from "@/ui/value-display" +import type { IconType } from "@/components/F0Icon" +import type { valueDisplayRenderers } from "@/ui/value-display" -import { CardPropertyType } from "./components/CardMetadata" +import type { CardPropertyType } from "./components/CardMetadata" export const cardAlertVariants = [ "info", diff --git a/packages/react/src/components/F0InputField/AppendTag.tsx b/packages/react/src/components/F0InputField/AppendTag.tsx index be27881959..045e6ad07c 100644 --- a/packages/react/src/components/F0InputField/AppendTag.tsx +++ b/packages/react/src/components/F0InputField/AppendTag.tsx @@ -1,4 +1,4 @@ -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" export const AppendTag = ({ text }: { text: string }) => { diff --git a/packages/react/src/components/F0InputField/components/Label.tsx b/packages/react/src/components/F0InputField/components/Label.tsx index 551c53833e..9027ebf817 100644 --- a/packages/react/src/components/F0InputField/components/Label.tsx +++ b/packages/react/src/components/F0InputField/components/Label.tsx @@ -1,5 +1,5 @@ import { F0Icon, type IconType } from "@/components/F0Icon" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" type LabelProps = { diff --git a/packages/react/src/components/F0Select/F0Select.tsx b/packages/react/src/components/F0Select/F0Select.tsx index f923e5c2d2..80ef80d4c3 100644 --- a/packages/react/src/components/F0Select/F0Select.tsx +++ b/packages/react/src/components/F0Select/F0Select.tsx @@ -1,7 +1,7 @@ import { useComposedRefs } from "@radix-ui/react-compose-refs" import { useDeepCompareEffect } from "@reactuses/core" import { cva } from "cva" -import { isEqual } from "lodash" +import isEqual from "lodash/isEqual.js" import { forwardRef, useCallback, @@ -49,7 +49,7 @@ import { SelectTrigger, VirtualItem, } from "@/ui/Select" -import { textVariants } from "@/ui/Text" +import { textVariants } from "@/ui/Text/variants" import type { ActiveFiltersChips as ActiveFiltersChipsComponent } from "./components/ActiveFiltersChips" import type { diff --git a/packages/react/src/components/F0Select/components/SelectAll.tsx b/packages/react/src/components/F0Select/components/SelectAll.tsx index 51b922eb48..864abddc17 100644 --- a/packages/react/src/components/F0Select/components/SelectAll.tsx +++ b/packages/react/src/components/F0Select/components/SelectAll.tsx @@ -1,7 +1,7 @@ import { ButtonInternal } from "@/components/F0Button/internal" import { F0Checkbox } from "@/components/F0Checkbox" import { Await } from "@/lib/Await" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" import { Skeleton } from "@/ui/skeleton" diff --git a/packages/react/src/components/F0Select/components/SelectItem.tsx b/packages/react/src/components/F0Select/components/SelectItem.tsx index d10e0041cf..8cba75d646 100644 --- a/packages/react/src/components/F0Select/components/SelectItem.tsx +++ b/packages/react/src/components/F0Select/components/SelectItem.tsx @@ -4,7 +4,7 @@ import { F0TagDot } from "@/components/tags/F0TagDot" import { F0TagPerson } from "@/components/tags/F0TagPerson" import { F0TagRaw } from "@/components/tags/F0TagRaw" import { F0TagStatus } from "@/components/tags/F0TagStatus" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { SelectItem as SelectItemPrimitive } from "@/ui/Select" import { F0SelectItemMetadata, F0SelectItemObject } from "../types" diff --git a/packages/react/src/components/F0Select/components/SelectedItems.tsx b/packages/react/src/components/F0Select/components/SelectedItems.tsx index 58af474123..df2cef0048 100644 --- a/packages/react/src/components/F0Select/components/SelectedItems.tsx +++ b/packages/react/src/components/F0Select/components/SelectedItems.tsx @@ -3,7 +3,7 @@ import { forwardRef } from "react" import { F0Avatar } from "@/components/avatars/F0Avatar" import { F0Icon } from "@/components/F0Icon" import { F0TagStatus } from "@/components/tags/F0TagStatus" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import type { F0SelectItemObject } from "../types" diff --git a/packages/react/src/components/F0Select/components/SelectionPreview.tsx b/packages/react/src/components/F0Select/components/SelectionPreview.tsx index 24f2fe4f29..2af2dacbca 100644 --- a/packages/react/src/components/F0Select/components/SelectionPreview.tsx +++ b/packages/react/src/components/F0Select/components/SelectionPreview.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef } from "react" import { F0Avatar } from "@/components/avatars/F0Avatar" import { F0Icon } from "@/components/F0Icon" import { CrossedCircle } from "@/icons/app" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { cn, focusRing } from "@/lib/utils" import { ScrollArea } from "@/ui/scrollarea" diff --git a/packages/react/src/components/avatars/F0AvatarFlag/F0AvatarFlag.tsx b/packages/react/src/components/avatars/F0AvatarFlag/F0AvatarFlag.tsx index a86ee6ae0e..9b9192c1e3 100644 --- a/packages/react/src/components/avatars/F0AvatarFlag/F0AvatarFlag.tsx +++ b/packages/react/src/components/avatars/F0AvatarFlag/F0AvatarFlag.tsx @@ -1,9 +1,9 @@ import { getFlag } from "@/flags/flagsMap.tsx" -import { CountryCode } from "@/lib/countries" +import type { CountryCode } from "@/lib/countries" import { useI18n } from "@/lib/providers/i18n" import { BaseAvatar } from "../internal/BaseAvatar" -import { F0AvatarFlagProps } from "./types" +import type { F0AvatarFlagProps } from "./types" export const F0AvatarFlag = ({ flag, diff --git a/packages/react/src/components/tags/internal/BaseTag/index.tsx b/packages/react/src/components/tags/internal/BaseTag/index.tsx index 3494976506..4473f84ae4 100644 --- a/packages/react/src/components/tags/internal/BaseTag/index.tsx +++ b/packages/react/src/components/tags/internal/BaseTag/index.tsx @@ -1,7 +1,7 @@ import { forwardRef, ReactNode } from "react" import { F0Icon } from "@/components/F0Icon" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { Tooltip } from "@/experimental/Overlays/Tooltip" import { InfoCircleLine } from "@/icons/app" import { cn } from "@/lib/utils" diff --git a/packages/react/src/deprecated/EntitySelect/Trigger/index.tsx b/packages/react/src/deprecated/EntitySelect/Trigger/index.tsx index 5eb6fc140d..011e9a2fae 100644 --- a/packages/react/src/deprecated/EntitySelect/Trigger/index.tsx +++ b/packages/react/src/deprecated/EntitySelect/Trigger/index.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react" import { Arrow } from "@/components/F0Select/components/Arrow" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" import { F0InputField, InputFieldProps } from "@/components/F0InputField" diff --git a/packages/react/src/experimental/Navigation/F0TableOfContent/TOCFooter/index.tsx b/packages/react/src/experimental/Navigation/F0TableOfContent/TOCFooter/index.tsx index aad8b2ed04..5b28ba33d2 100644 --- a/packages/react/src/experimental/Navigation/F0TableOfContent/TOCFooter/index.tsx +++ b/packages/react/src/experimental/Navigation/F0TableOfContent/TOCFooter/index.tsx @@ -1,5 +1,5 @@ import { F0Icon } from "@/components/F0Icon" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" import { TOCAction } from "../types" diff --git a/packages/react/src/kits/F0DataChart/components/FunnelChart/FunnelChart.tsx b/packages/react/src/kits/F0DataChart/components/FunnelChart/FunnelChart.tsx index 2e0a5d6dfa..0ae3af0ba2 100644 --- a/packages/react/src/kits/F0DataChart/components/FunnelChart/FunnelChart.tsx +++ b/packages/react/src/kits/F0DataChart/components/FunnelChart/FunnelChart.tsx @@ -1,6 +1,6 @@ import { useRef } from "react" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { Tag } from "@/components/tags/F0Tag/F0Tag" import type { diff --git a/packages/react/src/kits/ai/Banners/F0AiBanner/AiBannerInternal.tsx b/packages/react/src/kits/ai/Banners/F0AiBanner/AiBannerInternal.tsx index 43d573ef50..3bdfece330 100644 --- a/packages/react/src/kits/ai/Banners/F0AiBanner/AiBannerInternal.tsx +++ b/packages/react/src/kits/ai/Banners/F0AiBanner/AiBannerInternal.tsx @@ -1,7 +1,7 @@ import { forwardRef } from "react" import { F0Button } from "@/components/F0Button" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { F0RichTextDisplay } from "@/components/RichText/F0RichTextDisplay" import { Cross } from "@/icons/app" import { cn } from "@/lib/utils" diff --git a/packages/react/src/kits/ai/Banners/F0Callout/CalloutInternal.tsx b/packages/react/src/kits/ai/Banners/F0Callout/CalloutInternal.tsx index b244459a7d..a14593f928 100644 --- a/packages/react/src/kits/ai/Banners/F0Callout/CalloutInternal.tsx +++ b/packages/react/src/kits/ai/Banners/F0Callout/CalloutInternal.tsx @@ -3,7 +3,7 @@ import { forwardRef } from "react" import { F0Button } from "@/components/F0Button" import { F0Icon, IconType } from "@/components/F0Icon" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { CheckCircle, Cross, InfoCircle, Warning } from "@/icons/app" import { cn } from "@/lib/utils" import { Skeleton } from "@/ui/skeleton" diff --git a/packages/react/src/kits/ai/F0AiChat/components/markdownRenderers/components/Table.tsx b/packages/react/src/kits/ai/F0AiChat/components/markdownRenderers/components/Table.tsx index 42cceebaa3..65bab4434b 100644 --- a/packages/react/src/kits/ai/F0AiChat/components/markdownRenderers/components/Table.tsx +++ b/packages/react/src/kits/ai/F0AiChat/components/markdownRenderers/components/Table.tsx @@ -2,7 +2,7 @@ import { useCallback, useRef } from "react" import { Dropdown } from "@/experimental/Navigation/Dropdown" import { Download } from "@/icons/app" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" diff --git a/packages/react/src/kits/ai/F0AiChatHeader/F0AiChatHeader.tsx b/packages/react/src/kits/ai/F0AiChatHeader/F0AiChatHeader.tsx index 1228a6992c..6aa67f48a7 100644 --- a/packages/react/src/kits/ai/F0AiChatHeader/F0AiChatHeader.tsx +++ b/packages/react/src/kits/ai/F0AiChatHeader/F0AiChatHeader.tsx @@ -11,7 +11,7 @@ import Cross from "@/icons/app/Cross" import Maximize from "@/icons/app/Maximize" import Minimize from "@/icons/app/Minimize" import { useReducedMotion } from "@/lib/a11y" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" import { Action } from "@/ui/Action" diff --git a/packages/react/src/kits/ai/F0AiChatHeader/components/CreditsPopover.tsx b/packages/react/src/kits/ai/F0AiChatHeader/components/CreditsPopover.tsx index f74f2b3db3..80938ca500 100644 --- a/packages/react/src/kits/ai/F0AiChatHeader/components/CreditsPopover.tsx +++ b/packages/react/src/kits/ai/F0AiChatHeader/components/CreditsPopover.tsx @@ -5,7 +5,7 @@ import { F0AvatarCompany } from "@/components/avatars/F0AvatarCompany" import { F0Button } from "@/components/F0Button" import { ButtonInternal } from "@/components/F0Button/internal" import { Sliders, Upsell } from "@/icons/app" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover" diff --git a/packages/react/src/kits/ai/F0AiChatHeader/components/EmployeeCreditsPopover.tsx b/packages/react/src/kits/ai/F0AiChatHeader/components/EmployeeCreditsPopover.tsx index 9242783041..fb98a7434e 100644 --- a/packages/react/src/kits/ai/F0AiChatHeader/components/EmployeeCreditsPopover.tsx +++ b/packages/react/src/kits/ai/F0AiChatHeader/components/EmployeeCreditsPopover.tsx @@ -5,7 +5,7 @@ import { F0AvatarCompany } from "@/components/avatars/F0AvatarCompany" import { ButtonInternal } from "@/components/F0Button/internal" import { Sliders } from "@/icons/app" import { useReducedMotion } from "@/lib/a11y" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover" diff --git a/packages/react/src/kits/ai/F0AiChatHistory/F0AiChatHistory.tsx b/packages/react/src/kits/ai/F0AiChatHistory/F0AiChatHistory.tsx index 014b6def5b..8d7383eac0 100644 --- a/packages/react/src/kits/ai/F0AiChatHistory/F0AiChatHistory.tsx +++ b/packages/react/src/kits/ai/F0AiChatHistory/F0AiChatHistory.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { createPortal } from "react-dom" import { F0Icon } from "@/components/F0Icon" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import New from "@/icons/app/New" import Search from "@/icons/app/Search" import { useI18n } from "@/lib/providers/i18n" diff --git a/packages/react/src/kits/ai/F0AiChatHistory/components/ThreadItem.tsx b/packages/react/src/kits/ai/F0AiChatHistory/components/ThreadItem.tsx index 3fbb295a16..1bd81f0e9b 100644 --- a/packages/react/src/kits/ai/F0AiChatHistory/components/ThreadItem.tsx +++ b/packages/react/src/kits/ai/F0AiChatHistory/components/ThreadItem.tsx @@ -10,7 +10,7 @@ import { Ellipsis } from "@/icons/app" import Delete from "@/icons/app/Delete" import PushPin from "@/icons/app/PushPin" import PushPinSolid from "@/icons/app/PushPinSolid" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { useDateFnsLocale } from "@/lib/providers/l10n" import { cn, focusRing } from "@/lib/utils" diff --git a/packages/react/src/kits/ai/F0AiChatTextArea/F0AiChatTextArea.tsx b/packages/react/src/kits/ai/F0AiChatTextArea/F0AiChatTextArea.tsx index 68cfc5f278..431db85182 100644 --- a/packages/react/src/kits/ai/F0AiChatTextArea/F0AiChatTextArea.tsx +++ b/packages/react/src/kits/ai/F0AiChatTextArea/F0AiChatTextArea.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { F0AvatarAlert } from "@/components/avatars/F0AvatarAlert" import { useReducedMotion } from "@/lib/a11y" import { Link } from "@/lib/linkHandler" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" diff --git a/packages/react/src/kits/ai/F0AiChatTextArea/components/MentionPopover.tsx b/packages/react/src/kits/ai/F0AiChatTextArea/components/MentionPopover.tsx index b2dcdaeef6..b82b480ca8 100644 --- a/packages/react/src/kits/ai/F0AiChatTextArea/components/MentionPopover.tsx +++ b/packages/react/src/kits/ai/F0AiChatTextArea/components/MentionPopover.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useRef } from "react" import { F0AvatarPerson } from "@/components/avatars/F0AvatarPerson/F0AvatarPerson" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" import { Skeleton } from "@/ui/skeleton" diff --git a/packages/react/src/kits/ai/F0AiTableCard/F0AiTableCard.tsx b/packages/react/src/kits/ai/F0AiTableCard/F0AiTableCard.tsx index cca262a52d..408557817c 100644 --- a/packages/react/src/kits/ai/F0AiTableCard/F0AiTableCard.tsx +++ b/packages/react/src/kits/ai/F0AiTableCard/F0AiTableCard.tsx @@ -3,7 +3,7 @@ import { useCallback, useRef } from "react" import { Dropdown } from "@/experimental/Navigation/Dropdown" import { Download } from "@/icons/app" import { F0Box } from "@/lib/F0Box" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" diff --git a/packages/react/src/kits/ai/F0ClarifyingPanel/components/StepHeader.tsx b/packages/react/src/kits/ai/F0ClarifyingPanel/components/StepHeader.tsx index 562c2b4fc2..1105d08143 100644 --- a/packages/react/src/kits/ai/F0ClarifyingPanel/components/StepHeader.tsx +++ b/packages/react/src/kits/ai/F0ClarifyingPanel/components/StepHeader.tsx @@ -1,6 +1,6 @@ import { F0Button } from "@/components/F0Button" import { ChevronLeft, ChevronRight, Cross } from "@/icons/app" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" interface StepHeaderProps { diff --git a/packages/react/src/kits/ai/canvas/F0CanvasCard/F0CanvasCard.tsx b/packages/react/src/kits/ai/canvas/F0CanvasCard/F0CanvasCard.tsx index a72f1822ab..b725b37505 100644 --- a/packages/react/src/kits/ai/canvas/F0CanvasCard/F0CanvasCard.tsx +++ b/packages/react/src/kits/ai/canvas/F0CanvasCard/F0CanvasCard.tsx @@ -7,7 +7,7 @@ import type { FileDef } from "@/components/avatars/F0AvatarFile/types" import { F0AvatarIcon } from "@/components/avatars/F0AvatarIcon" import { F0Button } from "@/components/F0Button" import type { IconType } from "@/components/F0Icon" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" diff --git a/packages/react/src/lib/OneEllipsis/OneEllipsis.test.tsx b/packages/react/src/lib/OneEllipsis/OneEllipsis.test.tsx index e7b8e174fc..48e74c87c7 100644 --- a/packages/react/src/lib/OneEllipsis/OneEllipsis.test.tsx +++ b/packages/react/src/lib/OneEllipsis/OneEllipsis.test.tsx @@ -1,6 +1,12 @@ -import { act, render, screen } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + act, + screen, + userEvent, + zeroRender as render, +} from "@/testing/test-utils" + import { OneEllipsis } from "./OneEllipsis" describe("OneEllipsis", () => { @@ -77,7 +83,7 @@ describe("OneEllipsis", () => { expect(screen.getByTestId("one-ellipsis")).toBeInTheDocument() }) - it("renders text with ellipsis and tooltip when content overflows", () => { + it("renders text with ellipsis and tooltip when content overflows", async () => { // Mock element dimensions for overflow Object.defineProperty(HTMLElement.prototype, "scrollWidth", { configurable: true, @@ -88,6 +94,7 @@ describe("OneEllipsis", () => { value: 100, }) + const user = userEvent.setup() render( This is a very long text that should definitely overflow and show an @@ -101,6 +108,11 @@ describe("OneEllipsis", () => { ) ).toBeInTheDocument() expect(screen.getByTestId("one-ellipsis")).toBeInTheDocument() + + await user.hover(screen.getByTestId("one-ellipsis")) + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "This is a very long text that should definitely overflow and show an ellipsis" + ) }) it("supports multiple lines", () => { @@ -127,7 +139,9 @@ describe("OneEllipsis", () => { ) ).toBeInTheDocument() - expect(screen.getByTestId("one-ellipsis")).toBeInTheDocument() + expect(screen.getByTestId("one-ellipsis").className).toContain( + "pointer-events-auto" + ) }) it("updates ellipsis state when size changes", () => { @@ -157,10 +171,11 @@ describe("OneEllipsis", () => { }) // Trigger resize observer callback - resizeCallback?.() + act(() => resizeCallback?.()) - // Now tooltip should be present - expect(screen.getByTestId("one-ellipsis")).toBeInTheDocument() + expect(screen.getByTestId("one-ellipsis").className).toContain( + "pointer-events-auto" + ) // Simulate resize back to no overflow Object.defineProperty(HTMLElement.prototype, "scrollWidth", { @@ -173,10 +188,31 @@ describe("OneEllipsis", () => { }) // Trigger resize observer callback again - resizeCallback?.() + act(() => resizeCallback?.()) - // Tooltip should be gone again - expect(screen.getByTestId("one-ellipsis")).toBeInTheDocument() + expect(screen.getByTestId("one-ellipsis").className).not.toContain( + "pointer-events-auto" + ) + }) + + it("does not truncate or show a tooltip when disabled", () => { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + value: 200, + }) + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + value: 100, + }) + + render(Unclamped text) + + expect(screen.getByTestId("one-ellipsis").className).not.toContain( + "text-ellipsis" + ) + expect(screen.getByTestId("one-ellipsis").className).not.toContain( + "pointer-events-auto" + ) }) it("keeps the ellipsized text hoverable (pointer-events-auto) so its tooltip is reachable inside a pointer-events-none container", () => { diff --git a/packages/react/src/lib/OneEllipsis/OneEllipsis.tsx b/packages/react/src/lib/OneEllipsis/OneEllipsis.tsx index 19cf1a02ab..d6d5f16f41 100644 --- a/packages/react/src/lib/OneEllipsis/OneEllipsis.tsx +++ b/packages/react/src/lib/OneEllipsis/OneEllipsis.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useEffect, useMemo, useRef, useState } from "react" +import React, { forwardRef, useMemo, useRef, useState } from "react" import { parseMarkdown, stripMarkdown } from "@/lib/markdown" import { cn } from "@/lib/utils" @@ -9,31 +9,10 @@ import { TooltipTrigger, } from "@/ui/tooltip" -export const tags = [ - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "p", - "span", - "div", - "label", - "code", -] as const -export type Tag = (typeof tags)[number] +import type { Tag } from "./types" +import { useEllipsisOverflow } from "./use-ellipsis-overflow" -const checkForEllipsis = (element: HTMLElement | null, lines: number) => { - if (!element) return false - if (lines > 1) { - // For multi-line, check if content height exceeds line-clamp height - const lineHeight = parseInt(window.getComputedStyle(element).lineHeight) - return element.scrollHeight > lineHeight * lines - } - // For single line, check if content width exceeds container width - return element.scrollWidth > element.clientWidth -} +export { type Tag, tags } from "./types" type EllipsisWrapperProps = { children: string @@ -69,50 +48,12 @@ const EllipsisWrapper = forwardRef( }, ref ) => { - const [hasEllipsis, setHasEllipsis] = useState(false) - - useEffect(() => { - if (!ref || typeof ref !== "object" || disabled) return - - const element = ref.current - if (!element) return - - /** - * Finds the ellipsis state of the element and sets the state and emits the change - * @returns The ellipsis state of the element - */ - const findAndSetEllipsisState = () => { - const ellipsis = checkForEllipsis(element, lines) - setHasEllipsis(ellipsis) - onHasEllipsisChange(ellipsis) - return ellipsis - } - - // Initial check - findAndSetEllipsisState() - - // Re-check after the next layout. When this text lives in a flex row that - // an ancestor width-constrains only on a later pass (e.g. an OverflowList - // inside a table cell), the element can mount at its natural width and - // shrink afterwards — a transition the ResizeObserver below sometimes - // misses, leaving `hasEllipsis` (and thus the tooltip) stale-false while - // the text is visibly clipped. A post-layout re-measure catches it. - const raf = requestAnimationFrame(() => findAndSetEllipsisState()) - const timeout = setTimeout(() => findAndSetEllipsisState(), 100) - - // Set up resize observer - const resizeObserver = new ResizeObserver(() => { - findAndSetEllipsisState() - }) - - resizeObserver.observe(element) - - return () => { - cancelAnimationFrame(raf) - clearTimeout(timeout) - resizeObserver.disconnect() - } - }, [ref, onHasEllipsisChange, lines, disabled]) + const hasEllipsis = useEllipsisOverflow({ + disabled: disabled ?? false, + lines, + onChange: onHasEllipsisChange, + ref: ref && typeof ref === "object" ? ref : null, + }) const html = markdown ? parseMarkdown(children) : undefined diff --git a/packages/react/src/lib/OneEllipsis/PlainEllipsis.test.tsx b/packages/react/src/lib/OneEllipsis/PlainEllipsis.test.tsx new file mode 100644 index 0000000000..0f51bb593a --- /dev/null +++ b/packages/react/src/lib/OneEllipsis/PlainEllipsis.test.tsx @@ -0,0 +1,179 @@ +import { createRef } from "react" + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { + act, + screen, + userEvent, + waitFor, + zeroRender as render, +} from "@/testing/test-utils" + +import { PlainEllipsis } from "./PlainEllipsis" + +describe("PlainEllipsis", () => { + let resizeCallbacks: (() => void)[] + const disconnect = vi.fn() + + beforeEach(() => { + resizeCallbacks = [] + class MockResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.push(() => callback([], this)) + } + + observe = vi.fn() + unobserve = vi.fn() + disconnect = disconnect + } + + window.ResizeObserver = MockResizeObserver as typeof ResizeObserver + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + lineHeight: "20px", + } as CSSStyleDeclaration) + }) + + afterEach(() => { + delete (HTMLElement.prototype as { scrollWidth?: number }).scrollWidth + delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth + delete (HTMLElement.prototype as { scrollHeight?: number }).scrollHeight + vi.restoreAllMocks() + disconnect.mockReset() + }) + + it("renders markdown syntax as plain text", () => { + render(**literal label**) + + expect(screen.getByText("**literal label**")).toBeInTheDocument() + expect(screen.queryByRole("strong")).not.toBeInTheDocument() + }) + + it("keeps observing the same text element as overflow appears and clears", async () => { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + value: 200, + }) + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + value: 100, + }) + + const user = userEvent.setup() + render(A label that overflows) + const text = screen.getByTestId("one-ellipsis") + + await waitFor(() => { + expect(text.className).toContain("pointer-events-auto") + }) + expect(screen.getByTestId("one-ellipsis")).toBe(text) + + await user.hover(text) + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "A label that overflows" + ) + + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + value: 100, + }) + act(() => resizeCallbacks.forEach((callback) => callback())) + + await waitFor(() => { + expect(text.className).not.toContain("pointer-events-auto") + }) + expect(screen.getByTestId("one-ellipsis")).toBe(text) + }) + + it("supports multiline overflow measurement", async () => { + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + value: 100, + }) + + render(A multiline label) + + await waitFor(() => { + expect(screen.getByTestId("one-ellipsis").className).toContain( + "pointer-events-auto" + ) + }) + }) + + it("does not enable a tooltip when noTooltip is set", async () => { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + value: 200, + }) + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + value: 100, + }) + + const user = userEvent.setup() + render(Overflow without tooltip) + + await waitFor(() => { + expect(screen.getByTestId("one-ellipsis").className).not.toContain( + "pointer-events-auto" + ) + }) + await user.hover(screen.getByTestId("one-ellipsis")) + await expect( + screen.findByRole("tooltip", undefined, { timeout: 500 }) + ).rejects.toThrow() + }) + + it("clears an existing overflow state when disabled", async () => { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + value: 200, + }) + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + value: 100, + }) + + const { rerender } = render( + Overflow that becomes disabled + ) + + await waitFor(() => { + expect(screen.getByTestId("one-ellipsis").className).toContain( + "pointer-events-auto" + ) + }) + + rerender( + Overflow that becomes disabled + ) + + await waitFor(() => { + expect(screen.getByTestId("one-ellipsis").className).not.toContain( + "pointer-events-auto" + ) + }) + }) + + it("supports disabled truncation, custom tags, and forwarded refs", () => { + const ref = createRef() + + render( + + Unclamped paragraph + + ) + + expect(ref.current).toBe(screen.getByTestId("one-ellipsis")) + expect(ref.current?.tagName).toBe("P") + expect(ref.current?.className).not.toContain("text-ellipsis") + }) + + it("disconnects its observer on unmount", () => { + const { unmount } = render(Observed label) + + unmount() + + expect(disconnect).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/react/src/lib/OneEllipsis/PlainEllipsis.tsx b/packages/react/src/lib/OneEllipsis/PlainEllipsis.tsx new file mode 100644 index 0000000000..f90d653381 --- /dev/null +++ b/packages/react/src/lib/OneEllipsis/PlainEllipsis.tsx @@ -0,0 +1,91 @@ +import React, { forwardRef, useRef } from "react" + +import { cn } from "@/lib/utils" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/ui/tooltip" + +import type { Tag } from "./types" +import { useEllipsisOverflow } from "./use-ellipsis-overflow" + +export interface PlainEllipsisProps { + children: string + className?: string + disabled?: boolean + lines?: number + noTooltip?: boolean + tag?: Tag +} + +const PlainEllipsis = forwardRef( + ( + { + children, + className, + disabled = false, + lines = 1, + noTooltip = false, + tag = "span", + ...props + }, + forwardedRef + ) => { + const internalRef = useRef(null) + const ref = forwardedRef || internalRef + const hasEllipsis = useEllipsisOverflow({ + disabled, + lines, + ref: typeof ref === "object" ? ref : null, + }) + + const text = React.createElement( + tag, + { + ref, + className: cn( + !noTooltip && hasEllipsis && "pointer-events-auto", + "min-w-0 max-w-full overflow-hidden", + !disabled && [ + lines === 1 ? "text-ellipsis" : "", + lines > 1 + ? `not-supports-[(-webkit-line-clamp:${lines})]:whitespace-nowrap line-clamp-1 whitespace-normal` + : "block whitespace-nowrap", + ], + className + ), + style: { + WebkitLineClamp: lines > 1 ? lines : undefined, + lineClamp: lines > 1 ? lines : undefined, + }, + ...props, + "data-testid": "one-ellipsis", + }, + children + ) + + const showTooltip = hasEllipsis && !noTooltip + + return ( + + + + {text} + + {showTooltip && ( + {children} + )} + + + ) + } +) + +PlainEllipsis.displayName = "PlainEllipsis" + +export { PlainEllipsis, PlainEllipsis as OneEllipsis } diff --git a/packages/react/src/lib/OneEllipsis/__stories__/OneEllipsis.stories.tsx b/packages/react/src/lib/OneEllipsis/__stories__/OneEllipsis.stories.tsx index f98beea260..70660be687 100644 --- a/packages/react/src/lib/OneEllipsis/__stories__/OneEllipsis.stories.tsx +++ b/packages/react/src/lib/OneEllipsis/__stories__/OneEllipsis.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite" import { useId, useState } from "react" +import { withSnapshot } from "@/lib/storybook-utils/parameters" + import { OneEllipsis, Tag, tags } from "../OneEllipsis" const meta = { @@ -175,3 +177,22 @@ export const ShortTextMultiLine: Story = { /> ), } + +export const Snapshot: Story = { + parameters: withSnapshot({}), + args: { + children: "Ellipsis snapshot", + }, + render: () => ( +
+ + A long single-line label that is intentionally constrained + + + A longer multi-line description that is intentionally constrained to + demonstrate line clamping in the consolidated snapshot. + + Short text +
+ ), +} diff --git a/packages/react/src/lib/OneEllipsis/index.tsx b/packages/react/src/lib/OneEllipsis/index.tsx index 5f0d6e24ee..1751dc3323 100644 --- a/packages/react/src/lib/OneEllipsis/index.tsx +++ b/packages/react/src/lib/OneEllipsis/index.tsx @@ -1 +1,2 @@ export * from "./OneEllipsis" +export { PlainEllipsis } from "./PlainEllipsis" diff --git a/packages/react/src/lib/OneEllipsis/types.ts b/packages/react/src/lib/OneEllipsis/types.ts new file mode 100644 index 0000000000..2c0dea2f7d --- /dev/null +++ b/packages/react/src/lib/OneEllipsis/types.ts @@ -0,0 +1,15 @@ +export const tags = [ + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "p", + "span", + "div", + "label", + "code", +] as const + +export type Tag = (typeof tags)[number] diff --git a/packages/react/src/lib/OneEllipsis/use-ellipsis-overflow.ts b/packages/react/src/lib/OneEllipsis/use-ellipsis-overflow.ts new file mode 100644 index 0000000000..f1744c193b --- /dev/null +++ b/packages/react/src/lib/OneEllipsis/use-ellipsis-overflow.ts @@ -0,0 +1,59 @@ +import { useEffect, useState, type RefObject } from "react" + +interface UseEllipsisOverflowOptions { + disabled: boolean + lines: number + onChange?: (hasEllipsis: boolean) => void + ref: RefObject | null +} + +const checkForEllipsis = (element: HTMLElement, lines: number) => { + if (lines > 1) { + const lineHeight = parseInt(window.getComputedStyle(element).lineHeight) + return element.scrollHeight > lineHeight * lines + } + + return element.scrollWidth > element.clientWidth +} + +export function useEllipsisOverflow({ + disabled, + lines, + onChange, + ref, +}: UseEllipsisOverflowOptions) { + const [hasEllipsis, setHasEllipsis] = useState(false) + + useEffect( + function observeEllipsis() { + const element = ref?.current + if (!element) return + if (disabled) { + setHasEllipsis(false) + onChange?.(false) + return + } + + const updateEllipsis = () => { + const nextHasEllipsis = checkForEllipsis(element, lines) + setHasEllipsis(nextHasEllipsis) + onChange?.(nextHasEllipsis) + } + + updateEllipsis() + const animationFrame = requestAnimationFrame(updateEllipsis) + const timeout = setTimeout(updateEllipsis, 100) + const resizeObserver = new ResizeObserver(updateEllipsis) + resizeObserver.observe(element) + + return () => { + cancelAnimationFrame(animationFrame) + clearTimeout(timeout) + resizeObserver.disconnect() + } + }, + [disabled, lines, onChange, ref] + ) + + return hasEllipsis +} diff --git a/packages/react/src/lib/emoji-confetti.ts b/packages/react/src/lib/emoji-confetti.ts new file mode 100644 index 0000000000..64ff6189c1 --- /dev/null +++ b/packages/react/src/lib/emoji-confetti.ts @@ -0,0 +1,37 @@ +import confetti from "canvas-confetti" +import { useCallback, type RefObject } from "react" + +import { useReducedMotion } from "./a11y" + +export const useEmojiConfetti = () => { + const shouldReduceMotion = useReducedMotion() + + const fireEmojiConfetti = useCallback( + (emoji: string, elementRef: RefObject) => { + const button = elementRef.current + if (button) { + const rect = button.getBoundingClientRect() + const centerX = rect.left + rect.width / 2 + const centerY = rect.top + + confetti({ + particleCount: 20, + gravity: 0, + spread: 360, + startVelocity: 10, + ticks: 50, + origin: { + x: centerX / window.innerWidth, + y: centerY / window.innerHeight, + }, + shapes: [confetti.shapeFromText({ text: emoji, scalar: 2 })], + scalar: 2, + disableForReducedMotion: shouldReduceMotion, + }) + } + }, + [shouldReduceMotion] + ) + + return { fireEmojiConfetti } +} diff --git a/packages/react/src/lib/emojis.tsx b/packages/react/src/lib/emojis.tsx index 1623970752..88513c8de3 100644 --- a/packages/react/src/lib/emojis.tsx +++ b/packages/react/src/lib/emojis.tsx @@ -1,10 +1,7 @@ -import confetti from "canvas-confetti" import { cva, type VariantProps } from "cva" import { motion } from "motion/react" -import { RefObject, useCallback } from "react" import { parse } from "twemoji-parser" -import { useReducedMotion } from "./a11y" import { cn } from "./utils" interface ParseObject { @@ -133,36 +130,3 @@ const parseEmoji = (emoji: string): ParseObject | null => { export function getEmojiLabel(emoji: string): string { return `${emoji} emoji` } - -export const useEmojiConfetti = () => { - const shouldReduceMotion = useReducedMotion() - - const fireEmojiConfetti = useCallback( - (emoji: string, elementRef: RefObject) => { - const button = elementRef.current - if (button) { - const rect = button.getBoundingClientRect() - const centerX = rect.left + rect.width / 2 - const centerY = rect.top - - confetti({ - particleCount: 20, - gravity: 0, - spread: 360, - startVelocity: 10, - ticks: 50, - origin: { - x: centerX / window.innerWidth, - y: centerY / window.innerHeight, - }, - shapes: [confetti.shapeFromText({ text: emoji, scalar: 2 })], - scalar: 2, - disableForReducedMotion: shouldReduceMotion, - }) - } - }, - [shouldReduceMotion] - ) - - return { fireEmojiConfetti } -} diff --git a/packages/react/src/lib/exports.ts b/packages/react/src/lib/exports.ts index 6da3227e1c..56b7f797a7 100644 --- a/packages/react/src/lib/exports.ts +++ b/packages/react/src/lib/exports.ts @@ -7,6 +7,7 @@ export { DndProvider } from "./dnd/context" export { useDndEvents, useDraggable, useDroppableList } from "./dnd/hooks" export * from "./dnd/types" export * from "./emojis" +export { useEmojiConfetti } from "./emoji-confetti" export { experimentalComponent as experimental } from "./experimental" export { OneEllipsis } from "./OneEllipsis" export { PrivacyModeProvider, usePrivacyMode } from "./privacyMode" diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardItem/DashboardItem.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardItem/DashboardItem.tsx index 872ff15328..97b9c976bf 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardItem/DashboardItem.tsx +++ b/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardItem/DashboardItem.tsx @@ -25,7 +25,7 @@ import { import { InfoHint, type InfoHintContent } from "@/lib/InfoHint" import { One as OneIcon } from "@/icons/ai" import { useAiChat } from "@/kits/ai/F0AiChat/providers/AiChatStateProvider" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" import { diff --git a/packages/react/src/patterns/Navigation/Sidebar/Chats/SidebarChatItem.tsx b/packages/react/src/patterns/Navigation/Sidebar/Chats/SidebarChatItem.tsx index 5c2b897955..ace57a3a0a 100644 --- a/packages/react/src/patterns/Navigation/Sidebar/Chats/SidebarChatItem.tsx +++ b/packages/react/src/patterns/Navigation/Sidebar/Chats/SidebarChatItem.tsx @@ -3,7 +3,7 @@ import { ButtonInternal } from "@/components/F0Button/internal" import { F0Icon } from "@/components/F0Icon/F0Icon" import { PushPin, PushPinSolid } from "@/icons/app" import { EmojiImage } from "@/lib/emojis" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { cn, focusRing } from "@/lib/utils" import { Spinner } from "@/ui/Spinner" diff --git a/packages/react/src/patterns/Navigation/Sidebar/CompanySelector/index.tsx b/packages/react/src/patterns/Navigation/Sidebar/CompanySelector/index.tsx index 7dd250ca40..5e354edf31 100644 --- a/packages/react/src/patterns/Navigation/Sidebar/CompanySelector/index.tsx +++ b/packages/react/src/patterns/Navigation/Sidebar/CompanySelector/index.tsx @@ -5,7 +5,7 @@ import { AvatarVariant } from "@/components/avatars/F0Avatar" import { F0AvatarCompany } from "@/components/avatars/F0AvatarCompany" import { F0Icon, IconType } from "@/components/F0Icon" import { F0Select } from "@/components/F0Select" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { ChevronDown, Circle } from "@/icons/app" import { useI18n } from "@/lib/providers/i18n" import { cn, focusRing } from "@/lib/utils" diff --git a/packages/react/src/patterns/Navigation/Sidebar/Footer/index.tsx b/packages/react/src/patterns/Navigation/Sidebar/Footer/index.tsx index ce2edefb64..0a7c2b8026 100644 --- a/packages/react/src/patterns/Navigation/Sidebar/Footer/index.tsx +++ b/packages/react/src/patterns/Navigation/Sidebar/Footer/index.tsx @@ -1,6 +1,6 @@ import { F0AvatarPerson } from "@/components/avatars/F0AvatarPerson" import { F0Button } from "@/components/F0Button" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { Badge } from "@/ui/IconBadge" import { Tooltip } from "@/experimental/Overlays/Tooltip" import { Bell as BellIcon, Circle as CircleIcon } from "@/icons/app" diff --git a/packages/react/src/patterns/Navigation/Sidebar/Menu/index.tsx b/packages/react/src/patterns/Navigation/Sidebar/Menu/index.tsx index 1bfc93ea57..f2a3a3476d 100644 --- a/packages/react/src/patterns/Navigation/Sidebar/Menu/index.tsx +++ b/packages/react/src/patterns/Navigation/Sidebar/Menu/index.tsx @@ -11,7 +11,7 @@ import { import { AvatarVariant, F0Avatar } from "@/components/avatars/F0Avatar" import { F0Icon, IconType } from "@/components/F0Icon" import { F0TagRaw } from "@/components/tags/F0TagRaw" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { Counter } from "@/ui/Counter" import { Dropdown, DropdownItem } from "@/experimental/Navigation/Dropdown" import { NavigationItem } from "@/experimental/Navigation/utils" diff --git a/packages/react/src/patterns/OneDataCollection/components/ActionBar/OneDataCollectionActionBar.tsx b/packages/react/src/patterns/OneDataCollection/components/ActionBar/OneDataCollectionActionBar.tsx index be3ff0ffb5..5e09d510cd 100644 --- a/packages/react/src/patterns/OneDataCollection/components/ActionBar/OneDataCollectionActionBar.tsx +++ b/packages/react/src/patterns/OneDataCollection/components/ActionBar/OneDataCollectionActionBar.tsx @@ -10,7 +10,7 @@ import { F0ActionBar, type F0ActionBarRef, } from "@/components/F0ActionBar" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" export type { ActionBarGroup, ActionBarItem, ActionBarStatus, F0ActionBarRef } diff --git a/packages/react/src/patterns/OneDataCollection/visualizations/collection/List/components/ItemTeaser.tsx b/packages/react/src/patterns/OneDataCollection/visualizations/collection/List/components/ItemTeaser.tsx index 9792c2c18e..5eca8962b9 100644 --- a/packages/react/src/patterns/OneDataCollection/visualizations/collection/List/components/ItemTeaser.tsx +++ b/packages/react/src/patterns/OneDataCollection/visualizations/collection/List/components/ItemTeaser.tsx @@ -1,5 +1,5 @@ import { AvatarVariant, F0Avatar } from "@/components/avatars/F0Avatar" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" export type ItemTeaserProps = { title: string diff --git a/packages/react/src/patterns/OneDataCollection/visualizations/collection/Table/components/SortAndHideList/SortAndHideList.tsx b/packages/react/src/patterns/OneDataCollection/visualizations/collection/Table/components/SortAndHideList/SortAndHideList.tsx index 0f7ccea041..b0edd14407 100644 --- a/packages/react/src/patterns/OneDataCollection/visualizations/collection/Table/components/SortAndHideList/SortAndHideList.tsx +++ b/packages/react/src/patterns/OneDataCollection/visualizations/collection/Table/components/SortAndHideList/SortAndHideList.tsx @@ -6,7 +6,7 @@ import { ButtonInternal } from "@/components/F0Button/internal" import { F0Icon } from "@/components/F0Icon" import { Switch } from "@/experimental/Forms/Fields/Switch" import { Delete, Handle, LockLocked } from "@/icons/app" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { useI18n } from "@/lib/providers/i18n" import { TooltipWrapper } from "@/lib/tooltip-wrapper" import { cn } from "@/lib/utils" diff --git a/packages/react/src/patterns/OneFilterPicker/components/FilterList.tsx b/packages/react/src/patterns/OneFilterPicker/components/FilterList.tsx index 5df6d1e0b3..6576cf9a03 100644 --- a/packages/react/src/patterns/OneFilterPicker/components/FilterList.tsx +++ b/packages/react/src/patterns/OneFilterPicker/components/FilterList.tsx @@ -3,7 +3,7 @@ import { useId, useMemo } from "react" import { F0Button } from "@/components/F0Button" import { F0Icon } from "@/components/F0Icon" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { ChevronRight } from "@/icons/app" import { useReducedMotion } from "@/lib/a11y" import { useI18n } from "@/lib/providers/i18n" diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx index eaa465d472..a56bcfcf5d 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react" import { F0Checkbox } from "@/components/F0Checkbox" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { F0SearchInput } from "@/components/F0SearchInput" import { ScrollArea } from "@/ui/scrollarea" import { Spinner } from "@/ui/Spinner" diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/InFilterFlatOption.tsx b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/InFilterFlatOption.tsx index 512b4056b2..67cb40efe5 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/InFilterFlatOption.tsx +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/InFilterFlatOption.tsx @@ -1,6 +1,6 @@ "use client" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn, focusRing } from "@/lib/utils" import { InFilterOptionItem } from "../types" diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/InFilterOptionRow.tsx b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/InFilterOptionRow.tsx index dfe4d8f641..3b1f81f67c 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/InFilterOptionRow.tsx +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/InFilterOptionRow.tsx @@ -3,7 +3,7 @@ import { useCallback, useState } from "react" import { F0Button } from "@/components/F0Button" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { ChevronDown, ChevronRight } from "@/icons/app" import { useI18n } from "@/lib/providers/i18n" import { cn, focusRing } from "@/lib/utils" diff --git a/packages/react/src/sds/Home/ClockIn/ClockInControls/index.tsx b/packages/react/src/sds/Home/ClockIn/ClockInControls/index.tsx index 178b70c6e6..523dd588bb 100644 --- a/packages/react/src/sds/Home/ClockIn/ClockInControls/index.tsx +++ b/packages/react/src/sds/Home/ClockIn/ClockInControls/index.tsx @@ -6,7 +6,7 @@ import { F0Select } from "@/components/F0Select" import { F0TagRaw } from "@/components/tags/F0TagRaw" import { SolidPause, SolidPlay, SolidStop } from "@/icons/app" import { useReducedMotion } from "@/lib/a11y" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" import { ClockInGraph, ClockInGraphProps } from "../ClockInGraph" diff --git a/packages/react/src/sds/chat/F0Chat/components/ChatEmojiAutocomplete.tsx b/packages/react/src/sds/chat/F0Chat/components/ChatEmojiAutocomplete.tsx index 02c7c2e5e1..6cc3d5ba01 100644 --- a/packages/react/src/sds/chat/F0Chat/components/ChatEmojiAutocomplete.tsx +++ b/packages/react/src/sds/chat/F0Chat/components/ChatEmojiAutocomplete.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useRef } from "react" import { EmojiImage } from "@/lib/emojis" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" import { diff --git a/packages/react/src/sds/chat/F0Chat/components/ChatMentionPopover.tsx b/packages/react/src/sds/chat/F0Chat/components/ChatMentionPopover.tsx index c6c3842a50..6312660eaf 100644 --- a/packages/react/src/sds/chat/F0Chat/components/ChatMentionPopover.tsx +++ b/packages/react/src/sds/chat/F0Chat/components/ChatMentionPopover.tsx @@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useRef } from "react" import { F0Avatar } from "@/components/avatars/F0Avatar" import { F0Icon } from "@/components/F0Icon" import { People } from "@/icons/app" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" import { Skeleton } from "@/ui/skeleton" diff --git a/packages/react/src/sds/chat/F0Chat/components/ChatReactionPill.tsx b/packages/react/src/sds/chat/F0Chat/components/ChatReactionPill.tsx index 59a769c96f..5e8204281e 100644 --- a/packages/react/src/sds/chat/F0Chat/components/ChatReactionPill.tsx +++ b/packages/react/src/sds/chat/F0Chat/components/ChatReactionPill.tsx @@ -2,7 +2,8 @@ import NumberFlow from "@number-flow/react" import { type ReactNode, useEffect, useRef, useState } from "react" import { TooltipInternal } from "@/experimental/Overlays/Tooltip" -import { EmojiImage, getEmojiLabel, useEmojiConfetti } from "@/lib/emojis" +import { useEmojiConfetti } from "@/lib/emoji-confetti" +import { EmojiImage, getEmojiLabel } from "@/lib/emojis" import { cn } from "@/lib/utils" import { Action } from "@/ui/Action" diff --git a/packages/react/src/sds/social/Reactions/reaction.tsx b/packages/react/src/sds/social/Reactions/reaction.tsx index 2846ec6482..38380efce5 100644 --- a/packages/react/src/sds/social/Reactions/reaction.tsx +++ b/packages/react/src/sds/social/Reactions/reaction.tsx @@ -2,7 +2,8 @@ import NumberFlow from "@number-flow/react" import { useEffect, useRef, useState } from "react" import { TooltipInternal } from "@/experimental/Overlays/Tooltip" -import { EmojiImage, getEmojiLabel, useEmojiConfetti } from "@/lib/emojis" +import { useEmojiConfetti } from "@/lib/emoji-confetti" +import { EmojiImage, getEmojiLabel } from "@/lib/emojis" import { cn } from "@/lib/utils" import { Action } from "@/ui/Action" diff --git a/packages/react/src/ui/value-display/types/country/country.tsx b/packages/react/src/ui/value-display/types/country/country.tsx index bd68651f9b..7fb3f5b878 100644 --- a/packages/react/src/ui/value-display/types/country/country.tsx +++ b/packages/react/src/ui/value-display/types/country/country.tsx @@ -3,7 +3,7 @@ * Used for showing alerts on items in data collections. */ import { F0AvatarFlag } from "@/components/avatars/F0AvatarFlag" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { CountryCode } from "@/lib/countries" import { ValueDisplayRendererContext } from "../../renderers" diff --git a/packages/react/src/ui/value-display/types/longText/longText.tsx b/packages/react/src/ui/value-display/types/longText/longText.tsx index b677f4f3d0..950c55ad1c 100644 --- a/packages/react/src/ui/value-display/types/longText/longText.tsx +++ b/packages/react/src/ui/value-display/types/longText/longText.tsx @@ -2,7 +2,7 @@ * Long text cell type for displaying multiple lines of text in data collections. * Uses the OneEllipsis component to truncate the text and show a tooltip with the full text. */ -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" import { tableDisplayClassNames } from "../../const" diff --git a/packages/react/src/ui/value-display/types/person/person.tsx b/packages/react/src/ui/value-display/types/person/person.tsx index 0684da1dd1..f2d937a859 100644 --- a/packages/react/src/ui/value-display/types/person/person.tsx +++ b/packages/react/src/ui/value-display/types/person/person.tsx @@ -3,7 +3,7 @@ * Shows full name alongside a person avatar with optional badge. */ import { F0Avatar } from "@/components/avatars/F0Avatar" -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" import { tableDisplayClassNames } from "../../const" diff --git a/packages/react/src/ui/value-display/types/text/text.tsx b/packages/react/src/ui/value-display/types/text/text.tsx index 3c9337243b..d0d92f754a 100644 --- a/packages/react/src/ui/value-display/types/text/text.tsx +++ b/packages/react/src/ui/value-display/types/text/text.tsx @@ -2,7 +2,7 @@ * Text cell type for displaying text or number values in data collections. * Supports both direct values and objects with placeholder states. */ -import { OneEllipsis } from "@/lib/OneEllipsis" +import { OneEllipsis } from "@/lib/OneEllipsis/PlainEllipsis" import { cn } from "@/lib/utils" import { tableDisplayClassNames } from "../../const" diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 8c28073e59..b3bcd4bc2e 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -29,6 +29,11 @@ const isBareRuntimeImport = (id: string) => !id.includes("?") && !id.startsWith("\0") +const isPreservedEsmExternal = (id: string, importer?: string) => + isBareRuntimeImport(id) && + !id.startsWith("@atlaskit/pragmatic-drag-and-drop") && + !importer?.includes("/node_modules/") + // Add tailwind build const buildTailwind = process.argv.find((arg) => arg.startsWith("--tailwind")) if (buildTailwind) { @@ -176,10 +181,13 @@ export default defineConfig({ copyPublicDir: false, rollupOptions: { external: buildPreservedEsm - ? isBareRuntimeImport + ? isPreservedEsmExternal : [/@copilotkit\/.*/, /@livekit\/.*/, "livekit-client"], // Workaround to fix rebuild https://github.com/vitejs/vite/issues/19410#issuecomment-2661835482 output: { + assetFileNames: buildPreservedEsm + ? "_embedded/[hash][extname]" + : undefined, entryFileNames: buildPreservedEsm ? (chunkInfo) => chunkInfo.name.includes("node_modules/")