@recursica/mui-adapter 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/index.d.ts +166 -9
  3. package/dist/mui-adapter.cjs +69 -69
  4. package/dist/mui-adapter.cjs.map +1 -1
  5. package/dist/mui-adapter.css +1 -1
  6. package/dist/mui-adapter.js +8868 -8066
  7. package/dist/mui-adapter.js.map +1 -1
  8. package/llms.txt +1 -0
  9. package/package.json +1 -1
  10. package/src/components/AssistiveElement/AssistiveElement.tsx +1 -1
  11. package/src/components/Button/BUTTON_IMPLEMENTATION_NOTES.md +12 -0
  12. package/src/components/Button/Button.module.css +6 -5
  13. package/src/components/Checkbox/Checkbox.tsx +4 -1
  14. package/src/components/FileInput/FileInput.tsx +6 -0
  15. package/src/components/Popover/IMPLEMENTATION_NOTES.md +22 -0
  16. package/src/components/Popover/Popover.module.css +123 -0
  17. package/src/components/Popover/Popover.stories.tsx +133 -0
  18. package/src/components/Popover/Popover.tsx +275 -0
  19. package/src/components/Popover/USAGE.md +69 -0
  20. package/src/components/Popover/index.ts +1 -0
  21. package/src/components/SegmentedControl/IMPLEMENTATION_NOTES.md +6 -0
  22. package/src/components/SegmentedControl/SegmentedControl.module.css +33 -4
  23. package/src/components/SegmentedControl/SegmentedControl.tsx +18 -4
  24. package/src/components/Slider/IMPLEMENTATION_NOTES.md +29 -0
  25. package/src/components/Slider/Slider.module.css +80 -17
  26. package/src/components/Slider/Slider.stories.tsx +1 -1
  27. package/src/components/Slider/Slider.tsx +36 -1
  28. package/src/components/Stepper/IMPLEMENTATION_NOTES.md +54 -0
  29. package/src/components/Stepper/Stepper.module.css +139 -106
  30. package/src/components/Stepper/Stepper.tsx +76 -10
  31. package/src/components/Stepper/USAGE.md +4 -0
  32. package/src/components/Tabs/IMPLEMENTATION_NOTES.md +12 -0
  33. package/src/components/Tabs/Tabs.module.css +107 -24
  34. package/src/components/Tabs/Tabs.tsx +1 -0
  35. package/src/components/TextArea/TextArea.module.css +20 -4
  36. package/src/components/TextArea/TextArea.tsx +12 -23
  37. package/src/components/Timeline/IMPLEMENTATION_NOTES.md +24 -3
  38. package/src/components/Timeline/Timeline.module.css +56 -68
  39. package/src/components/Timeline/Timeline.tsx +23 -27
  40. package/src/components/Timeline/TimelineItem.tsx +38 -35
  41. package/src/components/TransferList/TRANSFERLIST_IMPLEMENTATION_NOTES.md +140 -0
  42. package/src/components/TransferList/TransferList.module.css +179 -35
  43. package/src/components/TransferList/TransferList.stories.tsx +110 -6
  44. package/src/components/TransferList/TransferList.tsx +417 -8
  45. package/src/components/TransferList/USAGE.md +37 -6
  46. package/src/components/index.ts +1 -0
  47. package/src/index.ts +3 -0
package/llms.txt CHANGED
@@ -52,6 +52,7 @@ Each component has its own `USAGE.md` documenting its props, adapter-specific be
52
52
  - [NumberInput](src/components/NumberInput/USAGE.md)
53
53
  - [Pagination](src/components/Pagination/USAGE.md)
54
54
  - [Panel](src/components/Panel/USAGE.md)
55
+ - [Popover](src/components/Popover/USAGE.md)
55
56
  - [Radio](src/components/Radio/USAGE.md)
56
57
  - [ReadOnlyField](src/components/ReadOnlyField/USAGE.md)
57
58
  - [RecursicaThemeProvider](src/components/RecursicaThemeProvider/USAGE.md)
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "git+https://github.com/borderux/recursica.git",
14
14
  "directory": "packages/mui-adapter"
15
15
  },
16
- "version": "0.24.0",
16
+ "version": "0.25.0",
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
@@ -86,7 +86,7 @@ export const AssistiveElement = React.forwardRef<
86
86
  {assistiveVariant === "error" ? <ErrorIcon /> : <HelpIcon />}
87
87
  </div>
88
88
  )}
89
- <div className={styles.text}>{children}</div>
89
+ <span className={styles.text}>{children}</span>
90
90
  </FormHelperText>
