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/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..1398719 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fluentui-helpers", - "version": "0.2.2", + "version": "0.3.2", "description": "Helper library for microsofts fluentui react library", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/src/index.ts b/src/index.ts index 9244973..e5c2b27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ 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/*"],