@recursica/mantine-adapter 0.18.0 → 0.19.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 +6 -0
- package/dist/mantine-adapter.cjs +1 -1
- package/dist/mantine-adapter.cjs.map +1 -1
- package/dist/mantine-adapter.css +1 -1
- package/dist/mantine-adapter.js +1642 -1357
- package/dist/mantine-adapter.js.map +1 -1
- package/dist/src/components/Modal/Modal.d.ts +39 -2
- package/dist/src/components/Modal/index.d.ts +1 -0
- package/dist/src/components/Popover/Popover.d.ts +55 -0
- package/dist/src/components/Popover/index.d.ts +1 -0
- package/dist/src/components/Timeline/Timeline.d.ts +14 -2
- package/dist/src/components/Timeline/TimelineItem.d.ts +25 -0
- package/dist/src/components/Toast/Toast.d.ts +21 -2
- package/dist/src/components/Toast/index.d.ts +1 -0
- package/dist/src/components/index.d.ts +2 -1
- package/package.json +1 -1
- package/src/components/Modal/MODAL_IMPLEMENTATION_NOTES.md +15 -0
- package/src/components/Modal/Modal.module.css +127 -0
- package/src/components/Modal/Modal.stories.tsx +66 -4
- package/src/components/Modal/Modal.tsx +218 -3
- package/src/components/Modal/index.ts +1 -0
- package/src/components/Popover/IMPLEMENTATION_NOTES.md +77 -0
- package/src/components/Popover/Popover.module.css +84 -0
- package/src/components/Popover/Popover.stories.tsx +133 -0
- package/src/components/Popover/Popover.tsx +156 -0
- package/src/components/Popover/index.ts +1 -0
- package/src/components/Timeline/TIMELINE_IMPLEMENTATION_NOTES.md +13 -0
- package/src/components/Timeline/Timeline.module.css +361 -0
- package/src/components/Timeline/Timeline.stories.tsx +114 -3
- package/src/components/Timeline/Timeline.tsx +81 -4
- package/src/components/Timeline/TimelineItem.tsx +102 -0
- package/src/components/Toast/TOAST_IMPLEMENTATION_NOTES.md +39 -0
- package/src/components/Toast/Toast.module.css +109 -0
- package/src/components/Toast/Toast.stories.tsx +57 -7
- package/src/components/Toast/Toast.tsx +90 -4
- package/src/components/Toast/index.ts +1 -0
- package/src/components/index.ts +2 -1
|
@@ -1,3 +1,40 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { Modal as MantineModal, ModalProps as MantineModalProps } from '@mantine/core';
|
|
3
|
+
import { RecursicaOverStyled } from '../../utils/filterStylingProps';
|
|
4
|
+
/**
|
|
5
|
+
* Properties for the strictly-tokenized Modal component.
|
|
6
|
+
* Native Mantine abstract properties like `size`, `radius`, and `shadow` have been stripped out
|
|
7
|
+
* because they directly conflict with the non-negotiable pixel boundaries defined in the Recursica UI Kit.
|
|
8
|
+
*/
|
|
9
|
+
export type ModalProps = RecursicaOverStyled<Omit<MantineModalProps, "size" | "radius" | "shadow">>;
|
|
10
|
+
/**
|
|
11
|
+
* The `Modal.Footer` provides a perfectly padded container for modal actions.
|
|
12
|
+
*
|
|
13
|
+
* **Recursica Abstract:**
|
|
14
|
+
* By default, this container aligns its children to the right (`justify-content: flex-end`)
|
|
15
|
+
* and applies the standard Figma button-gap spacing.
|
|
16
|
+
*
|
|
17
|
+
* > [!IMPORTANT]
|
|
18
|
+
* > **Button Hierarchy:** Recursica design language explicitly requires that the
|
|
19
|
+
* > primary action button MUST be the right-most element, with the secondary
|
|
20
|
+
* > (outline variant) action placed immediately to the left of it.
|
|
21
|
+
*/
|
|
22
|
+
declare const ModalFooter: React.ForwardRefExoticComponent<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
23
|
+
declare const ModalBody: React.ForwardRefExoticComponent<Omit<import('@mantine/core').ModalBodyProps & React.RefAttributes<HTMLDivElement> & {
|
|
24
|
+
component?: any;
|
|
25
|
+
renderRoot?: (props: Record<string, any>) => React.ReactNode;
|
|
26
|
+
}, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
27
|
+
interface ModalComponent extends React.ForwardRefExoticComponent<ModalProps & React.RefAttributes<HTMLDivElement>> {
|
|
28
|
+
Root: typeof MantineModal.Root;
|
|
29
|
+
Overlay: typeof MantineModal.Overlay;
|
|
30
|
+
Content: typeof MantineModal.Content;
|
|
31
|
+
Header: typeof MantineModal.Header;
|
|
32
|
+
Title: typeof MantineModal.Title;
|
|
33
|
+
CloseButton: typeof MantineModal.CloseButton;
|
|
34
|
+
Body: typeof ModalBody;
|
|
35
|
+
Footer: typeof ModalFooter;
|
|
36
|
+
}
|
|
37
|
+
export declare const Modal: ModalComponent & {
|
|
38
|
+
Footer: typeof ModalFooter;
|
|
39
|
+
};
|
|
40
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Modal';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { PopoverProps as MantinePopoverProps, PopoverTargetProps as MantinePopoverTargetProps, PopoverDropdownProps as MantinePopoverDropdownProps } from '@mantine/core';
|
|
2
|
+
import { RecursicaOverStyled } from '../../utils/filterStylingProps';
|
|
3
|
+
/**
|
|
4
|
+
* Recursica-specific props for Popover.
|
|
5
|
+
*/
|
|
6
|
+
export interface RecursicaPopoverProps {
|
|
7
|
+
/**
|
|
8
|
+
* Whether to display a beak (arrow) pointing from the dropdown to the target.
|
|
9
|
+
* This is the Recursica equivalent of Mantine's `withArrow`.
|
|
10
|
+
* When both `withBeak` and `withArrow` are provided, `withBeak` takes precedence.
|
|
11
|
+
*/
|
|
12
|
+
withBeak?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Recursica Popover component wrapping Mantine's composable Popover.
|
|
16
|
+
*
|
|
17
|
+
* Displays a dropdown panel when the user clicks or interacts with a target element.
|
|
18
|
+
* Uses the composable dot-notation pattern:
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <Popover withBeak>
|
|
21
|
+
* <Popover.Target>
|
|
22
|
+
* <Button>Click me</Button>
|
|
23
|
+
* </Popover.Target>
|
|
24
|
+
* <Popover.Dropdown>
|
|
25
|
+
* Content displayed in popover
|
|
26
|
+
* </Popover.Dropdown>
|
|
27
|
+
* </Popover>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export type PopoverProps = RecursicaOverStyled<MantinePopoverProps & RecursicaPopoverProps>;
|
|
31
|
+
declare const PopoverBase: {
|
|
32
|
+
({ overStyled, withBeak, ...rest }: PopoverProps): import("react/jsx-runtime").JSX.Element;
|
|
33
|
+
displayName: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Wrapper for the element that triggers the popover.
|
|
37
|
+
* Requires a single child element that supports ref forwarding.
|
|
38
|
+
*/
|
|
39
|
+
export type PopoverTargetProps = MantinePopoverTargetProps;
|
|
40
|
+
declare const PopoverTarget: {
|
|
41
|
+
(props: PopoverTargetProps): import("react/jsx-runtime").JSX.Element;
|
|
42
|
+
displayName: string;
|
|
43
|
+
};
|
|
44
|
+
/** The dropdown panel displayed from the popover. */
|
|
45
|
+
export type PopoverDropdownProps = RecursicaOverStyled<MantinePopoverDropdownProps>;
|
|
46
|
+
declare const PopoverDropdown: {
|
|
47
|
+
({ overStyled, ...rest }: PopoverDropdownProps): import("react/jsx-runtime").JSX.Element;
|
|
48
|
+
displayName: string;
|
|
49
|
+
};
|
|
50
|
+
type PopoverComponent = typeof PopoverBase & {
|
|
51
|
+
Target: typeof PopoverTarget;
|
|
52
|
+
Dropdown: typeof PopoverDropdown;
|
|
53
|
+
};
|
|
54
|
+
export declare const Popover: PopoverComponent;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Popover';
|
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { TimelineProps as MantineTimelineProps } from '@mantine/core';
|
|
3
|
+
import { RecursicaOverStyled } from '../../utils/filterStylingProps';
|
|
4
|
+
import { TimelineItem } from './TimelineItem';
|
|
5
|
+
/**
|
|
6
|
+
* Properties for the strictly-tokenized Timeline component.
|
|
7
|
+
* Native Mantine abstract properties like `color`, `radius`, `bulletSize`, and `lineWidth`
|
|
8
|
+
* have been stripped out to strictly enforce the structural mappings of the Recursica UI Kit.
|
|
9
|
+
*/
|
|
10
|
+
export type TimelineProps = RecursicaOverStyled<Omit<MantineTimelineProps, "color" | "radius" | "bulletSize" | "lineWidth">>;
|
|
11
|
+
interface TimelineComponent extends React.ForwardRefExoticComponent<TimelineProps & React.RefAttributes<HTMLDivElement>> {
|
|
12
|
+
Item: typeof TimelineItem;
|
|
13
|
+
}
|
|
14
|
+
export declare const Timeline: TimelineComponent;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
import { TimelineItemProps as MantineTimelineItemProps } from '@mantine/core';
|
|
3
|
+
import { RecursicaOverStyled } from '../../utils/filterStylingProps';
|
|
4
|
+
export interface RecursicaTimelineItemProps {
|
|
5
|
+
/**
|
|
6
|
+
* Timestamp text displayed below the description.
|
|
7
|
+
*/
|
|
8
|
+
timestamp?: React.ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* The structural styling variant of the bullet container.
|
|
11
|
+
* Maps to the UI Kit structural tokens.
|
|
12
|
+
* @default "default"
|
|
13
|
+
*/
|
|
14
|
+
bulletVariant?: "default" | "avatar" | "icon" | "icon-alternative";
|
|
15
|
+
}
|
|
16
|
+
export type TimelineItemProps = RecursicaOverStyled<Omit<MantineTimelineItemProps, "radius" | "color" | "lineVariant"> & RecursicaTimelineItemProps>;
|
|
17
|
+
/**
|
|
18
|
+
* The individual item component for the Timeline.
|
|
19
|
+
*
|
|
20
|
+
* **Recursica Abstract:**
|
|
21
|
+
* The `Timeline.Item` has been extended to support a `timestamp` string natively,
|
|
22
|
+
* rendering it below the body content. It also accepts a `bulletVariant` to morph
|
|
23
|
+
* the structural dimensions of the node circle automatically.
|
|
24
|
+
*/
|
|
25
|
+
export declare const TimelineItem: React.ForwardRefExoticComponent<TimelineItemProps & React.RefAttributes<HTMLDivElement>>;
|
|
@@ -1,3 +1,22 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { NotificationProps as MantineNotificationProps } from '@mantine/core';
|
|
3
|
+
import { RecursicaOverStyled } from '../../utils/filterStylingProps';
|
|
4
|
+
export interface RecursicaToastProps {
|
|
5
|
+
/**
|
|
6
|
+
* The visual variant of the toast.
|
|
7
|
+
* @default "default"
|
|
8
|
+
*/
|
|
9
|
+
variant?: "default";
|
|
10
|
+
/**
|
|
11
|
+
* Loading state is natively unsupported by Recursica UI Kit.
|
|
12
|
+
* If a loading state is required, pass a `<Loader size="sm" />` directly into the `icon` prop.
|
|
13
|
+
*/
|
|
14
|
+
loading?: false;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Toast component wrapping Mantine's Notification.
|
|
18
|
+
*
|
|
19
|
+
* Can be used as a standalone visual component to display a notification or message.
|
|
20
|
+
*/
|
|
21
|
+
export type ToastProps = RecursicaOverStyled<Omit<MantineNotificationProps, "color" | "radius" | "variant" | "loading"> & RecursicaToastProps>;
|
|
22
|
+
export declare const Toast: React.ForwardRefExoticComponent<ToastProps & React.RefAttributes<HTMLDivElement>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Toast';
|
|
@@ -24,6 +24,7 @@ export * from './Modal/Modal';
|
|
|
24
24
|
export * from './NumberInput/NumberInput';
|
|
25
25
|
export * from './Pagination/Pagination';
|
|
26
26
|
export * from './Panel/Panel';
|
|
27
|
+
export * from './Popover';
|
|
27
28
|
export * from './Radio/Radio';
|
|
28
29
|
export * from './Radio/RadioGroup';
|
|
29
30
|
export * from './ReadOnlyField';
|
|
@@ -41,6 +42,6 @@ export * from './TextField/TextField';
|
|
|
41
42
|
export * from './TimePicker/TimePicker';
|
|
42
43
|
export * from './Timeline/Timeline';
|
|
43
44
|
export * from './Title/Title';
|
|
44
|
-
export * from './Toast
|
|
45
|
+
export * from './Toast';
|
|
45
46
|
export * from './Tooltip/Tooltip';
|
|
46
47
|
export * from './TransferList/TransferList';
|
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Modal Implementation Notes
|
|
2
|
+
|
|
3
|
+
## Architecture
|
|
4
|
+
|
|
5
|
+
The `Modal` component strictly wraps Mantine's `<Modal>` primitive. We strip Mantine's abstract native styling props (`size`, `radius`, `shadow`) via the `overStyled` interface and strictly inject CSS variable definitions onto the internal node abstractions (`.content`, `.header`, `.body`, `.title`).
|
|
6
|
+
|
|
7
|
+
## Limitations & Structural Decisions
|
|
8
|
+
|
|
9
|
+
### 1. Stripped `size` Prop
|
|
10
|
+
|
|
11
|
+
Mantine natively exposes an abstract `size` prop (`"sm" | "md" | "lg" | "xl"`) that scales the Modal geometry. The Recursica UI Kit explicitly dictates strict geometric bounding boxes: `max-width: 960px` and `min-width: 304px`. To enforce absolute parity with the design system, the `size` prop has been intentionally omitted from the component's interface. The width of the Modal will scale fluidly strictly between these Figma-driven pixel limits.
|
|
12
|
+
|
|
13
|
+
### 2. Scroll Dividers behavior
|
|
14
|
+
|
|
15
|
+
Mantine internally handles scroll state natively, dynamically showing/hiding a divider line when content overflows in `.body`. This logic is tightly coupled to React DOM measurements internally. Our component inherits this dynamic behavior rather than statically rendering a permanent divider, matching Mantine's robust overflow UX. However, we aggressively override the generated `border-bottom` via CSS modules to ensure that when it _does_ appear, it correctly utilizes the `--recursica_ui-kit_components_modal_colors_scroll-divider` variable and `--recursica_ui-kit_components_modal_properties_scroll-divider-thickness` token.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
}
|
|
3
|
+
|
|
4
|
+
.inner {
|
|
5
|
+
/* Mantine applies padding to inner to keep modal away from edges. We rely on content constraints. */
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.content {
|
|
9
|
+
/* Geometric Bounds */
|
|
10
|
+
min-width: var(
|
|
11
|
+
--recursica_ui-kit_components_modal_properties_min-width
|
|
12
|
+
) !important;
|
|
13
|
+
max-width: var(
|
|
14
|
+
--recursica_ui-kit_components_modal_properties_max-width
|
|
15
|
+
) !important;
|
|
16
|
+
min-height: var(
|
|
17
|
+
--recursica_ui-kit_components_modal_properties_min-height
|
|
18
|
+
) !important;
|
|
19
|
+
|
|
20
|
+
/* Restrict max-height to either the UI Kit max or the viewport bounds to force internal scrolling */
|
|
21
|
+
max-height: min(
|
|
22
|
+
var(--recursica_ui-kit_components_modal_properties_max-height),
|
|
23
|
+
calc(100vh - 4rem)
|
|
24
|
+
) !important;
|
|
25
|
+
overflow: hidden !important;
|
|
26
|
+
|
|
27
|
+
/* Box model */
|
|
28
|
+
border-radius: var(
|
|
29
|
+
--recursica_ui-kit_components_modal_properties_border-radius
|
|
30
|
+
) !important;
|
|
31
|
+
border-width: var(
|
|
32
|
+
--recursica_ui-kit_components_modal_properties_border-size
|
|
33
|
+
) !important;
|
|
34
|
+
border-color: var(
|
|
35
|
+
--recursica_ui-kit_components_modal_colors_border-color
|
|
36
|
+
) !important;
|
|
37
|
+
border-style: solid !important;
|
|
38
|
+
|
|
39
|
+
/* Appearance */
|
|
40
|
+
background-color: var(
|
|
41
|
+
--recursica_ui-kit_components_modal_colors_background
|
|
42
|
+
) !important;
|
|
43
|
+
box-shadow: var(
|
|
44
|
+
--recursica_ui-kit_components_modal_properties_elevation
|
|
45
|
+
) !important;
|
|
46
|
+
|
|
47
|
+
/* Internal Flex container to trap header and let body scroll */
|
|
48
|
+
display: flex !important;
|
|
49
|
+
flex-direction: column !important;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.header {
|
|
53
|
+
padding: var(--recursica_ui-kit_components_modal_properties_vertical-padding)
|
|
54
|
+
var(--recursica_ui-kit_components_modal_properties_horizontal-padding) !important;
|
|
55
|
+
background-color: transparent !important; /* Inherit from content */
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.title {
|
|
59
|
+
/* Typography Class Injection */
|
|
60
|
+
composes: recursica_brand_typography_h2 from global;
|
|
61
|
+
color: var(--recursica_ui-kit_components_modal_colors_title) !important;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.bodyWrapper {
|
|
65
|
+
/* This is the container that overrides Mantine's native Body. It strips padding and acts as a flex column. */
|
|
66
|
+
padding: 0 !important;
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
flex: 1 1 auto;
|
|
70
|
+
overflow: hidden !important;
|
|
71
|
+
min-height: 0 !important; /* Critical for flex child scrolling constraint */
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.scrollArea {
|
|
75
|
+
padding: var(--recursica_ui-kit_components_modal_properties_vertical-padding)
|
|
76
|
+
var(--recursica_ui-kit_components_modal_properties_horizontal-padding) !important;
|
|
77
|
+
|
|
78
|
+
/* Internal scrolling */
|
|
79
|
+
flex: 1 1 auto;
|
|
80
|
+
overflow-y: auto !important;
|
|
81
|
+
min-height: 0 !important; /* Critical for flex child scrolling constraint */
|
|
82
|
+
|
|
83
|
+
/* Typography */
|
|
84
|
+
composes: recursica_brand_typography_body from global;
|
|
85
|
+
color: var(--recursica_ui-kit_components_modal_colors_content) !important;
|
|
86
|
+
|
|
87
|
+
/* Ensure button gap token is easily usable if developers use standard spacing */
|
|
88
|
+
--modal-button-gap: var(
|
|
89
|
+
--recursica_ui-kit_components_modal_properties_button-gap
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
/* Smooth divider transitions */
|
|
93
|
+
transition: border-color 0.15s ease;
|
|
94
|
+
border-top: var(
|
|
95
|
+
--recursica_ui-kit_components_modal_properties_scroll-divider-thickness
|
|
96
|
+
)
|
|
97
|
+
solid transparent !important;
|
|
98
|
+
border-bottom: var(
|
|
99
|
+
--recursica_ui-kit_components_modal_properties_scroll-divider-thickness
|
|
100
|
+
)
|
|
101
|
+
solid transparent !important;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.scrollArea[data-scrolled-top] {
|
|
105
|
+
border-top-color: var(
|
|
106
|
+
--recursica_ui-kit_components_modal_colors_scroll-divider
|
|
107
|
+
) !important;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.scrollArea[data-scrolled-bottom] {
|
|
111
|
+
border-bottom-color: var(
|
|
112
|
+
--recursica_ui-kit_components_modal_colors_scroll-divider
|
|
113
|
+
) !important;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.footer {
|
|
117
|
+
padding: var(--recursica_ui-kit_components_modal_properties_vertical-padding)
|
|
118
|
+
var(--recursica_ui-kit_components_modal_properties_horizontal-padding) !important;
|
|
119
|
+
background-color: transparent !important;
|
|
120
|
+
display: flex;
|
|
121
|
+
justify-content: flex-end;
|
|
122
|
+
gap: var(--recursica_ui-kit_components_modal_properties_button-gap);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.close {
|
|
126
|
+
/* Inherits standard icon button tokens natively, but we can override if required */
|
|
127
|
+
}
|
|
@@ -1,17 +1,79 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Modal } from "./Modal";
|
|
3
|
-
import {
|
|
3
|
+
import { Modal, type ModalProps } from "./Modal";
|
|
4
|
+
import { Button } from "../Button";
|
|
4
5
|
|
|
5
6
|
const meta: Meta<typeof Modal> = {
|
|
6
|
-
title: "UI-Kit
|
|
7
|
+
title: "UI-Kit/Modal",
|
|
7
8
|
component: Modal,
|
|
8
9
|
tags: ["autodocs"],
|
|
10
|
+
argTypes: {
|
|
11
|
+
opened: { table: { disable: true } },
|
|
12
|
+
onClose: { table: { disable: true } },
|
|
13
|
+
defaultChecked: { table: { disable: true } },
|
|
14
|
+
},
|
|
9
15
|
};
|
|
10
16
|
|
|
11
17
|
export default meta;
|
|
12
18
|
|
|
13
19
|
type Story = StoryObj<typeof Modal>;
|
|
14
20
|
|
|
21
|
+
const DefaultWrapper = (args: ModalProps) => {
|
|
22
|
+
const [opened, setOpened] = useState(false);
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<Modal {...args} opened={opened} onClose={() => setOpened(false)}>
|
|
26
|
+
Please log in to continue accessing this feature.
|
|
27
|
+
<Modal.Footer>
|
|
28
|
+
<Button variant="outline" onClick={() => setOpened(false)}>
|
|
29
|
+
Cancel
|
|
30
|
+
</Button>
|
|
31
|
+
<Button onClick={() => setOpened(false)}>Confirm</Button>
|
|
32
|
+
</Modal.Footer>
|
|
33
|
+
</Modal>
|
|
34
|
+
<Button onClick={() => setOpened(true)}>Open Modal</Button>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
15
39
|
export const Default: Story = {
|
|
16
|
-
|
|
40
|
+
args: {
|
|
41
|
+
title: "Authentication Required",
|
|
42
|
+
},
|
|
43
|
+
render: (args) => <DefaultWrapper {...args} />,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const ScrollingWrapper = (args: ModalProps) => {
|
|
47
|
+
const [opened, setOpened] = useState(false);
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<Modal {...args} opened={opened} onClose={() => setOpened(false)}>
|
|
51
|
+
<p>
|
|
52
|
+
This modal demonstrates the dynamically injected scroll dividers. When
|
|
53
|
+
this body content overflows, the borders between the Header and Footer
|
|
54
|
+
automatically appear to define the scrolling boundary.
|
|
55
|
+
</p>
|
|
56
|
+
{Array(20)
|
|
57
|
+
.fill(0)
|
|
58
|
+
.map((_, i) => (
|
|
59
|
+
<p key={i}>Scrolling content block {i + 1}...</p>
|
|
60
|
+
))}
|
|
61
|
+
{/* The new Footer abstraction */}
|
|
62
|
+
<Modal.Footer>
|
|
63
|
+
<Button variant="outline" onClick={() => setOpened(false)}>
|
|
64
|
+
Decline
|
|
65
|
+
</Button>
|
|
66
|
+
<Button onClick={() => setOpened(false)}>Accept Terms</Button>
|
|
67
|
+
</Modal.Footer>
|
|
68
|
+
</Modal>
|
|
69
|
+
<Button onClick={() => setOpened(true)}>Open Scrolling Modal</Button>
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const ScrollingContent: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
title: "Terms and Conditions",
|
|
77
|
+
},
|
|
78
|
+
render: (args) => <ScrollingWrapper {...args} />,
|
|
17
79
|
};
|
|
@@ -1,7 +1,222 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Modal as MantineModal,
|
|
4
|
+
type ModalProps as MantineModalProps,
|
|
5
|
+
} from "@mantine/core";
|
|
6
|
+
import {
|
|
7
|
+
filterStylingProps,
|
|
8
|
+
type RecursicaOverStyled,
|
|
9
|
+
} from "../../utils/filterStylingProps";
|
|
10
|
+
import styles from "./Modal.module.css";
|
|
2
11
|
|
|
3
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Properties for the strictly-tokenized Modal component.
|
|
14
|
+
* Native Mantine abstract properties like `size`, `radius`, and `shadow` have been stripped out
|
|
15
|
+
* because they directly conflict with the non-negotiable pixel boundaries defined in the Recursica UI Kit.
|
|
16
|
+
*/
|
|
17
|
+
export type ModalProps = RecursicaOverStyled<
|
|
18
|
+
Omit<MantineModalProps, "size" | "radius" | "shadow">
|
|
19
|
+
>;
|
|
4
20
|
|
|
5
|
-
|
|
6
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The `Modal` component displays a window overlaid on the primary viewport.
|
|
23
|
+
*
|
|
24
|
+
* **Recursica Abstract:**
|
|
25
|
+
* This component acts as a strict structural wrapper around Mantine's `<Modal>`.
|
|
26
|
+
* It automatically enforces Figma UI Kit geometries (`max-width: 960px`, `min-width: 304px`)
|
|
27
|
+
* and handles internal body scrolling explicitly via CSS module overrides.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <Modal opened={opened} onClose={close} title="Hello World">
|
|
32
|
+
* Modal Content
|
|
33
|
+
* </Modal>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
const ModalInner = React.forwardRef<HTMLDivElement, ModalProps>(function Modal(
|
|
37
|
+
{
|
|
38
|
+
overStyled = false,
|
|
39
|
+
children,
|
|
40
|
+
title,
|
|
41
|
+
withCloseButton = true,
|
|
42
|
+
overlayProps,
|
|
43
|
+
withOverlay = true,
|
|
44
|
+
closeButtonProps,
|
|
45
|
+
...rest
|
|
46
|
+
},
|
|
47
|
+
ref,
|
|
48
|
+
) {
|
|
49
|
+
const sanitizedProps = filterStylingProps(rest, overStyled);
|
|
50
|
+
|
|
51
|
+
const mergedClassNames: Partial<Record<string, string>> = {
|
|
52
|
+
root: styles.root,
|
|
53
|
+
inner: styles.inner,
|
|
54
|
+
content: styles.content,
|
|
55
|
+
header: styles.header,
|
|
56
|
+
title: styles.title,
|
|
57
|
+
close: styles.close,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const classNamesProp = (sanitizedProps as Record<string, unknown>).classNames;
|
|
61
|
+
if (
|
|
62
|
+
classNamesProp &&
|
|
63
|
+
typeof classNamesProp === "object" &&
|
|
64
|
+
!Array.isArray(classNamesProp)
|
|
65
|
+
) {
|
|
66
|
+
const o = classNamesProp as Record<string, string>;
|
|
67
|
+
Object.keys(o).forEach((key) => {
|
|
68
|
+
if (mergedClassNames[key]) {
|
|
69
|
+
mergedClassNames[key] = `${mergedClassNames[key]} ${o[key]}`;
|
|
70
|
+
} else {
|
|
71
|
+
mergedClassNames[key] = o[key];
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<MantineModal.Root
|
|
78
|
+
ref={ref}
|
|
79
|
+
classNames={mergedClassNames}
|
|
80
|
+
{...(sanitizedProps as unknown as Omit<
|
|
81
|
+
MantineModalProps,
|
|
82
|
+
"size" | "radius" | "shadow"
|
|
83
|
+
>)}
|
|
84
|
+
>
|
|
85
|
+
{withOverlay && <MantineModal.Overlay {...overlayProps} />}
|
|
86
|
+
<MantineModal.Content>
|
|
87
|
+
{(title || withCloseButton) && (
|
|
88
|
+
<MantineModal.Header>
|
|
89
|
+
{title && <MantineModal.Title>{title}</MantineModal.Title>}
|
|
90
|
+
{withCloseButton && (
|
|
91
|
+
<MantineModal.CloseButton {...closeButtonProps} />
|
|
92
|
+
)}
|
|
93
|
+
</MantineModal.Header>
|
|
94
|
+
)}
|
|
95
|
+
<ModalBody>{children}</ModalBody>
|
|
96
|
+
</MantineModal.Content>
|
|
97
|
+
</MantineModal.Root>
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
ModalInner.displayName = "Modal";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The `Modal.Footer` provides a perfectly padded container for modal actions.
|
|
105
|
+
*
|
|
106
|
+
* **Recursica Abstract:**
|
|
107
|
+
* By default, this container aligns its children to the right (`justify-content: flex-end`)
|
|
108
|
+
* and applies the standard Figma button-gap spacing.
|
|
109
|
+
*
|
|
110
|
+
* > [!IMPORTANT]
|
|
111
|
+
* > **Button Hierarchy:** Recursica design language explicitly requires that the
|
|
112
|
+
* > primary action button MUST be the right-most element, with the secondary
|
|
113
|
+
* > (outline variant) action placed immediately to the left of it.
|
|
114
|
+
*/
|
|
115
|
+
const ModalFooter = React.forwardRef<
|
|
116
|
+
HTMLDivElement,
|
|
117
|
+
React.ComponentPropsWithoutRef<"div">
|
|
118
|
+
>(function ModalFooter({ className, ...rest }, ref) {
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
ref={ref}
|
|
122
|
+
className={`${styles.footer} ${className || ""}`}
|
|
123
|
+
{...rest}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
ModalFooter.displayName = "Modal.Footer";
|
|
128
|
+
|
|
129
|
+
const ModalBody = React.forwardRef<
|
|
130
|
+
HTMLDivElement,
|
|
131
|
+
React.ComponentPropsWithoutRef<typeof MantineModal.Body>
|
|
132
|
+
>(function ModalBody({ className, onScroll, children, ...rest }, ref) {
|
|
133
|
+
const internalRef = React.useRef<HTMLDivElement>(null);
|
|
134
|
+
const [scrolledTop, setScrolledTop] = React.useState(false);
|
|
135
|
+
const [scrolledBottom, setScrolledBottom] = React.useState(false);
|
|
136
|
+
|
|
137
|
+
const checkScroll = React.useCallback(() => {
|
|
138
|
+
if (internalRef.current) {
|
|
139
|
+
const { scrollTop, scrollHeight, clientHeight } = internalRef.current;
|
|
140
|
+
setScrolledTop(scrollTop > 0);
|
|
141
|
+
setScrolledBottom(Math.ceil(scrollTop + clientHeight) < scrollHeight);
|
|
142
|
+
}
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
// Re-check scroll on mount and window resize
|
|
146
|
+
React.useEffect(() => {
|
|
147
|
+
checkScroll();
|
|
148
|
+
window.addEventListener("resize", checkScroll);
|
|
149
|
+
return () => window.removeEventListener("resize", checkScroll);
|
|
150
|
+
}, [checkScroll, children]);
|
|
151
|
+
|
|
152
|
+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
153
|
+
checkScroll();
|
|
154
|
+
onScroll?.(e);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Intercept children to pull Modal.Footer out of the scrolling container
|
|
158
|
+
let footer: React.ReactNode = null;
|
|
159
|
+
const bodyChildren: React.ReactNode[] = [];
|
|
160
|
+
|
|
161
|
+
React.Children.forEach(children, (child) => {
|
|
162
|
+
if (
|
|
163
|
+
React.isValidElement(child) &&
|
|
164
|
+
(child.type === ModalFooter ||
|
|
165
|
+
(child.type as React.ComponentType)?.displayName === "Modal.Footer")
|
|
166
|
+
) {
|
|
167
|
+
footer = child;
|
|
168
|
+
} else {
|
|
169
|
+
bodyChildren.push(child);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<MantineModal.Body
|
|
175
|
+
{...rest}
|
|
176
|
+
ref={(node) => {
|
|
177
|
+
if (typeof ref === "function") ref(node);
|
|
178
|
+
else if (ref)
|
|
179
|
+
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
180
|
+
}}
|
|
181
|
+
className={`${styles.bodyWrapper} ${className || ""}`}
|
|
182
|
+
>
|
|
183
|
+
<div
|
|
184
|
+
ref={internalRef}
|
|
185
|
+
onScroll={handleScroll}
|
|
186
|
+
data-scrolled-top={scrolledTop || undefined}
|
|
187
|
+
data-scrolled-bottom={scrolledBottom || undefined}
|
|
188
|
+
className={styles.scrollArea}
|
|
189
|
+
>
|
|
190
|
+
{bodyChildren}
|
|
191
|
+
</div>
|
|
192
|
+
{footer}
|
|
193
|
+
</MantineModal.Body>
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
ModalBody.displayName = "Modal.Body";
|
|
197
|
+
|
|
198
|
+
interface ModalComponent
|
|
199
|
+
extends React.ForwardRefExoticComponent<
|
|
200
|
+
ModalProps & React.RefAttributes<HTMLDivElement>
|
|
201
|
+
> {
|
|
202
|
+
Root: typeof MantineModal.Root;
|
|
203
|
+
Overlay: typeof MantineModal.Overlay;
|
|
204
|
+
Content: typeof MantineModal.Content;
|
|
205
|
+
Header: typeof MantineModal.Header;
|
|
206
|
+
Title: typeof MantineModal.Title;
|
|
207
|
+
CloseButton: typeof MantineModal.CloseButton;
|
|
208
|
+
Body: typeof ModalBody;
|
|
209
|
+
Footer: typeof ModalFooter;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const Modal = ModalInner as ModalComponent & {
|
|
213
|
+
Footer: typeof ModalFooter;
|
|
7
214
|
};
|
|
215
|
+
Modal.Root = MantineModal.Root;
|
|
216
|
+
Modal.Overlay = MantineModal.Overlay;
|
|
217
|
+
Modal.Content = MantineModal.Content;
|
|
218
|
+
Modal.Header = MantineModal.Header;
|
|
219
|
+
Modal.Title = MantineModal.Title;
|
|
220
|
+
Modal.CloseButton = MantineModal.CloseButton;
|
|
221
|
+
Modal.Body = ModalBody;
|
|
222
|
+
Modal.Footer = ModalFooter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./Modal";
|