91
91
  );
92
92
  });
@@ -53,3 +53,15 @@ We explicitly pass `disableRipple` and `disableElevation` to block MUI's dynamic
53
53
  **Fix:** wrapped every `.MuiButton-startIcon`/`.MuiButton-endIcon` reference in `:global(...)`.
54
54
 
55
55
  **Not otherwise fixed, flagged separately:** the same unwrapped-global-class pattern shows up in at least `Stepper.module.css` (`.Mui-active`/`.Mui-completed`/`.MuiStepLabel-root`, fully unwrapped) and `SegmentedControl.module.css`, and partially in `Label.module.css`/`Accordion.module.css` — meaning some of those components' MUI-state-driven styling may also be silently no-op'ing. Out of scope for this fix (Button only, per what was asked); worth a dedicated sweep.
56
+
57
+ ---
58
+
59
+ ## `line-height` wiring gap vs Mantine (Matt Massey, 2026-08-19)
60
+
61
+ **Reported symptom:** descenders on letters like "p"/"g" clipped in the Default story.
62
+
63
+ **Investigation:** live-verified both adapters in Storybook (Playwright) at the `Default` and `OutlineSmall` stories, including a direct canvas `measureText` check of the `gpqjy` glyph set in Lexend at 14px (`--recursica_tokens_font_line-heights_default: 1.05em` → 14.7px line box vs ~14.0px actual glyph ink) — no visible clipping reproduced in Chromium; both adapters rendered pixel-identical crops. The line-height token itself is objectively tight (near-zero headroom), but that's shared by both adapters and isn't itself mui-specific.
64
+
65
+ **Real discrepancy found:** `.root`'s "Shared Defaults" set `line-height` once, unconditionally, from the _default_-size token, and `.labelText` merely did `line-height: inherit` — so the small-size line-height token was never actually referenced (it was `recursica-ignore`d as "redundant"). Mantine's `Button.module.css`, by contrast, sets `line-height` directly on `.labelText` per size, from each size's own token. They currently resolve to the same literal value, so this wasn't visually observable, but it's a latent divergence from the source of truth — a future token change to the small-size line-height would silently not apply in mui-adapter.
66
+
67
+ **Fix:** moved `line-height` off `.root` and onto `.labelText` (default token), added `.root[data-size="small"] .labelText { line-height: ... }` (small token), and removed the now-inaccurate `recursica-ignore` for that token — matching Mantine's structure exactly.
@@ -24,9 +24,6 @@
24
24
  letter-spacing: var(
25
25
  --recursica_ui-kit_components_button_variants_sizes_default_properties_text_letter-spacing
26
26
  );
27
- line-height: var(
28
- --recursica_ui-kit_components_button_variants_sizes_default_properties_text_line-height
29
- );
30
27
  text-decoration: var(
31
28
  --recursica_ui-kit_components_button_variants_sizes_default_properties_text_text-decoration
32
29
  );
@@ -64,7 +61,9 @@
64
61
  text-overflow: ellipsis;
65
62
  white-space: nowrap;
66
63
  font-size: inherit;
67
- line-height: inherit;
64
+ line-height: var(
65
+ --recursica_ui-kit_components_button_variants_sizes_default_properties_text_line-height
66
+ );
68
67
  }
69
68
  /* Removed data-loading background override to rely on native disabled opacities like Mantine */
70
69
 
@@ -171,6 +170,9 @@
171
170
  max-width: var(
172
171
  --recursica_ui-kit_components_button_variants_sizes_small_properties_max-label-width
173
172
  );
173
+ line-height: var(
174
+ --recursica_ui-kit_components_button_variants_sizes_small_properties_text_line-height
175
+ );
174
176
  }
175
177
 
176
178
  /* Icon Resets */
@@ -387,6 +389,5 @@
387
389
  /* recursica-ignore: --recursica_ui-kit_components_button_variants_sizes_small_properties_min-width */
388
390
  /* recursica-ignore: --recursica_ui-kit_components_button_variants_sizes_small_properties_text_font-style */
389
391
  /* recursica-ignore: --recursica_ui-kit_components_button_variants_sizes_small_properties_text_letter-spacing */
390
- /* recursica-ignore: --recursica_ui-kit_components_button_variants_sizes_small_properties_text_line-height */
391
392
  /* recursica-ignore: --recursica_ui-kit_components_button_variants_sizes_small_properties_text_text-decoration */
392
393
  /* recursica-ignore: --recursica_ui-kit_components_button_variants_sizes_small_properties_text_text-transform */
