@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.
- package/CHANGELOG.md +26 -0
- package/dist/index.d.ts +166 -9
- package/dist/mui-adapter.cjs +69 -69
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +8868 -8066
- package/dist/mui-adapter.js.map +1 -1
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/components/AssistiveElement/AssistiveElement.tsx +1 -1
- package/src/components/Button/BUTTON_IMPLEMENTATION_NOTES.md +12 -0
- package/src/components/Button/Button.module.css +6 -5
- package/src/components/Checkbox/Checkbox.tsx +4 -1
- package/src/components/FileInput/FileInput.tsx +6 -0
- package/src/components/Popover/IMPLEMENTATION_NOTES.md +22 -0
- package/src/components/Popover/Popover.module.css +123 -0
- package/src/components/Popover/Popover.stories.tsx +133 -0
- package/src/components/Popover/Popover.tsx +275 -0
- package/src/components/Popover/USAGE.md +69 -0
- package/src/components/Popover/index.ts +1 -0
- package/src/components/SegmentedControl/IMPLEMENTATION_NOTES.md +6 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +33 -4
- package/src/components/SegmentedControl/SegmentedControl.tsx +18 -4
- package/src/components/Slider/IMPLEMENTATION_NOTES.md +29 -0
- package/src/components/Slider/Slider.module.css +80 -17
- package/src/components/Slider/Slider.stories.tsx +1 -1
- package/src/components/Slider/Slider.tsx +36 -1
- package/src/components/Stepper/IMPLEMENTATION_NOTES.md +54 -0
- package/src/components/Stepper/Stepper.module.css +139 -106
- package/src/components/Stepper/Stepper.tsx +76 -10
- package/src/components/Stepper/USAGE.md +4 -0
- package/src/components/Tabs/IMPLEMENTATION_NOTES.md +12 -0
- package/src/components/Tabs/Tabs.module.css +107 -24
- package/src/components/Tabs/Tabs.tsx +1 -0
- package/src/components/TextArea/TextArea.module.css +20 -4
- package/src/components/TextArea/TextArea.tsx +12 -23
- package/src/components/Timeline/IMPLEMENTATION_NOTES.md +24 -3
- package/src/components/Timeline/Timeline.module.css +56 -68
- package/src/components/Timeline/Timeline.tsx +23 -27
- package/src/components/Timeline/TimelineItem.tsx +38 -35
- package/src/components/TransferList/TRANSFERLIST_IMPLEMENTATION_NOTES.md +140 -0
- package/src/components/TransferList/TransferList.module.css +179 -35
- package/src/components/TransferList/TransferList.stories.tsx +110 -6
- package/src/components/TransferList/TransferList.tsx +417 -8
- package/src/components/TransferList/USAGE.md +37 -6
- package/src/components/index.ts +1 -0
- 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
|
@@ -86,7 +86,7 @@ export const AssistiveElement = React.forwardRef<
|
|
|
86
86
|
{assistiveVariant === "error" ? <ErrorIcon /> : <HelpIcon />}
|
|
87
87
|
</div>
|
|
88
88
|
)}
|
|
89
|
-
<
|
|
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:
|
|
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
|
-
|
|
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;
|