Repository: climatepolicyradar/navigator-frontend
Pull request #501: APP-566: Concept Popover component
Author: login: jesskelsall
Created: 2025-05-07T13:13:46Z | Merged: 2025-05-07T13:43:04Z

# What's changed

- Support for Base UI overlay components through components high up in the DOM (Next.js and Storybook).
- Add `Popover` component (atom):
  - Essentially Base UI's eponymous component with opinionated styling and a thinner list of props to constrain use.
  - Includes arrow on top edge for clarity of what is triggering it.
- Add `ConceptLink` component (molecule):
  - Concept passed as a prop.
  - Renders the name of the concept like a link.
  - When hovered or clicked, shows the concept description and a link to the concept store.
- Add `Info` component (molecule):
  - Renders an info icon.
  - Shows its `children` via Popover when hovered/clicked.
- `joinTailwindClasses` takes strings as a spread array rather than an explicit array - less markup.

## Why?

- Needed for [APP-566](https://linear.app/climate-policy-radar/issue/APP-566/concept-popover) and [APP-541](https://linear.app/climate-policy-radar/issue/APP-541/labelling).
- I want to get these in Conor's hands via Storybook for design iteration.

## Screenshots?

Popover:
<img width="201" alt="Screenshot 2025-05-07 at 14 06 14" src="https://github.com/user-attachments/assets/3569f5f8-f927-48d5-9e87-6af45e3ea3b2" />

ConceptLink:
<img width="527" alt="Screenshot 2025-05-07 at 14 06 27" src="https://github.com/user-attachments/assets/1f6c085f-a913-4bde-b595-b41509379c05" />

Info:
<img width="384" alt="Screenshot 2025-05-07 at 14 06 42" src="https://github.com/user-attachments/assets/81d327cd-b8d9-4665-bc74-20e9d706f4a3" />


Diff:
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index dc8507fca..1a4869e90 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -19,9 +19,11 @@ const preview: Preview = {
       const queryClient = new QueryClient();
 
       return (
-        <QueryClientProvider client={queryClient}>
-          <Story />
-        </QueryClientProvider>
+        <div className="root isolate">
+          <QueryClientProvider client={queryClient}>
+            <Story />
+          </QueryClientProvider>
+        </div>
       );
     },
   ],