@@ -92,7 +92,10 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
92
92
  : styles.root;
93
93
 
94
94
  const groupContext = React.useContext(CheckboxGroupContext);
95
- const isGrouped = groupContext !== null;
95
+ // A Checkbox with its own explicit `checked` prop owns its source of truth even when
96
+ // nested inside a CheckboxGroup used purely for layout (e.g. TransferList's ungrouped
97
+ // rows) — only defer to the group's value array when the caller hasn't already told us.
98
+ const isGrouped = groupContext !== null && restRecord.checked === undefined;
96
99
  const isGroupReadOnly = isGrouped ? groupContext.readOnly : false;
97
100
  const isChecked = isGrouped
98
101
  ? (groupContext.value || []).includes(restRecord.value)
@@ -387,6 +387,12 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
387
387
  e.stopPropagation();
388
388
  handleClearAll();
389
389
  }}
390
+ onKeyDown={(e) => {
391
+ if (e.key !== "Enter" && e.key !== " ") return;
392
+ e.preventDefault();
393
+ e.stopPropagation();
394
+ handleClearAll();
395
+ }}
390
396
  />
391
397
  )}
392
398
 
@@ -0,0 +1,22 @@
1
+ # Popover Implementation Notes
2
+
3
+ - **Built on Mui's Tooltip, in click-controlled mode:** Rather than Mui's `Popover`/`Modal` primitives, this component reuses `@mui/material`'s `Tooltip` — the same base HoverCard and Tooltip already use in this adapter — with `disableHoverListener`/`disableFocusListener`/`disableTouchListener` all set, and `open` fully controlled by this component. This keeps the arrow/placement/token-namespace conventions identical to HoverCard and Tooltip instead of introducing a second, unrelated positioning primitive.
4
+ - **Composable API preserved via child extraction:** Like HoverCard, `Popover.Target`/`Popover.Dropdown` are never rendered themselves — `PopoverBase` walks `children` looking for those `displayName`s and extracts their inner content, then renders the target as Tooltip's single child and the dropdown as Tooltip's `title`.
5
+ - **Diverges from HoverCard on missing Target/Dropdown:** HoverCard silently falls back to an empty `<div />` if the expected child isn't found. Popover throws instead — a silent empty popover is a worse failure mode for a click-triggered disclosure than for a hover card, and the repo convention is to let structural misuse fail loudly rather than mask it.
6
+ - **Click toggle + outside-click close is custom:** Mui's Tooltip has no notion of "click to open" or "click outside to close" once its native hover/focus/touch listeners are disabled — Mantine's Popover provides both natively. This component clones the target element to attach an `onClick` toggle handler and a ref, and closes on outside click via a `mousedown` listener on `document` that ignores clicks inside either the target (`targetRef`) or the dropdown content (`dropdownRef`, a wrapper `<div>` around the dropdown children solely for this hit-test — not a styling root). Escape-to-close comes for free from Tooltip's own internal Escape handling once `onClose` is wired up.
7
+ - **`beak-size` token exemption:** Mui's Tooltip arrow is a fixed CSS shape (`1em`/`0.71em`, relative to the tooltip's own `font-size`) with no JS size prop, unlike Mantine's `arrowSize`. There's no hook to bind `--recursica_ui-kit_components_hover-card-popover_properties_beak-size` to, so it's `recursica-ignore`d here (same conceptual gap as HoverCard/Tooltip's existing exemptions for this token family, just for a different reason — those wrote a comment referencing Mantine's inline-pixel arrow calculations, which doesn't apply in this adapter; this file's comment describes the actual Mui-specific reason).
8
+ - **Arrow fill uses `color`, not `border-color`:** HoverCard's and Tooltip's `.arrow` rules in this adapter set `border-color`, which has no visual effect — Mui's Tooltip arrow is a solid rotated-square filled via `background-color: currentColor` in its `::before`, not a bordered shape. This component's `.arrow` instead sets `color` to the panel's `background-color` token, which actually renders. Intentional deviation from the HoverCard/Tooltip precedent for correctness; not fixed there as it's out of scope for this change.
9
+ - **Shared token namespace:** Same as HoverCard, this component's CSS module exclusively uses `--recursica_ui-kit_components_hover-card-popover_*` tokens (geometry, typography, elevation, layer-aware colors) — no Popover-specific token namespace exists in the schema.
10
+ - **`width` and controlled `opened`/`onChange`:** Not present in the shared `RecursicaPopoverProps` (adapter-common only defines `withBeak`), these are defined locally in `PopoverOwnProps` to match Mantine's own `PopoverProps` surface, since there is no single Mui library type that already provides them.
11
+
12
+ ## Gap larger than Mantine's on `withBeak={false}` (Matt Massey, 2026-08-19)
13
+
14
+ **Root cause:** Mui's `Tooltip` styled component ships its own hardcoded per-placement margin (`marginTop`/`marginBottom`: `14px`, or `24px` in touch mode) on the tooltip content div, applied whenever `arrow` is falsy (the `arrow` variant resets it to `margin: 0`). This stacked on top of the `offset` popper modifier already applied in `Popover.tsx` (which alone reproduces Mantine's own gap — Mantine's Popover defaults to an 8px `offset`, the same value used here), so the visible gap was `8 + 14 = 22px` instead of `8px` with `withBeak={false}`.
15
+
16
+ **Fix:** neutralize Mui's built-in margin in `Popover.module.css` via `:global(.MuiTooltip-popper[data-popper-placement]) .dropdown { margin: 0; }`, matching Mui's own rule's specificity (class + attribute + class) so it wins on source order under `injectFirst`. The `offset` modifier is now the single source of truth for the gap, matching Mantine.
17
+
18
+ ## Beak had no visible edge against the dropdown body (Matt Massey, 2026-08-19)
19
+
20
+ **Root cause:** Mantine's arrow is a bordered shape — `.arrow { border-color: ... }` — visible because Mantine's own arrow implementation sets `border-width` natively. Mui's arrow `::before` pseudo-element has no border at all by default (just `background-color: currentColor`), so setting only `color` (as this component already did, to fill the triangle) left it with zero visible edge against a similarly-colored panel.
21
+
22
+ **Fix:** added `.arrow::before { border-style: solid; border-width: ...border-size; border-color: ...colors_border-color; }`, the same border tokens the `.dropdown` container itself uses — verified live (Playwright) that the arrow now shows a visible border matching Mantine's.
@@ -0,0 +1,123 @@
1
+ /* HARDCODED VALUES:
2
+ - border-style: solid. Mui's Tooltip content div does not set border-style natively;
3
+ without it, the border-width/border-color tokens below have no visible effect.
4
+ Same pattern as HoverCard / Tooltip / Menu in this adapter.
5
+ - margin: 0 on the dropdown (per placement, see below). Mui's Tooltip content div ships
6
+ its own hardcoded 14px (or 24px on touch) margin toward the target for every placement,
7
+ stacked on top of the `offset` popper modifier Popover.tsx already applies. That modifier
8
+ alone is what matches Mantine's own gap, so Mui's built-in margin must be zeroed out or
9
+ the visible gap ends up far larger than Mantine's.
10
+ - border-style: solid on the arrow's ::before. Mui's arrow pseudo-element has no border by
11
+ default; without it, the border-color/border-width tokens below have no visible effect.
12
+ - All structural layout (display, position, overflow) is deferred to Mui's native
13
+ Popper/Tooltip behavior. We only override visual design tokens (colors, typography,
14
+ spacing, borders).
15
+ */
16
+ /* EXEMPTIONS:
17
+ - beak-size is ignored here because Mui's Tooltip arrow is a fixed-size CSS shape
18
+ (1em / 0.71em, relative to the tooltip's own font-size) with no size prop of its own —
19
+ unlike Mantine, there is no JS/pixel hook to bind this token to. */
20
+ /* recursica-ignore: --recursica_ui-kit_components_hover-card-popover_properties_beak-size = 16px */
21
+
22
+ /* ======================================
23
+ DROPDOWN CONTAINER
24
+ ====================================== */
25
+
26
+ .dropdown {
27
+ background-color: var(
28
+ --recursica_ui-kit_components_hover-card-popover_properties_colors_background-color
29
+ );
30
+ border-style: solid; /* HARDCODE: Mui's Tooltip content div does not set border-style natively */
31
+ border-width: var(
32
+ --recursica_ui-kit_components_hover-card-popover_properties_border-size
33
+ );
34
+ border-color: var(
35
+ --recursica_ui-kit_components_hover-card-popover_properties_colors_border-color
36
+ );
37
+ border-radius: var(
38
+ --recursica_ui-kit_components_hover-card-popover_properties_border-radius
39
+ );
40
+ box-shadow: var(
41
+ --recursica_ui-kit_components_hover-card-popover_properties_elevation
42
+ );
43
+
44
+ min-width: var(
45
+ --recursica_ui-kit_components_hover-card-popover_properties_min-width
46
+ );
47
+ max-width: var(
48
+ --recursica_ui-kit_components_hover-card-popover_properties_max-width
49
+ );
50
+
51
+ padding: var(
52
+ --recursica_ui-kit_components_hover-card-popover_properties_vertical-padding
53
+ )
54
+ var(
55
+ --recursica_ui-kit_components_hover-card-popover_properties_horizontal-padding
56
+ );
57
+
58
+ /* Typography */
59
+ font-family: var(
60
+ --recursica_ui-kit_components_hover-card-popover_properties_content-text_font-family
61
+ );
62
+ font-size: var(
63
+ --recursica_ui-kit_components_hover-card-popover_properties_content-text_font-size
64
+ );
65
+ font-style: var(
66
+ --recursica_ui-kit_components_hover-card-popover_properties_content-text_font-style
67
+ );
68
+ font-weight: var(
69
+ --recursica_ui-kit_components_hover-card-popover_properties_content-text_font-weight
70
+ );
71
+ letter-spacing: var(
72
+ --recursica_ui-kit_components_hover-card-popover_properties_content-text_letter-spacing
73
+ );
74
+ line-height: var(
75
+ --recursica_ui-kit_components_hover-card-popover_properties_content-text_line-height
76
+ );
77
+ text-decoration: var(
78
+ --recursica_ui-kit_components_hover-card-popover_properties_content-text_text-decoration
79
+ );
80
+ text-transform: var(
81
+ --recursica_ui-kit_components_hover-card-popover_properties_content-text_text-transform
82
+ );
83
+
84
+ color: var(
85
+ --recursica_ui-kit_components_hover-card-popover_properties_colors_content
86
+ );
87
+ }
88
+
89
+ /* Mui's Tooltip content div sets its own margin toward the target per placement
90
+ (marginTop/marginBottom/marginLeft/marginRight), duplicating the gap already produced by
91
+ the `offset` popper modifier in Popover.tsx. Zero it out so the modifier is the single
92
+ source of truth for the gap, matching Mantine's gap. Selector specificity matches Mui's own
93
+ placement rule (class + attribute + class) so it wins on source order (this adapter's
94
+ modules are injected after Mui's via injectFirst). */
95
+ :global(.MuiTooltip-popper[data-popper-placement]) .dropdown {
96
+ margin: 0; /* HARDCODE: cancel Mui's built-in per-placement margin, see file header */
97
+ }
98
+
99
+ /* ======================================
100
+ ARROW / BEAK
101
+ ====================================== */
102
+
103
+ /* Mui's arrow is a solid rotated-square shape filled via `currentColor` (no separate
104
+ border), unlike Mantine's bordered-diamond arrow. Fill with the panel's own
105
+ background-color token for the closest visual match Mui's primitive allows. */
106
+ .arrow {
107
+ color: var(
108
+ --recursica_ui-kit_components_hover-card-popover_properties_colors_background-color
109
+ );
110
+ }
111
+
112
+ /* Mui's arrow ::before has no border by default, so it renders as a solid triangle with no
113
+ visible edge against a similarly-colored dropdown body. Mantine's arrow gets its visibility
114
+ the same way — a border using the popover's own border tokens — so replicate that here. */
115
+ .arrow::before {
116
+ border-style: solid; /* HARDCODE: Mui's arrow ::before does not set border-style natively */
117
+ border-width: var(
118
+ --recursica_ui-kit_components_hover-card-popover_properties_border-size
119
+ );
120
+ border-color: var(
121
+ --recursica_ui-kit_components_hover-card-popover_properties_colors_border-color
122
+ );
123
+ }
@@ -0,0 +1,133 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Popover } from "./Popover";
3
+ import { Button } from "../Button";
4
+ import { Text } from "../Text/Text";
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ type PopoverStoryArgs = Record<string, any>;
8
+
9
+ const meta: Meta = {
10
+ title: "UI-Kit/Popover",
11
+ component: Popover,
12
+ tags: ["autodocs"],
13
+ parameters: {
14
+ controls: {
15
+ // Explicitly list only the props integrators should configure — Mui's
16
+ // underlying Tooltip props would otherwise leak into Controls.
17
+ include: ["withBeak", "position", "defaultOpened"],
18
+ },
19
+ docs: {
20
+ description: {
21
+ component:
22
+ "The `Popover` component is a composable wrapper around Mui's Tooltip in click-controlled mode. It displays a dropdown panel when the user clicks a target element.",
23
+ },
24
+ },
25
+ },
26
+ argTypes: {
27
+ withBeak: {
28
+ control: "boolean",
29
+ description:
30
+ "Whether to display a beak (arrow) pointing from the dropdown to the target.",
31
+ },
32
+ position: {
33
+ control: "select",
34
+ options: [
35
+ "top",
36
+ "top-start",
37
+ "top-end",
38
+ "bottom",
39
+ "bottom-start",
40
+ "bottom-end",
41
+ "left",
42
+ "left-start",
43
+ "left-end",
44
+ "right",
45
+ "right-start",
46
+ "right-end",
47
+ ],
48
+ description: "Dropdown position relative to target",
49
+ },
50
+ defaultOpened: {
51
+ control: "boolean",
52
+ description: "Initial opened state",
53
+ },
54
+ },
55
+ };
56
+
57
+ export default meta;
58
+ type Story = StoryObj<PopoverStoryArgs>;
59
+
60
+ export const Default: Story = {
61
+ args: {
62
+ withBeak: true,
63
+ position: "top",
64
+ },
65
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
66
+ render: ({ withLayer, layer, ...args }: PopoverStoryArgs) => {
67
+ return (
68
+ <Popover width={250} {...args}>
69
+ <Popover.Target>
70
+ <Button variant="solid">Toggle Popover</Button>
71
+ </Popover.Target>
72
+ <Popover.Dropdown>
73
+ <Text>
74
+ This is the popover content. It can contain any elements you want to
75
+ display when the user clicks the target.
76
+ </Text>
77
+ </Popover.Dropdown>
78
+ </Popover>
79
+ );
80
+ },
81
+ };
82
+
83
+ export const SolidDefault: Story = {
84
+ args: {
85
+ withBeak: true,
86
+ position: "top",
87
+ defaultOpened: true,
88
+ },
89
+ parameters: {
90
+ layout: "centered",
91
+ controls: { disable: true },
92
+ },
93
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
94
+ render: ({ withLayer, layer, ...args }: PopoverStoryArgs) => {
95
+ return (
96
+ <Popover width={200} {...args}>
97
+ <Popover.Target>
98
+ <Button variant="solid">Toggle Popover</Button>
99
+ </Popover.Target>
100
+ <Popover.Dropdown>
101
+ <Text>
102
+ This is a static representation of an opened popover with a beak.
103
+ </Text>
104
+ </Popover.Dropdown>
105
+ </Popover>
106
+ );
107
+ },
108
+ };
109
+
110
+ export const WithoutBeak: Story = {
111
+ args: {
112
+ withBeak: false,
113
+ position: "bottom",
114
+ defaultOpened: true,
115
+ },
116
+ parameters: {
117
+ layout: "centered",
118
+ controls: { disable: true },
119
+ },
120
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
121
+ render: ({ withLayer, layer, ...args }: PopoverStoryArgs) => {
122
+ return (
123
+ <Popover width={200} {...args}>
124
+ <Popover.Target>
125
+ <Button variant="outline">Bottom Popover</Button>
126
+ </Popover.Target>
127
+ <Popover.Dropdown>
128
+ <Text>This popover is positioned at the bottom and has no beak.</Text>
129
+ </Popover.Dropdown>
130
+ </Popover>
131
+ );
132
+ },
133
+ };
@@ -0,0 +1,275 @@
1
+ import React, {
2
+ cloneElement,
3
+ isValidElement,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import {
9
+ Tooltip as MuiTooltip,
10
+ type TooltipProps as MuiTooltipProps,
11
+ } from "@mui/material";
12
+ import {
13
+ filterStylingProps,
14
+ type RecursicaOverStyled,
15
+ } from "../../utils/filterStylingProps";
16
+ import styles from "./Popover.module.css";
17
+
18
+ // ============================================================
19
+ // POPOVER ROOT
20
+ // ============================================================
21
+
22
+ import { type RecursicaPopoverProps } from "@recursica/adapter-common";
23
+
24
+ /**
25
+ * Behavioral props specific to this adapter's click-controlled implementation.
26
+ * `withBeak` comes from the shared `RecursicaPopoverProps` (adapter-common); the
27
+ * rest map to Mantine's own `PopoverProps` surface, reproduced here since Mui has
28
+ * no single library type that already covers them.
29
+ */
30
+ export interface PopoverOwnProps extends RecursicaPopoverProps {
31
+ /** Dropdown position relative to the target */
32
+ position?: MuiTooltipProps["placement"];
33
+ /** Initial opened state (uncontrolled) */
34
+ defaultOpened?: boolean;
35
+ /** Controlled opened state */
36
+ opened?: boolean;
37
+ /** Called whenever the opened state changes */
38
+ onChange?: (opened: boolean) => void;
39
+ /** Distance in px between the dropdown and the target */
40
+ offset?: number;
41
+ /** Fixed width applied to the dropdown panel */
42
+ width?: number | string;
43
+ children?: React.ReactNode;
44
+ }
45
+
46
+ /**
47
+ * Recursica Popover component wrapping Mui's Tooltip in click-controlled mode.
48
+ *
49
+ * Displays a dropdown panel when the user clicks the target element.
50
+ * Uses the composable dot-notation pattern:
51
+ * ```tsx
52
+ * <Popover withBeak>
53
+ * <Popover.Target>
54
+ * <Button>Click me</Button>
55
+ * </Popover.Target>
56
+ * <Popover.Dropdown>
57
+ * Content displayed in popover
58
+ * </Popover.Dropdown>
59
+ * </Popover>
60
+ * ```
61
+ */
62
+ export type PopoverProps = RecursicaOverStyled<
63
+ Omit<
64
+ MuiTooltipProps,
65
+ "title" | "children" | "open" | "onClose" | "onOpen" | "placement"
66
+ > &
67
+ PopoverOwnProps
68
+ >;
69
+
70
+ const PopoverBase = function Popover({
71
+ overStyled = false,
72
+ withBeak = true,
73
+ position = "top",
74
+ defaultOpened = false,
75
+ opened,
76
+ onChange,
77
+ offset = 8,
78
+ width,
79
+ children,
80
+ ...rest
81
+ }: PopoverProps) {
82
+ const sanitizedProps = filterStylingProps(
83
+ rest as Record<string, unknown>,
84
+ overStyled,
85
+ );
86
+
87
+ // Bind CSS module classes to Mui's internal classNames API
88
+ const mergedClassNames: Partial<Record<string, string>> = {
89
+ tooltip: styles.dropdown,
90
+ arrow: styles.arrow,
91
+ };
92
+
93
+ const classesProp = (sanitizedProps as Record<string, unknown>).classes;
94
+ if (
95
+ classesProp &&
96
+ typeof classesProp === "object" &&
97
+ !Array.isArray(classesProp)
98
+ ) {
99
+ const o = classesProp as Record<string, string>;
100
+ Object.keys(o).forEach((key) => {
101
+ mergedClassNames[key] = mergedClassNames[key]
102
+ ? `${mergedClassNames[key]} ${o[key]}`
103
+ : o[key];
104
+ });
105
+ }
106
+
107
+ const [internalOpened, setInternalOpened] = useState(defaultOpened);
108
+ const isControlled = opened !== undefined;
109
+ const currentOpened = isControlled ? (opened as boolean) : internalOpened;
110
+
111
+ const setOpened = (next: boolean) => {
112
+ if (!isControlled) setInternalOpened(next);
113
+ onChange?.(next);
114
+ };
115
+
116
+ // Find Target and Dropdown children
117
+ let targetNode: React.ReactNode = null;
118
+ let dropdownNode: React.ReactNode = null;
119
+
120
+ React.Children.forEach(children, (child) => {
121
+ if (isValidElement(child)) {
122
+ const childElement = child as unknown as {
123
+ type?: { displayName?: string };
124
+ props: { children?: React.ReactNode };
125
+ };
126
+ if (childElement.type?.displayName === "PopoverTarget") {
127
+ targetNode = childElement.props.children;
128
+ } else if (childElement.type?.displayName === "PopoverDropdown") {
129
+ dropdownNode = childElement.props.children;
130
+ }
131
+ }
132
+ });
133
+
134
+ if (!targetNode) {
135
+ throw new Error("Popover requires a <Popover.Target> child.");
136
+ }
137
+ if (!dropdownNode) {
138
+ throw new Error("Popover requires a <Popover.Dropdown> child.");
139
+ }
140
+
141
+ const targetRef = useRef<HTMLElement | null>(null);
142
+ const dropdownRef = useRef<HTMLDivElement | null>(null);
143
+
144
+ // Mui's Tooltip has no native "click outside to close" behavior once its own
145
+ // hover/focus/touch listeners are disabled for click-controlled use, so it's
146
+ // implemented here directly (mirrors Mantine's default closeOnClickOutside).
147
+ useEffect(() => {
148
+ if (!currentOpened) return undefined;
149
+ const handlePointerDown = (event: MouseEvent) => {
150
+ const target = event.target as Node;
151
+ if (targetRef.current?.contains(target)) return;
152
+ if (dropdownRef.current?.contains(target)) return;
153
+ setOpened(false);
154
+ };
155
+ document.addEventListener("mousedown", handlePointerDown);
156
+ return () => document.removeEventListener("mousedown", handlePointerDown);
157
+ // eslint-disable-next-line react-hooks/exhaustive-deps
158
+ }, [currentOpened]);
159
+
160
+ const handleTargetClick = (event: React.MouseEvent) => {
161
+ if (isValidElement(targetNode)) {
162
+ (
163
+ targetNode.props as { onClick?: (e: React.MouseEvent) => void }
164
+ ).onClick?.(event);
165
+ }
166
+ setOpened(!currentOpened);
167
+ };
168
+
169
+ // Mui's Tooltip auto-wires `aria-labelledby`/`aria-label` on the target to describe
170
+ // it via the tooltip content once open — correct for an actual tooltip, but wrong here:
171
+ // it would silently replace the target's own accessible name (e.g. a Button's label)
172
+ // with the popover's body text. Explicitly reset both so the target keeps its own name.
173
+ const targetAriaOverrides = {
174
+ "aria-haspopup": "dialog" as const,
175
+ "aria-expanded": currentOpened,
176
+ "aria-labelledby": undefined,
177
+ "aria-label": undefined,
178
+ };
179
+
180
+ const clonedTarget = isValidElement(targetNode) ? (
181
+ cloneElement(
182
+ targetNode as React.ReactElement,
183
+ {
184
+ onClick: handleTargetClick,
185
+ ref: targetRef,
186
+ ...targetAriaOverrides,
187
+ } as Record<string, unknown>,
188
+ )
189
+ ) : (
190
+ <span
191
+ onClick={handleTargetClick}
192
+ ref={targetRef as unknown as React.Ref<HTMLSpanElement>}
193
+ {...targetAriaOverrides}
194
+ >
195
+ {targetNode}
196
+ </span>
197
+ );
198
+
199
+ return (
200
+ <MuiTooltip
201
+ {...(sanitizedProps as unknown as Omit<
202
+ MuiTooltipProps,
203
+ "title" | "children"
204
+ >)}
205
+ title={<div ref={dropdownRef}>{dropdownNode}</div>}
206
+ open={currentOpened}
207
+ onClose={() => setOpened(false)}
208
+ placement={position}
209
+ arrow={withBeak}
210
+ disableHoverListener
211
+ disableFocusListener
212
+ disableTouchListener
213
+ slotProps={{
214
+ popper: {
215
+ modifiers: [
216
+ {
217
+ name: "offset",
218
+ options: {
219
+ offset: [0, offset],
220
+ },
221
+ },
222
+ ],
223
+ },
224
+ ...(width !== undefined ? { tooltip: { style: { width } } } : {}),
225
+ }}
226
+ classes={mergedClassNames as unknown as MuiTooltipProps["classes"]}
227
+ >
228
+ {clonedTarget}
229
+ </MuiTooltip>
230
+ );
231
+ };
232
+ PopoverBase.displayName = "Popover";
233
+
234
+ // ============================================================
235
+ // POPOVER TARGET
236
+ // ============================================================
237
+
238
+ /**
239
+ * Wrapper for the element that triggers the popover.
240
+ * Requires a single child element; only used as a marker to locate the
241
+ * trigger element, it is never rendered directly (see `PopoverBase`).
242
+ */
243
+ export type PopoverTargetProps = { children?: React.ReactNode };
244
+
245
+ const PopoverTarget = function PopoverTarget({ children }: PopoverTargetProps) {
246
+ return <>{children}</>;
247
+ };
248
+ PopoverTarget.displayName = "PopoverTarget";
249
+
250
+ // ============================================================
251
+ // POPOVER DROPDOWN
252
+ // ============================================================
253
+
254
+ /** The dropdown panel displayed from the popover. */
255
+ export type PopoverDropdownProps = { children?: React.ReactNode };
256
+
257
+ const PopoverDropdown = function PopoverDropdown({
258
+ children,
259
+ }: PopoverDropdownProps) {
260
+ return <>{children}</>;
261
+ };
262
+ PopoverDropdown.displayName = "PopoverDropdown";
263
+
264
+ // ============================================================
265
+ // DOT NOTATION EXPORT
266
+ // ============================================================
267
+
268
+ type PopoverComponent = typeof PopoverBase & {
269
+ Target: typeof PopoverTarget;
270
+ Dropdown: typeof PopoverDropdown;
271
+ };
272
+
273
+ export const Popover = PopoverBase as PopoverComponent;
274
+ Popover.Target = PopoverTarget;
275
+ Popover.Dropdown = PopoverDropdown;