diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..40c1cfa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false + +[*.mk] +indent_style = tab +indent_size = 2 + +[makefile] +indent_style = tab +indent_size = 2 \ No newline at end of file diff --git a/changelog-notes.md b/changelog-notes.md new file mode 100644 index 0000000..de21b54 --- /dev/null +++ b/changelog-notes.md @@ -0,0 +1,3 @@ +- added fit-content option to Flex +- added role to Flex +- added ButtonGroup diff --git a/makefile b/makefile index c31c8fd..d0d56bd 100644 --- a/makefile +++ b/makefile @@ -28,4 +28,12 @@ endef define echo_purple echo -e ${PURPLE}$1${NC} -endef \ No newline at end of file +endef + +git-sync-prod: + @echo "Syncing with prod branch" + git fetch origin production:production + +git-sync-dev: + @echo "Syncing with dev branch" + git fetch origin development:development diff --git a/package-lock.json b/package-lock.json index 4d6eab1..6b0aa6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "fluentui-helpers", - "version": "0.1.0", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fluentui-helpers", - "version": "0.1.0", + "version": "0.2.2", "license": "MIT", "devDependencies": { "@babel/preset-env": "^7.25.9", "@babel/preset-typescript": "^7.25.9", "@fluentui/react-components": "^9.55.0", "@fluentui/react-icons": "^2.0.261", + "@fluentui/react-motion-components-preview": "^0.4.1", "@griffel/react": "^1.5.25", "@griffel/tag-processor": "^1.0.7", "@griffel/vite-plugin": "^0.1.7", @@ -63,9 +64,10 @@ "vite-tsconfig-paths": "^5.0.1" }, "peerDependencies": { - "@fluentui/react-components": "^9.55.0", - "@fluentui/react-icons": "^2.0.261", - "@griffel/react": "^1.5.25", + "@fluentui/react-components": "^9", + "@fluentui/react-icons": "^2", + "@fluentui/react-motion-components-preview": "^0.4.1", + "@griffel/react": "^1.5", "react": "^18 || ^17", "react-dom": "^18 || ^17" } diff --git a/package.json b/package.json index 6b8346f..550bab9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fluentui-helpers", - "version": "0.2.2", + "version": "0.3.0", "description": "Helper library for microsofts fluentui react library", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/src/components/index.ts b/src/components/index.ts index 0d16f39..0f05206 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1 +1,2 @@ +export { ButtonGroup } from "@components/molecules"; export { Flex } from "@components/layout"; diff --git a/src/components/layout/Flex/func.tsx b/src/components/layout/Flex/func.tsx index e68592e..ee8f9ed 100644 --- a/src/components/layout/Flex/func.tsx +++ b/src/components/layout/Flex/func.tsx @@ -28,6 +28,7 @@ import type { type TProps = { children: ReactNode; + role?: string; position?: TFlexPosition; direction?: TFlexDirection; justifyContent?: TFlexOptionContent; @@ -124,6 +125,7 @@ export default function Flex({ className = undefined, testId = undefined, children, + role = undefined, ...rest }: TProps) { const flexBoxClass = useFlexBox( @@ -149,6 +151,7 @@ export default function Flex({ // eslint-disable-next-line react/jsx-props-no-spreading {...ariaProps} data-testid={testId} + role={role} className={mergeClasses( positionClass, flexBoxClass, diff --git a/src/components/layout/Flex/styles/dimensions.ts b/src/components/layout/Flex/styles/dimensions.ts index 356a216..a945d56 100644 --- a/src/components/layout/Flex/styles/dimensions.ts +++ b/src/components/layout/Flex/styles/dimensions.ts @@ -4,6 +4,9 @@ const useDimensionClasses = makeStyles({ autoWidth: { width: "auto", }, + fitContentWidth: { + width: "fit-content", + }, "25%Width": { width: "25%", }, @@ -17,6 +20,9 @@ const useDimensionClasses = makeStyles({ width: "100%", }, + fitContentHeight: { + height: "fit-content", + }, autoHeight: { height: "auto", }, diff --git a/src/components/layout/Flex/tests.tsx b/src/components/layout/Flex/tests.tsx index 74a288f..2a5d2b6 100644 --- a/src/components/layout/Flex/tests.tsx +++ b/src/components/layout/Flex/tests.tsx @@ -359,10 +359,10 @@ describe("Flex", () => { const FlexElement = screen.getByText("FlexChild"); expect(FlexElement).toHaveStyle("height: 75%"); }); - it("should render with height auto", () => { - render(FlexChild); + it("should render with height fitContent", () => { + render(FlexChild); const FlexElement = screen.getByText("FlexChild"); - expect(FlexElement).toHaveStyle("height: auto"); + expect(FlexElement).toHaveStyle("height: fit-content"); }); }); describe("for shWidth", () => { @@ -386,10 +386,10 @@ describe("Flex", () => { const FlexElement = screen.getByText("FlexChild"); expect(FlexElement).toHaveStyle("width: 75%"); }); - it("should render with width auto", () => { - render(FlexChild); + it("should render with width fitContent", () => { + render(FlexChild); const FlexElement = screen.getByText("FlexChild"); - expect(FlexElement).toHaveStyle("width: auto"); + expect(FlexElement).toHaveStyle("width: fit-content"); }); }); describe("for className", () => { diff --git a/src/components/layout/Flex/types.ts b/src/components/layout/Flex/types.ts index a5843bf..74d0591 100644 --- a/src/components/layout/Flex/types.ts +++ b/src/components/layout/Flex/types.ts @@ -31,7 +31,14 @@ export type TFlexBasis = | "fitContent" | "content" | "0"; -export type TFlexShorthandDimensions = "25%" | "50%" | "75%" | "100%" | "auto"; + +export type TFlexShorthandDimensions = + | "25%" + | "50%" + | "75%" + | "100%" + | "auto" + | "fitContent"; export type TFlexPosition = | "-moz-initial" diff --git a/src/components/molecules/ButtonGroup/func.tsx b/src/components/molecules/ButtonGroup/func.tsx new file mode 100644 index 0000000..95bca00 --- /dev/null +++ b/src/components/molecules/ButtonGroup/func.tsx @@ -0,0 +1,52 @@ +import type { JSX } from "react"; + +import { Flex } from "@components/layout"; +import useButtonGroupClasses from "@components/molecules/ButtonGroup/styles"; + +type TProps = { + children: JSX.Element[]; + ariaLabel?: string; +}; + +/** + * @description + * - a very simple wrapper to get started with a button group + * - will make sure that the first and last childs borderRadius is preserved + * - while the ones in between are normalized and the border is removed, to avoid double borders + * - all subsequent logic needs to be handled by the consumer, there are examples in the storybook + * + * @hints + * - make sure the buttons are of the same size + * - make sure the buttons on the edges have the same shape + * - make sure that when disabling a Button, you use the `disableFosusable` prop, in order to ensure consistent tab-index order + * - use appearance="primary" for the currently active button and the rest to default "outline" + * - probably use it in a XOR fashion, so that only one button is active at a time + * + * @accessibility + * - in general should not be used for 100% accesibility proof interactions + * - as its not very clear how to indicate the current state to screen readers, because the buttons dont have an active state + * + * @props + * - `children`: expects the Button component, at least two + * - `ariaLabel`: optional, used for accessibility + * + * @default + * arialLabel = "Button group" + * + */ +export default function ButtonGroup({ + children, + ariaLabel = "Button group", +}: TProps): JSX.Element { + const classes = useButtonGroupClasses(); + return ( + + {children} + + ); +} diff --git a/src/components/molecules/ButtonGroup/index.ts b/src/components/molecules/ButtonGroup/index.ts new file mode 100644 index 0000000..10de8d5 --- /dev/null +++ b/src/components/molecules/ButtonGroup/index.ts @@ -0,0 +1,3 @@ +import ButtonGroup from "@components/molecules/ButtonGroup/func"; + +export default ButtonGroup; diff --git a/src/components/molecules/ButtonGroup/stories.tsx b/src/components/molecules/ButtonGroup/stories.tsx new file mode 100644 index 0000000..99e8aa9 --- /dev/null +++ b/src/components/molecules/ButtonGroup/stories.tsx @@ -0,0 +1,266 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { CalendarMonthRegular } from "@fluentui/react-icons"; +import { Button, Tooltip } from "@fluentui/react-components"; + +import { Flex } from "@components/layout"; +import ButtonGroup from "@components/molecules/ButtonGroup"; +import useFuiProviderNode from "@hooks/useFuiProviderNode"; + +const meta: Meta = { + title: "Components/Molecules/ButtonGroup", + component: ButtonGroup, + args: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Index: Story = { + render: () => { + const [activeOption, setActiveOption] = useState(1); + const { fuiProviderNode } = useFuiProviderNode(); + + return ( + + + + + + + + ); + }, + parameters: { + docs: { + source: { + code: ` +import { useState } from "react"; +import { Button, Tooltip } from "@fluentui/react-components"; +import { CalendarMonthRegular } from "@fluentui/react-icons"; +import ButtonGroup from "@components/molecules/ButtonGroup"; +import useFuiProviderNode from "@hooks/useFuiProviderNode"; + +const Example = () => { + const [activeOption, setActiveOption] = useState(1); + const { fuiProviderNode } = useFuiProviderNode(); + + return ( + + + + + + + + ); +}; + +export default Example; + `, + }, + }, + }, +}; + +export const Sizes: Story = { + render: () => { + return ( + + + + + + + + + + + + + + + + + + ); + }, + parameters: { + docs: { + source: { + code: ` +import { Button } from "@fluentui/react-components"; +import ButtonGroup from "@components/molecules/ButtonGroup"; +import { Flex } from "@components/layout"; + +const Example = () => { + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default Example; + `, + }, + }, + }, +}; + +export const Shapes: Story = { + render: () => { + return ( + + + + + + + + + + + + + + + + + + ); + }, + parameters: { + docs: { + source: { + code: ` +import { Button } from "@fluentui/react-components"; +import ButtonGroup from "@components/molecules/ButtonGroup"; +import { Flex } from "@components/layout"; + +const Example = () => { + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default Example; + `, + }, + }, + }, +}; diff --git a/src/components/molecules/ButtonGroup/styles.ts b/src/components/molecules/ButtonGroup/styles.ts new file mode 100644 index 0000000..04656ba --- /dev/null +++ b/src/components/molecules/ButtonGroup/styles.ts @@ -0,0 +1,21 @@ +import { makeStyles } from "@fluentui/react-components"; + +const useButtonGroupClasses = makeStyles({ + root: { + "& > *:first-child": { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + "& > *:not(:first-child):not(:last-child)": { + borderRadius: 0, + borderLeft: "none", + }, + "& > *:last-child": { + borderLeft: "none", + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + }, +}); + +export default useButtonGroupClasses; diff --git a/src/components/molecules/ButtonGroup/tests.tsx b/src/components/molecules/ButtonGroup/tests.tsx new file mode 100644 index 0000000..0208cae --- /dev/null +++ b/src/components/molecules/ButtonGroup/tests.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@test-utils"; +import "@testing-library/jest-dom"; + +import { Button } from "@fluentui/react-components"; +import ButtonGroup from "@components/molecules/ButtonGroup"; + +describe("ButtonGroup", () => { + it("should render with defaults", () => { + render( + + + + , + ); + const ButtonGroupElement = screen.getByRole("group"); + expect(ButtonGroupElement).toBeInTheDocument(); + expect(ButtonGroupElement).toHaveAttribute("aria-label", "Button group"); + }); + it("should render with custom aria label", () => { + render( + + + + , + ); + const ButtonGroupElement = screen.getByRole("group"); + expect(ButtonGroupElement).toBeInTheDocument(); + expect(ButtonGroupElement).toHaveAttribute( + "aria-label", + "Custom aria label", + ); + }); + it("should render the given buttons with appropriate styles", () => { + render( + + + + + , + ); + const ButtonOne = screen.getByText("Button 1"); + expect(ButtonOne).toHaveStyle("border-radius: var(--borderRadiusMedium)"); + expect(ButtonOne).toHaveStyle("border-top-right-radius: 0"); + expect(ButtonOne).toHaveStyle("border-bottom-right-radius: 0"); + + const ButtonTwo = screen.getByText("Button 2"); + expect(ButtonTwo).toHaveStyle("border-radius: 0"); + expect(ButtonTwo).toHaveStyle("border-left: none"); + + const ButtonThree = screen.getByText("Button 3"); + expect(ButtonThree).toHaveStyle("border-radius: var(--borderRadiusMedium)"); + expect(ButtonThree).toHaveStyle("border-top-left-radius: 0"); + expect(ButtonThree).toHaveStyle("border-bottom-left-radius: 0"); + }); +}); diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts new file mode 100644 index 0000000..ff9a454 --- /dev/null +++ b/src/components/molecules/index.ts @@ -0,0 +1 @@ +export { default as ButtonGroup } from "@components/molecules/ButtonGroup"; diff --git a/src/index.ts b/src/index.ts index 37f88b8..e5c2b27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ -export { EThemeSpacing, EThemeIconSizes, EThemeDimensions } from "@theme/index"; -export { useFuiProviderNode } from "@hooks/index"; -export { Flex } from "@components/index"; +export { EThemeSpacing, EThemeIconSizes, EThemeDimensions } from "@theme"; +export { useFuiProviderNode } from "@hooks"; +export { Flex, ButtonGroup } from "@components"; +export { useModalContext, ModalAnchor, ModalContextProvider } from "@preview"; diff --git a/src/preview/elevated-modal/ModalAnchor/anchor.css b/src/preview/elevated-modal/ModalAnchor/anchor.css new file mode 100644 index 0000000..938c87f --- /dev/null +++ b/src/preview/elevated-modal/ModalAnchor/anchor.css @@ -0,0 +1,7 @@ +#modal-anchor-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/src/preview/elevated-modal/ModalAnchor/branches/Trapped.tsx b/src/preview/elevated-modal/ModalAnchor/branches/Trapped.tsx new file mode 100644 index 0000000..fbb5134 --- /dev/null +++ b/src/preview/elevated-modal/ModalAnchor/branches/Trapped.tsx @@ -0,0 +1,56 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable consistent-return */ +import { useEffect, useRef } from "react"; +import { useModalContext } from "@preview/elevated-modal/provider"; +import constants from "@preview/elevated-modal/constants"; + +export default function Trapped() { + const { Modal, isActivated } = useModalContext(); + const modalRef = useRef(null); + + useEffect(() => { + if (!isActivated || !modalRef.current) return; + + const focusableElements = modalRef.current.querySelectorAll( + 'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex]:not([tabindex="-1"])', + ); + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + const trapFocus = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + lastElement.focus(); + e.preventDefault(); + } + } else if (document.activeElement === lastElement) { + firstElement.focus(); + e.preventDefault(); + } + }; + + modalRef.current.addEventListener("keydown", trapFocus); + + // Focus the first element when the modal is activated + firstElement.focus(); + + return () => { + modalRef.current?.removeEventListener("keydown", trapFocus); + }; + }, [isActivated]); + + return ( + isActivated && ( + + ) + ); +} diff --git a/src/preview/elevated-modal/ModalAnchor/branches/Untrapped.tsx b/src/preview/elevated-modal/ModalAnchor/branches/Untrapped.tsx new file mode 100644 index 0000000..23d2b0d --- /dev/null +++ b/src/preview/elevated-modal/ModalAnchor/branches/Untrapped.tsx @@ -0,0 +1,20 @@ +import { useModalContext } from "@preview/elevated-modal/provider"; + +import constants from "@preview/elevated-modal/constants"; + +export default function Untrapped() { + const { Modal, isActivated } = useModalContext(); + + return ( + isActivated && ( + + ) + ); +} diff --git a/src/preview/elevated-modal/ModalAnchor/branches/index.ts b/src/preview/elevated-modal/ModalAnchor/branches/index.ts new file mode 100644 index 0000000..c27768e --- /dev/null +++ b/src/preview/elevated-modal/ModalAnchor/branches/index.ts @@ -0,0 +1,2 @@ +export { default as UntrappedBranch } from "@previewelevated-modal/ModalAnchor/branches/Untrapped"; +export { default as TrappedBranch } from "@previewelevated-modal/ModalAnchor/branches/Trapped"; diff --git a/src/preview/elevated-modal/ModalAnchor/func.tsx b/src/preview/elevated-modal/ModalAnchor/func.tsx new file mode 100644 index 0000000..bca9ec4 --- /dev/null +++ b/src/preview/elevated-modal/ModalAnchor/func.tsx @@ -0,0 +1,14 @@ +import "./anchor.css"; + +import { + TrappedBranch, + UntrappedBranch, +} from "@previewelevated-modal/ModalAnchor/branches"; + +type TProps = { + disableTrapping?: boolean; +}; + +export default function ModalAnchor({ disableTrapping = false }: TProps) { + return disableTrapping ? : ; +} diff --git a/src/preview/elevated-modal/ModalAnchor/index.ts b/src/preview/elevated-modal/ModalAnchor/index.ts new file mode 100644 index 0000000..4c61596 --- /dev/null +++ b/src/preview/elevated-modal/ModalAnchor/index.ts @@ -0,0 +1,3 @@ +import ModalAnchor from "@previewelevated-modal/ModalAnchor/func"; + +export default ModalAnchor; diff --git a/src/preview/elevated-modal/StrippedDialogSurface/func.tsx b/src/preview/elevated-modal/StrippedDialogSurface/func.tsx new file mode 100644 index 0000000..816e5d0 --- /dev/null +++ b/src/preview/elevated-modal/StrippedDialogSurface/func.tsx @@ -0,0 +1,62 @@ +import type { JSX } from "react"; + +import { mergeClasses } from "@fluentui/react-components"; + +import { Flex } from "@components/layout"; + +import useStrippedDialogSurfaceClasses from "@previewelevated-modal/StrippedDialogSurface/styles"; + +type TProps = { + enhancementOptions?: { + rootSurfaceStyles?: boolean; + defaultDimensions?: boolean; + parentCentering?: boolean; + parentDimming?: boolean; + }; + children: JSX.Element; + className?: string; +}; + +export default function StrippedDialogSurface({ + enhancementOptions = { + rootSurfaceStyles: true, + defaultDimensions: true, + parentCentering: true, + parentDimming: true, + }, + children, + className = undefined, +}: TProps): JSX.Element { + const classes = useStrippedDialogSurfaceClasses(); + const bareSurface = ( +
+ {children} +
+ ); + return enhancementOptions.parentCentering ? ( + + {bareSurface} + + ) : ( + bareSurface + ); +} diff --git a/src/preview/elevated-modal/StrippedDialogSurface/index.ts b/src/preview/elevated-modal/StrippedDialogSurface/index.ts new file mode 100644 index 0000000..79777fc --- /dev/null +++ b/src/preview/elevated-modal/StrippedDialogSurface/index.ts @@ -0,0 +1,3 @@ +import StrippedDialogSurface from "@preview/elevated-modal/StrippedDialogSurface/func"; + +export default StrippedDialogSurface; diff --git a/src/preview/elevated-modal/StrippedDialogSurface/styles.ts b/src/preview/elevated-modal/StrippedDialogSurface/styles.ts new file mode 100644 index 0000000..48eaadd --- /dev/null +++ b/src/preview/elevated-modal/StrippedDialogSurface/styles.ts @@ -0,0 +1,29 @@ +import { makeStyles, tokens } from "@fluentui/react-components"; +import { EThemeSpacing } from "@theme/tokens"; + +const useElevatedModalClasses = makeStyles({ + root: { + padding: EThemeSpacing.XXL, + border: `1px solid ${tokens.colorTransparentStroke}`, + borderRadius: tokens.borderRadiusXLarge, + boxShadow: tokens.shadow64, + backgroundColor: tokens.colorNeutralBackground1, + }, + defaultDimensions: { + maxWidth: "600px", + height: "fit-content", + }, + centerRoot: { + // fade in animation for the modal + backgroundColor: "rgba(0, 0, 0, 0.4)", + // animationName: { + // to: { + // backgroundColor: "rgba(0, 0, 0, 0.4)", + // }, + // }, + // animationDuration: "0.5s", + // animationFillMode: "forwards", + }, +}); + +export default useElevatedModalClasses; diff --git a/src/preview/elevated-modal/constants.ts b/src/preview/elevated-modal/constants.ts new file mode 100644 index 0000000..bc63a60 --- /dev/null +++ b/src/preview/elevated-modal/constants.ts @@ -0,0 +1,3 @@ +export default { + modalAnchorContainerId: "modal-anchor-container", +}; diff --git a/src/preview/elevated-modal/index.ts b/src/preview/elevated-modal/index.ts new file mode 100644 index 0000000..a03e292 --- /dev/null +++ b/src/preview/elevated-modal/index.ts @@ -0,0 +1,18 @@ +import { + useModalContext, + ModalContextProvider, +} from "@preview/elevated-modal/provider"; + +import ModalAnchor from "@preview/elevated-modal/ModalAnchor"; + +import constants from "@preview/elevated-modal/constants"; + +import StrippedDialogSurface from "@previewelevated-modal/StrippedDialogSurface"; + +export { + useModalContext, + ModalContextProvider, + ModalAnchor, + constants, + StrippedDialogSurface as EXP_StrippedDialogSurface, +}; diff --git a/src/preview/elevated-modal/provider.tsx b/src/preview/elevated-modal/provider.tsx new file mode 100644 index 0000000..6d61b8f --- /dev/null +++ b/src/preview/elevated-modal/provider.tsx @@ -0,0 +1,44 @@ +import { createContext, useState, useContext, useMemo } from "react"; +import type { ReactNode, JSX, Dispatch, SetStateAction } from "react"; + +type TProps = { + isActivated: boolean; + setIsActivated: Dispatch>; + Modal: JSX.Element | null; + setModal: Dispatch>; +}; + +const ModalContext = createContext(undefined); + +function ModalContextProvider({ children }: { children: ReactNode }) { + const [Modal, setModal] = useState(null); + const [isActivated, setIsActivated] = useState(false); + + const contextValue = useMemo( + () => ({ + Modal, + setModal, + isActivated, + setIsActivated, + }), + [Modal, setModal, isActivated, setIsActivated], + ); + + return ( + + {children} + + ); +} + +function useModalContext() { + const context = useContext(ModalContext); + if (!context) { + throw new Error( + "useModalContext must be used within a ModalContextProvider", + ); + } + return context; +} + +export { useModalContext, ModalContextProvider }; diff --git a/src/preview/elevated-modal/stories/example.tsx b/src/preview/elevated-modal/stories/example.tsx new file mode 100644 index 0000000..6dcd54b --- /dev/null +++ b/src/preview/elevated-modal/stories/example.tsx @@ -0,0 +1,51 @@ +/* eslint-disable react/button-has-type */ + +import { + DialogTrigger, + DialogTitle, + DialogBody, + DialogActions, + DialogContent, + Button, +} from "@fluentui/react-components"; + +import StrippedDialogSurface from "@preview/elevated-modal/StrippedDialogSurface"; + +type TProps = { + someString: string; + someNumber: number; + someObject: { + someKey: string; + }; + onAbort: () => void; +}; + +export default function ExampleModal({ + someString, + someNumber, + someObject, + onAbort, +}: TProps) { + return ( + + + Dialog title -- {someString} + + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam + exercitationem cumque repellendus eaque est dolor eius expedita nulla + ullam? Tenetur reprehenderit aut voluptatum impedit voluptates in + natus iure cumque eaque? Some number: {someNumber} and some object + key: {someObject.someKey} + + + + + + + + + + ); +} diff --git a/src/preview/elevated-modal/stories/func.tsx b/src/preview/elevated-modal/stories/func.tsx new file mode 100644 index 0000000..b8777a3 --- /dev/null +++ b/src/preview/elevated-modal/stories/func.tsx @@ -0,0 +1,37 @@ +/* eslint-disable react/button-has-type */ +import { useModalContext } from "@preview/elevated-modal"; +import ExampleModal from "@previewelevated-modal/stories/example"; + +export default function App() { + const { setIsActivated, setModal } = useModalContext(); + return ( +
+ +
+ ); +} diff --git a/src/preview/elevated-modal/stories/index.ts b/src/preview/elevated-modal/stories/index.ts new file mode 100644 index 0000000..7699c70 --- /dev/null +++ b/src/preview/elevated-modal/stories/index.ts @@ -0,0 +1,3 @@ +import App from "@previewelevated-modal/stories/func"; + +export default App; diff --git a/src/preview/elevated-modal/stories/root.tsx b/src/preview/elevated-modal/stories/root.tsx new file mode 100644 index 0000000..49a359f --- /dev/null +++ b/src/preview/elevated-modal/stories/root.tsx @@ -0,0 +1,96 @@ +import { ModalContextProvider, ModalAnchor } from "@preview/elevated-modal"; +import App from "@previewelevated-modal/stories"; + +/** + * ### NOTE: This is currently highly experimental, and thus in `preview`, its not recommended to be used + * + * @description + * - in drag and drop enviroments, state is seldomly preserved between re-ordering + * - this makes using modals a bit tricky which rely on state transitions via portals + * - because the state is kept at the caller level, the modal will be unmounted and re-mounted, thus losing state + * - to solve this, we will use a similiar approach to toasting libraries, which instead of using portals, use a single anchor point to which a JSX element is dispatched (via Context API) + * - this way, no matter what happens to the caller state, the modal will always be mounted to the anchor point, preserving state etc. + * + * @accessibility apart from focus trapping, not really accessible + * + * @example + * + * ```jsx + * // setting up the root configuration + * import { ModalContextProvider, ModalAnchor } from "fluentui-helpers"; + * + * export default function Root() { + * return ( + * + * + * // if there are issues with trapping focus, use disableTrapping flag on ModalAnchor + * + * + * ); + * + * + * // the ModalAnchor itself provides a shell container for the actual modal, it has 100% width and height of the viewport + * // an example could be: + * + * + * import { + * DialogTrigger, + * DialogTitle, + * DialogBody, + * DialogActions, + * DialogContent, + * Button, + * } from "@fluentui/react-components"; + * + * // this one is not available, the gist is, is that the DialogSurface cannot be used directly, issues with the way the container spreads + * import StrippedDialogSurface from "@preview/elevated-modal/StrippedDialogSurface"; + * + * type TProps = { + * someString: string; + * someNumber: number; + * someObject: { + * someKey: string; + * }; + * onAbort: () => void; + * }; + * + * export default function ExampleModal({ + * someString, + * someNumber, + * someObject, + * onAbort, + * }: TProps) { + * return ( + * + * + * Dialog title -- {someString} + * + * Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam + * exercitationem cumque repellendus eaque est dolor eius expedita nulla + * ullam? Tenetur reprehenderit aut voluptatum impedit voluptates in + * natus iure cumque eaque? Some number: {someNumber} and some object + * key: {someObject.someKey} + * + * + * + * + * + * + * + * + * + * ); + * } + * ``` + * + */ +export default function Root() { + return ( + + + + + ); +} diff --git a/src/preview/elevated-modal/stories/stories.tsx b/src/preview/elevated-modal/stories/stories.tsx new file mode 100644 index 0000000..69583a3 --- /dev/null +++ b/src/preview/elevated-modal/stories/stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import Root from "@previewelevated-modal/stories/root"; + +const meta: Meta = { + title: "Preview/Elevated Modal", + component: Root, + args: {}, + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Index: Story = {}; diff --git a/src/preview/index.ts b/src/preview/index.ts new file mode 100644 index 0000000..1c5dad6 --- /dev/null +++ b/src/preview/index.ts @@ -0,0 +1,5 @@ +export { + useModalContext, + ModalAnchor, + ModalContextProvider, +} from "@preview/elevated-modal"; diff --git a/tsconfig.json b/tsconfig.json index 18e8b91..ac93797 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,8 @@ // Aliases "baseUrl": ".", "paths": { + "@preview*": ["src/preview/*"], + "@preview": ["src/preview/index.ts"], "@components/*": ["src/components/*"], "@components": ["src/components/index.ts"], "@hooks/*": ["src/hooks/*"],