diff --git a/src/components/atoms/button/Button.tsx b/src/components/atoms/button/Button.tsx
index dab6ee14d..8434609be 100644
--- a/src/components/atoms/button/Button.tsx
+++ b/src/components/atoms/button/Button.tsx
@@ -76,7 +76,7 @@ export const getButtonClasses = ({
 
   const cursor = disabled ? "pointer-events-none" : "";
 
-  return joinTailwindClasses([baseClasses, layout, sizing, bgColor, border, outlineColor, roundness, textColor, cursor, className]);
+  return joinTailwindClasses(baseClasses, layout, sizing, bgColor, border, outlineColor, roundness, textColor, cursor, className);
 };
 
 export const Button = ({
diff --git a/src/components/atoms/input/Input.tsx b/src/components/atoms/input/Input.tsx
index 32ff34627..b2e525335 100644
--- a/src/components/atoms/input/Input.tsx
+++ b/src/components/atoms/input/Input.tsx
@@ -52,19 +52,19 @@ export const Input = ({
     }
 
     return {
-      button: joinTailwindClasses(["shrink-0 text-icon-standard", iconPadding]),
-      container: joinTailwindClasses([
+      button: joinTailwindClasses("shrink-0 text-icon-standard", iconPadding),
+      container: joinTailwindClasses(
         "w-full px-2 flex flex-row justify-around items-center bg-surface-ui rounded-md focus-within:outline",
         outlineColor,
-        containerClasses,
-      ]),
-      icon: joinTailwindClasses(["shrink-0", iconPadding]),
-      input: joinTailwindClasses([
+        containerClasses
+      ),
+      icon: joinTailwindClasses("shrink-0", iconPadding),
+      input: joinTailwindClasses(
         "w-full block bg-transparent border-none focus:shadow-[none] leading-none font-medium text-text-primary placeholder:text-text-tertiary caret-text-brand",
         inputPadding,
         textSize,
-        inputClasses,
-      ]),
+        inputClasses
+      ),
     };
   }, [color, containerClasses, inputClasses, size]);
 
diff --git a/src/components/atoms/popover/Popover.stories.tsx b/src/components/atoms/popover/Popover.stories.tsx
new file mode 100644
index 000000000..7b2b6dad1
--- /dev/null
+++ b/src/components/atoms/popover/Popover.stories.tsx
@@ -0,0 +1,25 @@
+import { Meta, StoryObj } from "@storybook/react/*";
+import { Button } from "../button/Button";
+import { Popover } from "./Popover";
+
+const meta = {
+  title: "Atoms/Popover",
+  component: Popover,
+  parameters: { layout: "centered" },
+  argTypes: {
+    children: { control: "text" },
+    onOpenChange: { control: false },
+    trigger: { control: false },
+  },
+} satisfies Meta<typeof Popover>;
+type Story = StoryObj<typeof Popover>;
+
+export default meta;
+
+export const Generic: Story = {
+  args: {
+    children: "Hello, I am a popover!",
+    openOnHover: false,
+    trigger: <Button>Trigger</Button>,
+  },
+};
diff --git a/src/components/atoms/popover/Popover.tsx b/src/components/atoms/popover/Popover.tsx
new file mode 100644
index 000000000..ac7647de1
--- /dev/null
+++ b/src/components/atoms/popover/Popover.tsx
@@ -0,0 +1,47 @@
+import { joinTailwindClasses } from "@/utils/tailwind";
+import { Popover as BasePopover } from "@base-ui-components/react/popover";
+import { ReactElement, ReactNode } from "react";
+
+const PopoverArrow = () => (
+  <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
+    <path
+      d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
+      className="fill-surface-light"
+    />
+    <path
+      d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
+      className="fill-border-light"
+    />
+  </svg>
+);
+
+interface PopoverProps {
+  children: ReactNode;
+  onOpenChange?: (open: boolean) => void;
+  openOnHover?: boolean;
+  popupClasses?: string;
+  trigger: ReactElement;
+}
+
+export const Popover = ({ children, onOpenChange, openOnHover = false, popupClasses = "", trigger }: PopoverProps) => {
+  const allPopupClasses = joinTailwindClasses(
+    "p-3 max-w-[350px] bg-surface-light border border-border-light rounded-md shadow-md text-sm leading-normal select-none focus-visible:outline-0",
+    popupClasses
+  );
+
+  return (
+    <BasePopover.Root openOnHover={openOnHover} onOpenChangeComplete={onOpenChange}>
+      <BasePopover.Trigger render={trigger} />
+      <BasePopover.Portal>
+        <BasePopover.Positioner positionMethod="fixed" sideOffset={8}>
+          <BasePopover.Popup className={allPopupClasses}>
+            <BasePopover.Arrow className="flex -top-2">
+              <PopoverArrow />
+            </BasePopover.Arrow>
+            {children}
+          </BasePopover.Popup>
+        </BasePopover.Positioner>
+      </BasePopover.Portal>
+    </BasePopover.Root>
+  );
+};
diff --git a/src/components/document/FamilyListItem.tsx b/src/components/document/FamilyListItem.tsx
index e1f101401..fea87199c 100644
--- a/src/components/document/FamilyListItem.tsx
+++ b/src/components/document/FamilyListItem.tsx
@@ -5,6 +5,7 @@ import { LinkWithQuery } from "@/components/LinkWithQuery";
 import { FamilyMeta } from "./FamilyMeta";
 
 import { truncateString } from "@/utils/truncateString";
+import { Popover } from "../atoms/popover/Popover";
 
 type TProps = {
   family: TFamily;
@@ -43,6 +44,13 @@ export const FamilyListItem: FC<TProps> = ({ family, children }) => {
           geographies={family_geographies}
           {...(corpus_type_name === "Reports" ? { author: (family_metadata as { author: string[] }).author } : {})}
         />
+        <Popover trigger={<p className="underline decoration-dotted decoration-[1.5px] hover:decoration-wavy hover:decoration-1">Net-Zero Target</p>}>
+          <p className="font-bold">Description</p>
+          <p className="my-2">
+            A commitment to balance GHG emissions with removal by a certain point in time, effectively reducing the net emissions to zero.
+          </p>
+          <p className="underline">Source</p>
+        </Popover>
       </div>
       <p
         className="my-3 text-content"
diff --git a/src/components/molecules/conceptLink/ConceptLink.stories.tsx b/src/components/molecules/conceptLink/ConceptLink.stories.tsx
new file mode 100644
index 000000000..49d4d3819
--- /dev/null
+++ b/src/components/molecules/conceptLink/ConceptLink.stories.tsx
@@ -0,0 +1,67 @@
+import { Meta, StoryObj } from "@storybook/react/*";
+import { ConceptLink } from "./ConceptLink";
+import { TConcept } from "@/types";
+
+const meta = {
+  title: "Molecules/ConceptLink",
+  component: ConceptLink,
+  parameters: { layout: "centered" },
+  argTypes: {},
+} satisfies Meta<typeof ConceptLink>;
+type Story = StoryObj<typeof ConceptLink>;
+
+export default meta;
+
+const concepts: Partial<TConcept>[] = [
+  {
+    preferred_label: "air pollution risk",
+    description:
+      "Air pollution involves harmful contaminants in the air, affecting both indoor and outdoor environments. It can lead to health issues like respiratory diseases and heart problems, and climate impacts such as global warming. Pollutants can damage plants, trees, habitats, and water bodies like rivers and lakes.",
+    definition:
+      "Air pollution involves harmful contaminants in the air, affecting both indoor and outdoor environments. It can lead to health issues like respiratory diseases and heart problems, and climate impacts such as global warming. Pollutants can damage plants, trees, habitats, and water bodies like rivers and lakes.",
+    wikibase_id: "Q412",
+  },
+  {
+    preferred_label: "marine risk",
+    description:
+      "Risks to the ecosystem health of the marine environment, including ocean warming, ocean acidification, and ocean deoxygenation, harming biodiversity, species health, ecosystem services, and coastal community livelihoods.",
+    definition:
+      "Environmental risks to marine ecosystems refer to threats from climate change impacts like rising sea temperatures, ocean acidification, and deoxygenation. These disrupt marine biodiversity, species health, and distribution, endangering ecosystem services and the livelihoods of coastal communities.",
+    wikibase_id: "Q368",
+  },
+  {
+    preferred_label: "terrestrial risk",
+    description:
+      'Terrestrial means "of the earth" and relates to the ecological system that includes all living organisms and their interactions within land-based environments, such as forests, grasslands, and deserts.',
+    definition:
+      'Terrestrial means "of the earth", and refers to factors and phenomena that occur on land and influence climatic conditions and environmental systems. It encompasses a range of land-based factors affecting climate and ecosystems, including events such as landslides, wildfires, and soil erosion. Examples of terrestrial ecosystems include the tropical rainforests, grasslands, and deserts.',
+    wikibase_id: "Q404",
+  },
+  {
+    preferred_label: "extreme weather",
+    description:
+      "Extreme weather refers to events where a weather variable exceeds (or falls below) a threshold. Meaning it is significantly different from the average or usual weather pattern.",
+    definition:
+      "Extreme weather refers to events where a weather variable exceeds (or falls below) a threshold, with thresholds defined differently across studies. Extreme weather events can span from hours (e.g., convective storms), days (e.g., heatwaves), to seasons and years (e.g., droughts), posing risks to health, livelihoods, assets, and ecosystems.",
+    wikibase_id: "Q374",
+  },
+];
+
+export const Single: Story = {
+  args: {
+    concept: concepts[0] as TConcept,
+  },
+};
+
+export const Multiple: Story = {
+  argTypes: {
+    concept: { control: false },
+  },
+  render: () => (
+    <div className="flex flex-row gap-4">
+      {concepts.map((concept) => (
+        <ConceptLink key={concept.wikibase_id} concept={concept as TConcept} />
+      ))}
+    </div>
+  ),
+};
diff --git a/src/components/molecules/conceptLink/ConceptLink.tsx b/src/components/molecules/conceptLink/ConceptLink.tsx
new file mode 100644
index 000000000..9742b7f7a
--- /dev/null
+++ b/src/components/molecules/conceptLink/ConceptLink.tsx
@@ -0,0 +1,31 @@
+import { Popover } from "@/components/atoms/popover/Popover";
+import { ExternalLink } from "@/components/ExternalLink";
+import { TConcept } from "@/types";
+import { joinTailwindClasses } from "@/utils/tailwind";
+import Link from "next/link";
+import { useState } from "react";
+
+interface ConceptLinkProps {
+  concept: TConcept;
+  triggerClasses?: string;
+}
+
+export const ConceptLink = ({ concept, triggerClasses = "" }: ConceptLinkProps) => {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const allTriggerClasses = joinTailwindClasses(
+    "inline text-text-primary capitalize underline underline-offset-2 decoration-dotted cursor-help",
+    isOpen ? "decoration-text-primary" : "decoration-text-tertiary",
+    triggerClasses
+  );
+
+  return (
+    <Popover openOnHover onOpenChange={setIsOpen} trigger={<span className={allTriggerClasses}>{concept.preferred_label}</span>}>
+      <span className="font-bold capitalize underline-offset-un">{concept.preferred_label}</span>
+      <p className="my-2">{concept.description}</p>
+      <ExternalLink className="underline" url={`https://climatepolicyradar.wikibase.cloud/wiki/Item:${concept.wikibase_id}`}>
+        Source
+      </ExternalLink>
+    </Popover>
+  );
+};
diff --git a/src/components/molecules/info/Info.stories.tsx b/src/components/molecules/info/Info.stories.tsx
new file mode 100644
index 000000000..db8ec6c66
--- /dev/null
+++ b/src/components/molecules/info/Info.stories.tsx
@@ -0,0 +1,43 @@
+import { Meta, StoryObj } from "@storybook/react/*";
+import { Info } from "./Info";
+
+const meta = {
+  title: "Molecules/Info",
+  component: Info,
+  parameters: { layout: "centered" },
+  argTypes: {
+    children: { control: "text" },
+  },
+} satisfies Meta<typeof Info>;
+type Story = StoryObj<typeof Info>;
+
+export default meta;
+
+export const Generic: Story = {
+  args: {
+    children: "Here is some more info!",
+  },
+};
+
+export const Filters: Story = {
+  argTypes: {
+    children: { control: false },
+  },
+  args: {
+    children: (
+      <>
+        <span className="font-bold">About</span>
+        <p className="my-2">Narrow down your results using the filter below. You can also combine these with a search term.</p>
+        <a href="#" className="underline">
+          Learn more
+        </a>
+      </>
+    ),
+  },
+  render: ({ ...props }) => (
+    <p className="flex gap-1.5 items-center">
+      <span className="font-medium">Filters</span>
+      <Info {...props} />
+    </p>
+  ),
+};
diff --git a/src/components/molecules/info/Info.tsx b/src/components/molecules/info/Info.tsx
new file mode 100644
index 000000000..6c25bddce
--- /dev/null
+++ b/src/components/molecules/info/Info.tsx
@@ -0,0 +1,29 @@
+import { Popover } from "@/components/atoms/popover/Popover";
+import { joinTailwindClasses } from "@/utils/tailwind";
+import { ReactNode, useState } from "react";
+import { LuInfo } from "react-icons/lu";
+
+type InfoProps = {
+  className?: string;
+  children: ReactNode;
+};
+
+export const Info = ({ className, children }: InfoProps) => {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const infoClasses = joinTailwindClasses("cursor-help", isOpen ? "text-text-brand" : "text-text-secondary", className);
+
+  return (
+    <Popover
+      openOnHover
+      onOpenChange={setIsOpen}
+      trigger={
+        <div className={infoClasses}>
+          <LuInfo height="16" width="16" />
+        </div>
+      }
+    >
+      {children}
+    </Popover>
+  );
+};
diff --git a/src/components/molecules/navSearch/NavSearch.tsx b/src/components/molecules/navSearch/NavSearch.tsx
index f96a7280e..8750b8ed9 100644
--- a/src/components/molecules/navSearch/NavSearch.tsx
+++ b/src/components/molecules/navSearch/NavSearch.tsx
@@ -162,7 +162,7 @@ export const NavSearch = () => {
 
       {/* Results */}
       {isFocused && (
-        <div className="absolute top-0 left-0 z-20 right-0 min-h-[56px] outline -outline-offset-1 outline-border-lighter rounded-xl bg-surface-light shadow-[0px_4px_48px_0px_rgba(0,0,0,0.08)]">
+        <div className="absolute top-0 left-0 z-20 right-0 min-h-[56px] outline -outline-offset-1 outline-border-lighter rounded-xl bg-surface-light shadow-lg">
           {showResults && (
             <div className="flex flex-col gap-3 p-2 pt-[56px]">
               {/* Geographies */}
diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx
index 3a52cd3d1..703056776 100644
--- a/src/pages/_document.tsx
+++ b/src/pages/_document.tsx
@@ -8,7 +8,7 @@ export default function Document() {
         <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
         <link rel="stylesheet" href="https://use.typekit.net/qeq0int.css" />
       </Head>
-      <body>
+      <body className="root isolate">
         <Main />
         <NextScript />
       </body>
diff --git a/src/styles/theme.css b/src/styles/theme.css
index 7d7585ea0..0da796f8e 100644
--- a/src/styles/theme.css
+++ b/src/styles/theme.css
@@ -104,6 +104,8 @@
 
   --radius-4xl: 30px;
 
+  --shadow-lg: 0px 4px 48px 0px rgba(0, 0, 0, 0.08);
+  --shadow-md: 0px 4px 16px 0px rgba(0, 0, 0, 0.08);
   --shadow-xs: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
   --shadow-oep: 0px 8px 44px 0px rgba(32, 32, 32, 0.16);
 
diff --git a/src/utils/tailwind.test.ts b/src/utils/tailwind.test.ts
index f3bd2d029..15911e11e 100644
--- a/src/utils/tailwind.test.ts
+++ b/src/utils/tailwind.test.ts
@@ -2,16 +2,16 @@ import { joinTailwindClasses } from "./tailwind";
 
 describe("joinTailwindClasses", () => {
   it("joins multiple class strings", () => {
-    expect(joinTailwindClasses(["md-5"])).toBe("md-5");
-    expect(joinTailwindClasses(["md-5", "bg-text-primary"])).toBe("md-5 bg-text-primary");
+    expect(joinTailwindClasses("md-5")).toBe("md-5");
+    expect(joinTailwindClasses("md-5", "bg-text-primary")).toBe("md-5 bg-text-primary");
   });
 
   it("handles empty strings", () => {
-    expect(joinTailwindClasses([undefined])).toBe("");
-    expect(joinTailwindClasses(["", "text-md"])).toBe("text-md");
+    expect(joinTailwindClasses(undefined)).toBe("");
+    expect(joinTailwindClasses("", "text-md")).toBe("text-md");
   });
 
   it("handles whitespace trimming", () => {
-    expect(joinTailwindClasses([" underline  "])).toBe("underline");
+    expect(joinTailwindClasses(" underline  ")).toBe("underline");
   });
 });
diff --git a/src/utils/tailwind.ts b/src/utils/tailwind.ts
index 1a52154d5..b801d0316 100644
--- a/src/utils/tailwind.ts
+++ b/src/utils/tailwind.ts
@@ -1,4 +1,4 @@
-export const joinTailwindClasses = (parts: (string | undefined)[]) =>
+export const joinTailwindClasses = (...parts: (string | undefined)[]) =>
   parts
     .map((part) => (part || "").trim())
     .filter((part) => part)


Reviews:
user: login: PatFawbertMills
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T13:26:37Z
user: login: PatFawbertMills
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T13:29:55Z
user: login: PatFawbertMills
body: I've left a few comments but nothing blocking.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-07T13:30:35Z
user: login: jesskelsall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:36:51Z

Review comments:
diff_hunk: @@ -0,0 +1,47 @@
+import { joinTailwindClasses } from "@/utils/tailwind";
+import { Popover as BasePopover } from "@base-ui-components/react/popover";
+import { ReactElement, ReactNode } from "react";
+
+const PopoverArrow = () => (
+  <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
+    <path
+      d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
+      className="fill-surface-light"
+    />
+    <path
+      d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
+      className="fill-border-light"
+    />
+  </svg>
+);
+
+interface PopoverProps {
+  children: ReactNode;
+  onOpenChange?: (open: boolean) => void;
+  openOnHover?: boolean;
+  popupClasses?: string;
+  trigger: ReactElement;
+}
+
+export const Popover = ({ children, onOpenChange, openOnHover = false, popupClasses = "", trigger }: PopoverProps) => {
+  const allPopupClasses = joinTailwindClasses(
+    "p-3 max-w-[350px] bg-surface-light border border-border-light rounded-md shadow-[0px_4px_16px_0px_rgba(0,0,0,0.08)] text-sm leading-normal select-none focus-visible:outline-0",
path: src/components/atoms/popover/Popover.tsx
user: login: PatFawbertMills
body: Suggestion: is it worth us theme'ifying this drop shadow? Do we think we'll reuse it for the modal, for instance??
created_at: 2025-05-07T13:26:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,30 @@
+import { Popover } from "@/components/atoms/popover/Popover";
+import { TConcept } from "@/types";
+import { joinTailwindClasses } from "@/utils/tailwind";
+import Link from "next/link";
+import { useState } from "react";
+
+interface ConceptLinkProps {
+  concept: TConcept;
+  triggerClasses?: string;
+}
+
+export const ConceptLink = ({ concept, triggerClasses = "" }: ConceptLinkProps) => {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const allTriggerClasses = joinTailwindClasses(
+    "inline text-text-primary capitalize underline underline-offset-2 decoration-dotted cursor-help",
+    isOpen ? "decoration-text-primary" : "decoration-text-tertiary",
+    triggerClasses
+  );
+
+  return (
+    <Popover openOnHover onOpenChange={setIsOpen} trigger={<span className={allTriggerClasses}>{concept.preferred_label}</span>}>
+      <span className="font-bold capitalize underline-offset-un">{concept.preferred_label}</span>
+      <p className="my-2">{concept.description}</p>
+      <Link className="underline" href={`https://climatepolicyradar.wikibase.cloud/wiki/Item:${concept.wikibase_id}`}>
path: src/components/molecules/conceptLink/ConceptLink.tsx
user: login: PatFawbertMills
body: Suggestion: Can we use the `ExternalLink` component here? This will open in the same tab if I am not wrong?
created_at: 2025-05-07T13:29:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,47 @@
+import { joinTailwindClasses } from "@/utils/tailwind";
+import { Popover as BasePopover } from "@base-ui-components/react/popover";
+import { ReactElement, ReactNode } from "react";
+
+const PopoverArrow = () => (
+  <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
+    <path
+      d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
+      className="fill-surface-light"
+    />
+    <path
+      d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
+      className="fill-border-light"
+    />
+  </svg>
+);
+
+interface PopoverProps {
+  children: ReactNode;
+  onOpenChange?: (open: boolean) => void;
+  openOnHover?: boolean;
+  popupClasses?: string;
+  trigger: ReactElement;
+}
+
+export const Popover = ({ children, onOpenChange, openOnHover = false, popupClasses = "", trigger }: PopoverProps) => {
+  const allPopupClasses = joinTailwindClasses(
+    "p-3 max-w-[350px] bg-surface-light border border-border-light rounded-md shadow-[0px_4px_16px_0px_rgba(0,0,0,0.08)] text-sm leading-normal select-none focus-visible:outline-0",
path: src/components/atoms/popover/Popover.tsx
user: login: jesskelsall
body: Great shout! ✅ 
created_at: 2025-05-07T13:36:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-05-07T13:13:49Z
body: <p><a href="https://linear.app/climate-policy-radar/issue/APP-566/concept-popover">APP-566 Concept Popover</a></p>
author_association: NONE
user: login: trunk-io[bot]
created_at: 2025-05-07T13:13:50Z
body: <!-- Trunk Metrics Report -->



<details>
<summary>
⏱️ 52m total CI duration on this PR
</summary>

| Job | Cumulative Duration | Recent Runs |
|---|---|---|
| [test-e2e](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801636138) | 13m | [🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884281824/job/41799613287)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884514653/job/41800424576)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801636138)&#8201; |
| [Lighthouse Mobile](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801634404) | 9m | [🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884281824/job/41799610768)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884514653/job/41800421593)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801634404)&#8201; |
| [Lighthouse Desktop](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801633222) | 9m | [🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884281824/job/41799609243)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884514653/job/41800421068)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801633222)&#8201; |
| [size](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801635017) | 7m | [🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884281824/job/41799612115)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884514653/job/41800423291)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801635017)&#8201; |
| [percy](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801632668) | 7m | [🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884281824/job/41799609946)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884514653/job/41800422204)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801632668)&#8201; |
| [test](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801635557) | 4m | [🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884281824/job/41799612741)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884514653/job/41800423957)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801635557)&#8201; |
| [code-quality](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801633820) | 2m | [🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884281824/job/41799611543)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884514653/job/41800422730)&#8201;[🟩](https://github.com/climatepolicyradar/navigator-frontend/actions/runs/14884865242/job/41801633820)&#8201; |
</details>

<sub>[settings](https://app.trunk.io/climatepolicyradar/settings/repo?repo=climatepolicyradar/navigator-frontend&utm_source=gh-comment#ci-analytics) ⋅ [feedback](https://form.typeform.com/to/w7Iv34u2?utm_source=gh-comment) ⋅ [docs](https://docs.trunk.io/ci-analytics/pull-request-metrics?utm_source=gh-comment) ⋅ learn more about [trunk.io](https://trunk.io?utm_source=gh-comment)</sub>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-07T13:16:28Z
body: ## size-limit report 📦 
| Path              | Size                  |
| ----------------- | --------------------- |
| Next static files | 346.19 KB (+1.37% 🔺) |
author_association: NONE
user: login: trunk-io[bot]
created_at: 2025-05-07T14:22:27Z
body: <!-- Trunk Test Analytics -->


[![Static Badge](https://raster.shields.io/badge/23_tests-127510f0-purple)](https://app.trunk.io/climatepolicyradar/flaky-tests/pr/501?repo=climatepolicyradar/navigator-frontend&commitHash=127510f0efe2e04013aa7266b578ca98aac83bba&utm_source=gh-comment) &ensp; [![Static Badge](https://raster.shields.io/badge/0-failed-crimson)](https://app.trunk.io/climatepolicyradar/flaky-tests/pr/501?repo=climatepolicyradar/navigator-frontend&commitHash=127510f0efe2e04013aa7266b578ca98aac83bba&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://raster.shields.io/badge/0-flaky-red)](https://app.trunk.io/climatepolicyradar/flaky-tests/pr/501?repo=climatepolicyradar/navigator-frontend&commitHash=127510f0efe2e04013aa7266b578ca98aac83bba&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://raster.shields.io/badge/0-quarantined-yellow)](https://app.trunk.io/climatepolicyradar/flaky-tests/pr/501?repo=climatepolicyradar/navigator-frontend&commitHash=127510f0efe2e04013aa7266b578ca98aac83bba&utm_source=gh-comment&runConclusion=QUARANTINED)


<sub>

[View Full Report ↗︎](https://app.trunk.io/climatepolicyradar/flaky-tests/pr/501?repo=climatepolicyradar/navigator-frontend&commitHash=127510f0efe2e04013aa7266b578ca98aac83bba&utm_source=gh-comment) ⋅ [Docs](https://docs.trunk.io/flaky-tests?utm_source=gh-comment)

</sub>
author_association: NONE