diff --git a/package-lock.json b/package-lock.json index 97ae3a0a028..12cae481d8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44275,6 +44275,7 @@ "@leafygreen-ui/code": "^16.0.2", "@leafygreen-ui/combobox": "^11.0.2", "@leafygreen-ui/confirmation-modal": "^6.0.2", + "@leafygreen-ui/descendants": "^2.1.0", "@leafygreen-ui/emotion": "^4.0.9", "@leafygreen-ui/guide-cue": "^7.0.2", "@leafygreen-ui/hooks": "^8.3.4", @@ -44282,6 +44283,7 @@ "@leafygreen-ui/icon-button": "16.0.2", "@leafygreen-ui/info-sprinkle": "^4.0.2", "@leafygreen-ui/leafygreen-provider": "^4.0.2", + "@leafygreen-ui/lib": "^15.2.0", "@leafygreen-ui/logo": "^10.0.2", "@leafygreen-ui/marketing-modal": "^5.0.2", "@leafygreen-ui/menu": "^29.0.5", @@ -44356,6 +44358,18 @@ "@leafygreen-ui/lib": "^14.2.0" } }, + "packages/compass-components/node_modules/@leafygreen-ui/a11y/node_modules/@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "packages/compass-components/node_modules/@leafygreen-ui/chip": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/@leafygreen-ui/chip/-/chip-3.0.12.tgz", @@ -44373,6 +44387,18 @@ "@leafygreen-ui/leafygreen-provider": "^4.0.7" } }, + "packages/compass-components/node_modules/@leafygreen-ui/chip/node_modules/@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "packages/compass-components/node_modules/@leafygreen-ui/descendants": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@leafygreen-ui/descendants/-/descendants-2.1.5.tgz", @@ -44387,6 +44413,18 @@ "@leafygreen-ui/leafygreen-provider": "^4.0.7" } }, + "packages/compass-components/node_modules/@leafygreen-ui/descendants/node_modules/@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "packages/compass-components/node_modules/@leafygreen-ui/icon-button": { "version": "16.0.2", "resolved": "https://registry.npmjs.org/@leafygreen-ui/icon-button/-/icon-button-16.0.2.tgz", @@ -44406,6 +44444,18 @@ "@leafygreen-ui/leafygreen-provider": "^4.0.2" } }, + "packages/compass-components/node_modules/@leafygreen-ui/icon-button/node_modules/@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "packages/compass-components/node_modules/@leafygreen-ui/inline-definition": { "version": "8.0.12", "resolved": "https://registry.npmjs.org/@leafygreen-ui/inline-definition/-/inline-definition-8.0.12.tgz", @@ -44422,6 +44472,18 @@ "@leafygreen-ui/leafygreen-provider": "^4.0.7" } }, + "packages/compass-components/node_modules/@leafygreen-ui/inline-definition/node_modules/@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "packages/compass-components/node_modules/@leafygreen-ui/input-option": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/@leafygreen-ui/input-option/-/input-option-3.0.12.tgz", @@ -44440,6 +44502,30 @@ "@leafygreen-ui/leafygreen-provider": "^4.0.7" } }, + "packages/compass-components/node_modules/@leafygreen-ui/input-option/node_modules/@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, + "packages/compass-components/node_modules/@leafygreen-ui/lib": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-15.2.0.tgz", + "integrity": "sha512-wrVJGaqACcYWE/xPHHJREpRvkoy4Biwim1SUuq0hs/lXf6cEMg7MD9x2fUDJ9v6tQmLiFuwRXbJiXrvVXkz4Lg==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "packages/compass-components/node_modules/@leafygreen-ui/menu": { "version": "29.0.5", "resolved": "https://registry.npmjs.org/@leafygreen-ui/menu/-/menu-29.0.5.tgz", @@ -44485,6 +44571,18 @@ "@leafygreen-ui/leafygreen-provider": "^4.0.7" } }, + "packages/compass-components/node_modules/@leafygreen-ui/menu/node_modules/@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "packages/compass-components/node_modules/@leafygreen-ui/table": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/@leafygreen-ui/table/-/table-13.0.1.tgz", @@ -44513,6 +44611,18 @@ "@leafygreen-ui/leafygreen-provider": "^4.0.2" } }, + "packages/compass-components/node_modules/@leafygreen-ui/table/node_modules/@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "packages/compass-components/node_modules/sinon": { "version": "9.2.4", "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", @@ -56678,6 +56788,7 @@ "@leafygreen-ui/code": "^16.0.2", "@leafygreen-ui/combobox": "^11.0.2", "@leafygreen-ui/confirmation-modal": "^6.0.2", + "@leafygreen-ui/descendants": "^2.1.0", "@leafygreen-ui/emotion": "^4.0.9", "@leafygreen-ui/guide-cue": "^7.0.2", "@leafygreen-ui/hooks": "^8.3.4", @@ -56685,6 +56796,7 @@ "@leafygreen-ui/icon-button": "16.0.2", "@leafygreen-ui/info-sprinkle": "^4.0.2", "@leafygreen-ui/leafygreen-provider": "^4.0.2", + "@leafygreen-ui/lib": "^15.2.0", "@leafygreen-ui/logo": "^10.0.2", "@leafygreen-ui/marketing-modal": "^5.0.2", "@leafygreen-ui/menu": "^29.0.5", @@ -56753,6 +56865,16 @@ "@leafygreen-ui/emotion": "^4.1.1", "@leafygreen-ui/hooks": "^8.4.1", "@leafygreen-ui/lib": "^14.2.0" + }, + "dependencies": { + "@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "requires": { + "lodash": "^4.17.21" + } + } } }, "@leafygreen-ui/chip": { @@ -56766,6 +56888,16 @@ "@leafygreen-ui/lib": "^14.2.0", "@leafygreen-ui/palette": "^4.1.4", "@leafygreen-ui/tokens": "^2.12.2" + }, + "dependencies": { + "@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "requires": { + "lodash": "^4.17.21" + } + } } }, "@leafygreen-ui/descendants": { @@ -56776,6 +56908,16 @@ "@leafygreen-ui/hooks": "^8.4.1", "@leafygreen-ui/lib": "^14.2.0", "lodash": "^4.17.21" + }, + "dependencies": { + "@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "requires": { + "lodash": "^4.17.21" + } + } } }, "@leafygreen-ui/icon-button": { @@ -56791,6 +56933,16 @@ "@leafygreen-ui/palette": "^4.1.3", "@leafygreen-ui/tokens": "^2.11.3", "polished": "^4.2.2" + }, + "dependencies": { + "@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "requires": { + "lodash": "^4.17.21" + } + } } }, "@leafygreen-ui/inline-definition": { @@ -56803,6 +56955,16 @@ "@leafygreen-ui/palette": "^4.1.4", "@leafygreen-ui/tokens": "^2.12.2", "@leafygreen-ui/tooltip": "^13.0.12" + }, + "dependencies": { + "@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "requires": { + "lodash": "^4.17.21" + } + } } }, "@leafygreen-ui/input-option": { @@ -56817,6 +56979,24 @@ "@leafygreen-ui/polymorphic": "^2.0.9", "@leafygreen-ui/tokens": "^2.12.2", "@leafygreen-ui/typography": "^20.1.9" + }, + "dependencies": { + "@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "requires": { + "lodash": "^4.17.21" + } + } + } + }, + "@leafygreen-ui/lib": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-15.2.0.tgz", + "integrity": "sha512-wrVJGaqACcYWE/xPHHJREpRvkoy4Biwim1SUuq0hs/lXf6cEMg7MD9x2fUDJ9v6tQmLiFuwRXbJiXrvVXkz4Lg==", + "requires": { + "lodash": "^4.17.21" } }, "@leafygreen-ui/menu": { @@ -56855,6 +57035,14 @@ "@leafygreen-ui/tokens": "^2.12.2", "polished": "^4.2.2" } + }, + "@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "requires": { + "lodash": "^4.17.21" + } } } }, @@ -56880,6 +57068,16 @@ "polished": "^4.2.2", "react-fast-compare": "3.2.2", "react-intersection-observer": "^8.25.1" + }, + "dependencies": { + "@leafygreen-ui/lib": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-14.2.0.tgz", + "integrity": "sha512-JWHFwtWXY52YL1uNFpHWvRUWVl5tkXQzyq2uEMFHyZQKYUG0of9o5V+Zc6vAXdMvvAhE3DeYvDjTpaQbUk1PrQ==", + "requires": { + "lodash": "^4.17.21" + } + } } }, "sinon": { diff --git a/packages/compass-components/package.json b/packages/compass-components/package.json index d3699ef69bb..4d883a87283 100644 --- a/packages/compass-components/package.json +++ b/packages/compass-components/package.json @@ -43,6 +43,7 @@ "@leafygreen-ui/code": "^16.0.2", "@leafygreen-ui/combobox": "^11.0.2", "@leafygreen-ui/confirmation-modal": "^6.0.2", + "@leafygreen-ui/descendants": "^2.1.0", "@leafygreen-ui/emotion": "^4.0.9", "@leafygreen-ui/guide-cue": "^7.0.2", "@leafygreen-ui/hooks": "^8.3.4", @@ -50,6 +51,7 @@ "@leafygreen-ui/icon-button": "16.0.2", "@leafygreen-ui/info-sprinkle": "^4.0.2", "@leafygreen-ui/leafygreen-provider": "^4.0.2", + "@leafygreen-ui/lib": "^15.2.0", "@leafygreen-ui/logo": "^10.0.2", "@leafygreen-ui/marketing-modal": "^5.0.2", "@leafygreen-ui/menu": "^29.0.5", diff --git a/packages/compass-components/src/components/drawer/constants.ts b/packages/compass-components/src/components/drawer/constants.ts new file mode 100644 index 00000000000..1986d2aec2b --- /dev/null +++ b/packages/compass-components/src/components/drawer/constants.ts @@ -0,0 +1,10 @@ +export { TOOLBAR_WIDTH } from '../toolbar'; + +export const GRID_AREA = { + drawer: 'drawer', + content: 'content', + toolbar: 'toolbar', + innerDrawer: 'inner-drawer', +}; + +export const PANEL_WIDTH = 432; diff --git a/packages/compass-components/src/components/drawer/drawer-layout/drawer-layout.tsx b/packages/compass-components/src/components/drawer/drawer-layout/drawer-layout.tsx new file mode 100644 index 00000000000..2c432ea4a72 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-layout/drawer-layout.tsx @@ -0,0 +1,59 @@ +import React, { forwardRef } from 'react'; + +import { consoleOnce } from '@leafygreen-ui/lib'; + +import { DisplayMode } from '../drawer/drawer.types'; +import { DrawerToolbarLayout } from '../drawer-toolbar-layout'; +import { LayoutComponent } from '../layout-component'; + +import type { DrawerLayoutProps } from './drawer-layout.types'; + +/** + * `DrawerLayout` is a component that provides a flexible layout for displaying content in a drawer. + * It can be used in both `overlay` and `embedded` modes, with or without a `Toolbar`. + */ +export const DrawerLayout = forwardRef( + ( + { + toolbarData, + children, + displayMode = DisplayMode.Overlay, + isDrawerOpen = false, + ...rest + }: DrawerLayoutProps, + forwardedRef + ) => { + // If there is data, we render the DrawerToolbarLayout. + if (toolbarData) { + return ( + + {children} + + ); + } + + consoleOnce.warn( + 'Using a Drawer without a toolbar is not recommended. To include a toolbar, pass a toolbarData prop containing the desired toolbar items.' + ); + + // If there is no data, we render the LayoutComponent. + // The LayoutComponent will read the displayMode and render the appropriate layout. + return ( + + {children} + + ); + } +); + +DrawerLayout.displayName = 'DrawerLayout'; diff --git a/packages/compass-components/src/components/drawer/drawer-layout/drawer-layout.types.ts b/packages/compass-components/src/components/drawer/drawer-layout/drawer-layout.types.ts new file mode 100644 index 00000000000..ab43ec1e9a5 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-layout/drawer-layout.types.ts @@ -0,0 +1,50 @@ +import type { DarkModeProps, HTMLElementProps } from '@leafygreen-ui/lib'; + +import type { DrawerProps } from '../drawer/drawer.types'; +import type { DrawerToolbarLayoutProps } from '../drawer-toolbar-layout'; +import type { LayoutComponentProps } from '../layout-component'; + +export type PickedDrawerProps = Pick; + +export interface BaseDrawerLayoutProps + extends PickedDrawerProps, + HTMLElementProps<'div'>, + DarkModeProps { + children: React.ReactNode; +} + +export type DrawerLayoutPropsWithoutToolbar = Omit< + LayoutComponentProps, + 'displayMode' | 'isDrawerOpen' +> & { + /** + * An array of data that will be used to render the toolbar items and the drawer content. + */ + toolbarData?: never; + + /** + * Event handler called on close button click. If provided, a close button will be rendered in the Drawer header. + */ + onClose?: never; + + /** + * Determines if the Drawer is open. This is only needed if using the Drawer without a toolbar. This will shift the layout to the right by the width of the drawer if `displayMode` is set to 'embedded'. + */ + isDrawerOpen?: boolean; +} & BaseDrawerLayoutProps; + +export type DrawerLayoutPropsWithToolbar = Omit< + DrawerToolbarLayoutProps, + 'displayMode' +> & { + /** + * Determines if the Drawer is open. This is only needed if using the Drawer without a toolbar. This will shift the layout to the right by the width of the drawer if `displayMode` is set to 'embedded'. + */ + isDrawerOpen?: never; +} & BaseDrawerLayoutProps; + +export type DrawerLayoutProps = + | ({ + toolbarData: DrawerLayoutPropsWithToolbar['toolbarData']; + } & DrawerLayoutPropsWithToolbar) + | ({ toolbarData?: never } & DrawerLayoutPropsWithoutToolbar); diff --git a/packages/compass-components/src/components/drawer/drawer-layout/index.ts b/packages/compass-components/src/components/drawer/drawer-layout/index.ts new file mode 100644 index 00000000000..0103bca5dc2 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-layout/index.ts @@ -0,0 +1,2 @@ +export { DrawerLayout } from './drawer-layout'; +export type { DrawerLayoutProps } from './drawer-layout.types'; diff --git a/packages/compass-components/src/components/drawer/drawer-stack-context/drawer-stack-context.tsx b/packages/compass-components/src/components/drawer/drawer-stack-context/drawer-stack-context.tsx new file mode 100644 index 00000000000..d617865153f --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-stack-context/drawer-stack-context.tsx @@ -0,0 +1,67 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import type { DrawerStackContextType } from './drawer-stack-context.types'; + +export const DrawerStackContext = createContext({ + getDrawerIndex: () => 0, + registerDrawer: () => {}, + unregisterDrawer: () => {}, +}); + +export const DrawerStackProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [stack, setStack] = useState>([]); + + const getDrawerIndex = useCallback( + (id: string) => { + return stack.indexOf(id); + }, + [stack] + ); + + const registerDrawer = useCallback( + (id: string) => { + setStack((prev) => { + if (prev.includes(id)) return prev; + return [...prev, id]; + }); + }, + [setStack] + ); + + const unregisterDrawer = useCallback( + (id: string) => { + setStack((prev) => { + if (!prev.includes(id)) return prev; + return prev.filter((item) => item !== id); + }); + }, + [setStack] + ); + + const value = useMemo( + () => ({ getDrawerIndex, registerDrawer, unregisterDrawer }), + [getDrawerIndex, registerDrawer, unregisterDrawer] + ); + + return ( + + {children} + + ); +}; + +export const useDrawerStackContext = () => { + const context = useContext(DrawerStackContext); + + return context; +}; diff --git a/packages/compass-components/src/components/drawer/drawer-stack-context/drawer-stack-context.types.ts b/packages/compass-components/src/components/drawer/drawer-stack-context/drawer-stack-context.types.ts new file mode 100644 index 00000000000..851cbac53ce --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-stack-context/drawer-stack-context.types.ts @@ -0,0 +1,22 @@ +export interface DrawerStackContextType { + /** + * Returns the index of a drawer instance in the drawer stack + * @param id - The id of the drawer instance to get the index of + * @returns The index of the drawer instance in the stack + */ + getDrawerIndex: (id: string) => number; + + /** + * Registers a drawer instance in the drawer stack + * @param id - The id of the drawer instance to register + * @returns void + */ + registerDrawer: (id: string) => void; + + /** + * Unregisters a drawer instance from the drawer stack + * @param id - The id of the drawer instance to unregister + * @returns void + */ + unregisterDrawer: (id: string) => void; +} diff --git a/packages/compass-components/src/components/drawer/drawer-stack-context/index.ts b/packages/compass-components/src/components/drawer/drawer-stack-context/index.ts new file mode 100644 index 00000000000..d4b114d53d0 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-stack-context/index.ts @@ -0,0 +1,6 @@ +export { + DrawerStackContext, + DrawerStackProvider, + useDrawerStackContext, +} from './drawer-stack-context'; +export { type DrawerStackContextType } from './drawer-stack-context.types'; diff --git a/packages/compass-components/src/components/drawer/drawer-toolbar-context/drawer-toolbar-context.tsx b/packages/compass-components/src/components/drawer/drawer-toolbar-context/drawer-toolbar-context.tsx new file mode 100644 index 00000000000..5460c06af23 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-toolbar-context/drawer-toolbar-context.tsx @@ -0,0 +1,89 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { drawerTransitionDuration } from '../drawer/drawer.styles'; + +import type { + ContextData, + DataId, + DrawerToolbarContextType, + DrawerToolbarProviderProps, +} from './drawer-toolbar-context.types'; + +export const DrawerToolbarContext = + createContext(null); + +export const DrawerToolbarProvider = ({ + children, + data, +}: DrawerToolbarProviderProps) => { + const [content, setContent] = useState(undefined); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const openDrawer = useCallback( + (id: DataId) => { + const activeDrawerContent = data.find( + (item) => item?.id === id && item?.content + ); + + if (activeDrawerContent) { + setIsDrawerOpen(true); + setContent((prev) => { + if (prev?.id === id) return prev; + return activeDrawerContent; + }); + } else { + // eslint-disable-next-line no-console + console.error( + `No matching item found in the toolbar for the provided id: ${id}. Please verify that the id is correct.` + ); + } + }, + [setContent, data, setIsDrawerOpen] + ); + + const closeDrawer = useCallback(() => { + // Delay the removal of the content to allow the drawer to close before removing the content + setTimeout(() => { + setContent(undefined); + }, drawerTransitionDuration); + setIsDrawerOpen(false); + }, [setContent]); + + const getActiveDrawerContent = useCallback(() => { + return content; + }, [content]); + + const value = useMemo( + () => ({ + openDrawer, + closeDrawer, + getActiveDrawerContent, + isDrawerOpen, + }), + [openDrawer, closeDrawer, getActiveDrawerContent, isDrawerOpen] + ); + + return ( + + {children} + + ); +}; + +export const useDrawerToolbarContext = () => { + const context = useContext(DrawerToolbarContext); + + if (!context) { + throw new Error( + 'useDrawerToolbarContext must be used within a DrawerToolbarProvider' + ); + } + + return context; +}; diff --git a/packages/compass-components/src/components/drawer/drawer-toolbar-context/drawer-toolbar-context.types.ts b/packages/compass-components/src/components/drawer/drawer-toolbar-context/drawer-toolbar-context.types.ts new file mode 100644 index 00000000000..2c86db2b0ee --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-toolbar-context/drawer-toolbar-context.types.ts @@ -0,0 +1,44 @@ +import type { LayoutData } from '../drawer-toolbar-layout'; + +export type ContextData = LayoutData | undefined; + +export type DataId = LayoutData['id']; + +export interface DrawerToolbarContextType { + /** + * This function is used to open the drawer with the given data. + * @param id - The id of the drawer to open + * @returns void + */ + openDrawer: (id: DataId) => void; + + /** + * This function is used to clear the active drawer data and indicates that the drawer should close. + * @returns void + */ + closeDrawer: () => void; + + /** + * Indicates whether the drawer should be closed. Used to manage transition states. + * For example, during close animations, content should remain until the transition completes. + */ + isDrawerOpen: boolean; + + /** + * This function is used to get the active drawer content. + * @returns The active drawer data + */ + getActiveDrawerContent: () => ContextData; +} + +export interface DrawerToolbarProviderProps { + /** + * The children of the provider + */ + children: React.ReactNode; + + /** + * The data to be used in the drawer + */ + data: Array; +} diff --git a/packages/compass-components/src/components/drawer/drawer-toolbar-context/index.ts b/packages/compass-components/src/components/drawer/drawer-toolbar-context/index.ts new file mode 100644 index 00000000000..5a779369eda --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-toolbar-context/index.ts @@ -0,0 +1,4 @@ +export { + DrawerToolbarProvider, + useDrawerToolbarContext, +} from './drawer-toolbar-context'; diff --git a/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout-container.tsx b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout-container.tsx new file mode 100644 index 00000000000..bc489da577c --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout-container.tsx @@ -0,0 +1,116 @@ +import React, { forwardRef } from 'react'; + +import { Toolbar, ToolbarIconButton } from '../../toolbar'; + +import { Drawer } from '../drawer/drawer'; +import { DisplayMode } from '../drawer/drawer.types'; +import { useDrawerToolbarContext } from '../drawer-toolbar-context'; +import { DrawerWithToolbarWrapper } from '../drawer-with-toolbar-wrapper'; +import { LayoutComponent } from '../layout-component'; +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils'; + +import { contentStyles } from './drawer-toolbar-layout.styles'; +import type { + DrawerToolbarLayoutContainerProps, + LayoutData, +} from './drawer-toolbar-layout.types'; + +/** + * @internal + * + * DrawerToolbarLayoutContainer is a component that provides a layout for displaying content in a drawer with a toolbar. + * It manages the state of the drawer and toolbar, and renders the appropriate components based on the display mode. + */ +export const DrawerToolbarLayoutContainer = forwardRef< + HTMLDivElement, + DrawerToolbarLayoutContainerProps +>( + ( + { + children, + displayMode = DisplayMode.Overlay, + toolbarData, + onClose, + // darkMode: darkModeProp, + 'data-lgid': dataLgId = DEFAULT_LGID_ROOT, + ...rest + }: DrawerToolbarLayoutContainerProps, + forwardRef + ) => { + const { openDrawer, closeDrawer, getActiveDrawerContent, isDrawerOpen } = + useDrawerToolbarContext(); + const { id, title, content } = getActiveDrawerContent() || {}; + const lgIds = getLgIds(dataLgId); + const hasData = toolbarData && toolbarData.length > 0; + + const handleOnClose = (event: React.MouseEvent) => { + onClose?.(event); + closeDrawer(); + }; + + const handleIconClick = ( + event: React.MouseEvent, + id: LayoutData['id'], + onClick?: (event: React.MouseEvent) => void + ) => { + onClick?.(event); + openDrawer(id); + }; + + return ( + +
{children}
+ + + {toolbarData?.map((toolbarItem) => ( + ) => { + if (!toolbarItem.content) { + // If the toolbar item does not have content, we don't want to open/update/close the drawer + // but we still want to call the onClick function if it exists. E.g. open a modal or perform an action + toolbarItem.onClick?.(event); + return; + } + + return handleIconClick( + event, + toolbarItem.id, + toolbarItem.onClick + ); + }} + active={toolbarItem.id === id} + disabled={toolbarItem.disabled} + /> + ))} + + + {content} + + +
+ ); + } +); + +DrawerToolbarLayoutContainer.displayName = 'DrawerToolbarLayoutContainer'; diff --git a/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout.styles.ts b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout.styles.ts new file mode 100644 index 00000000000..cf0b93d8ba5 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout.styles.ts @@ -0,0 +1,9 @@ +import { css } from '@leafygreen-ui/emotion'; + +import { GRID_AREA } from '../constants'; + +export const contentStyles = css` + grid-area: ${GRID_AREA.content}; + overflow: scroll; + height: inherit; +`; diff --git a/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout.tsx b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout.tsx new file mode 100644 index 00000000000..9ac6b2919db --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout.tsx @@ -0,0 +1,35 @@ +import React, { forwardRef } from 'react'; + +import { DrawerToolbarProvider } from '../drawer-toolbar-context'; + +import type { DrawerToolbarLayoutProps } from './drawer-toolbar-layout.types'; +import { DrawerToolbarLayoutContainer } from './drawer-toolbar-layout-container'; + +/** + * @internal + * + * DrawerToolbarLayout is a component that provides a layout for displaying content in a drawer with a toolbar. + */ +export const DrawerToolbarLayout = forwardRef< + HTMLDivElement, + DrawerToolbarLayoutProps +>( + ( + { children, toolbarData, ...rest }: DrawerToolbarLayoutProps, + forwardRef + ) => { + return ( + + + {children} + + + ); + } +); + +DrawerToolbarLayout.displayName = 'DrawerToolbarLayout'; diff --git a/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout.types.ts b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout.types.ts new file mode 100644 index 00000000000..119d3fa3025 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/drawer-toolbar-layout.types.ts @@ -0,0 +1,58 @@ +import type React from 'react'; + +import type { DarkModeProps, LgIdProps } from '@leafygreen-ui/lib'; +import type { ToolbarIconButtonProps } from '../../toolbar'; + +import type { DrawerProps } from '../drawer/drawer.types'; + +type PickedOptionalDrawerProps = Pick; +type PickedRequiredToolbarIconButtonProps = Pick< + ToolbarIconButtonProps, + 'glyph' | 'label' | 'onClick' | 'disabled' +>; + +interface LayoutBase extends PickedRequiredToolbarIconButtonProps { + /** + * The id of the layout. This is used to open the drawer. + */ + id: string; +} + +interface LayoutWithContent extends LayoutBase { + /** + * The title of the drawer. This is not required if the toolbar item should not open a drawer. + */ + title: React.ReactNode; + + /** + * The content of the drawer. This is not required if the toolbar item should not open a drawer. + */ + content: React.ReactNode; +} + +interface LayoutWithoutContent extends LayoutBase { + /** + * The title of the drawer. This is not required if the toolbar item should not open a drawer. + */ + title?: never; + + /** + * The content of the drawer. This is not required if the toolbar item should not open a drawer. + */ + content?: never; +} + +export type LayoutData = LayoutWithContent | LayoutWithoutContent; + +export type DrawerToolbarLayoutProps = PickedOptionalDrawerProps & + DarkModeProps & + LgIdProps & { + /** + * An array of data that will be used to render the toolbar items and the drawer content. + */ + toolbarData: Array; + className?: string; + children: React.ReactNode; + }; + +export type DrawerToolbarLayoutContainerProps = DrawerToolbarLayoutProps; diff --git a/packages/compass-components/src/components/drawer/drawer-toolbar-layout/index.ts b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/index.ts new file mode 100644 index 00000000000..a516e650db3 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-toolbar-layout/index.ts @@ -0,0 +1,5 @@ +export { DrawerToolbarLayout } from './drawer-toolbar-layout'; +export type { + DrawerToolbarLayoutProps, + LayoutData, +} from './drawer-toolbar-layout.types'; diff --git a/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/drawer-with-toolbar-wrapper.styles.ts b/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/drawer-with-toolbar-wrapper.styles.ts new file mode 100644 index 00000000000..d0ca13c9405 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/drawer-with-toolbar-wrapper.styles.ts @@ -0,0 +1,171 @@ +import { css, cx, keyframes } from '@leafygreen-ui/emotion'; +import type { Theme } from '@leafygreen-ui/lib'; +import { addOverflowShadow, breakpoints, Side } from '@leafygreen-ui/tokens'; +import { toolbarClassName } from '../../toolbar'; + +import { GRID_AREA } from '../constants'; +import { PANEL_WIDTH, TOOLBAR_WIDTH } from '../constants'; +import { + drawerClassName, + drawerTransitionDuration, +} from '../drawer/drawer.styles'; +import { DisplayMode } from '../drawer/drawer.types'; + +const MOBILE_BREAKPOINT = breakpoints.Tablet; +const SHADOW_WIDTH = 36; // Width of the shadow padding on the left side + +const drawerIn = keyframes` + from { + // Because of .show() and .close() in the drawer component, transitioning from 0px to (x)px does not transition correctly. Using 1px along with css animations is a workaround to get the animation to work. + grid-template-columns: ${TOOLBAR_WIDTH}px 1px; + } + to { + grid-template-columns: ${TOOLBAR_WIDTH}px ${PANEL_WIDTH}px; + } +`; + +const drawerOut = keyframes` + from { + grid-template-columns: ${TOOLBAR_WIDTH}px ${PANEL_WIDTH}px; + } + to { + grid-template-columns: ${TOOLBAR_WIDTH}px 0px; + } +`; + +const drawerOutMobile = keyframes` + from { + grid-template-columns: ${TOOLBAR_WIDTH}px calc(100vw - ${ + TOOLBAR_WIDTH * 2 +}px); + } + to { + grid-template-columns: ${TOOLBAR_WIDTH}px 0px; + } +`; + +const drawerInMobile = keyframes` + from { + grid-template-columns: ${TOOLBAR_WIDTH}px 1px; + } + to { + grid-template-columns: ${TOOLBAR_WIDTH}px calc(100vw - ${ + TOOLBAR_WIDTH * 2 +}px); + } +`; + +// This animation is used to animate the padding of the drawer when it closes, so that the padding does not block the content underneath it. +const drawerPaddingOut = keyframes` + 0% { + padding-left: ${SHADOW_WIDTH}px; + } + 99% { + padding-left: ${SHADOW_WIDTH}px; + } + 100% { + padding-left: 0px; + } +`; + +const openStyles = css` + animation-name: ${drawerIn}; + animation-fill-mode: forwards; + + @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + animation-name: ${drawerInMobile}; + } +`; + +const closedStyles = css` + animation-name: ${drawerOut}; + + @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + animation-name: ${drawerOutMobile}; + } +`; + +const getDrawerShadowStyles = ({ theme }: { theme: Theme }) => css` + ${addOverflowShadow({ isInside: false, side: Side.Left, theme })}; + + // Need this to show the box shadow since we are using overflow: hidden + padding-left: ${SHADOW_WIDTH}px; + + &::before { + transition-property: opacity; + transition-duration: ${drawerTransitionDuration}ms; + transition-timing-function: ease-in-out; + opacity: 1; + left: ${SHADOW_WIDTH}px; + } +`; + +const baseStyles = css` + display: grid; + grid-template-columns: ${TOOLBAR_WIDTH}px 0px; + grid-template-areas: '${GRID_AREA.toolbar} ${GRID_AREA.innerDrawer}'; + grid-area: ${GRID_AREA.drawer}; + justify-self: end; + animation-timing-function: ease-in-out; + animation-duration: ${drawerTransitionDuration}ms; + z-index: 0; + height: 100%; + overflow: hidden; + + .${toolbarClassName} { + grid-area: ${GRID_AREA.toolbar}; + } + + .${drawerClassName} { + grid-area: ${GRID_AREA.innerDrawer}; + position: unset; + transition: none; + transform: unset; + overflow: hidden; + opacity: 1; + border-left: 0; + border-right: 0; + height: 100%; + animation: none; + + > div::before { + box-shadow: unset; + } + } +`; + +const closedDrawerShadowStyles = css` + padding-left: 0; + animation-name: ${drawerPaddingOut}; + animation-timing-function: ease-in-out; + animation-duration: ${drawerTransitionDuration}ms; + + ::before { + opacity: 0; + } +`; + +export const getDrawerWithToolbarWrapperStyles = ({ + className, + isDrawerOpen, + shouldAnimate, + displayMode, + theme, +}: { + className?: string; + isDrawerOpen: boolean; + shouldAnimate?: boolean; + displayMode: DisplayMode; + theme: Theme; +}) => + cx( + baseStyles, + { + [getDrawerShadowStyles({ theme })]: displayMode === DisplayMode.Overlay, + [closedDrawerShadowStyles]: + displayMode === DisplayMode.Overlay && !isDrawerOpen, + [openStyles]: isDrawerOpen, + [closedStyles]: !isDrawerOpen && shouldAnimate, // This ensures that the drawer does not animate closed on initial render + }, + className + ); diff --git a/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/drawer-with-toolbar-wrapper.tsx b/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/drawer-with-toolbar-wrapper.tsx new file mode 100644 index 00000000000..0cc7f62f05c --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/drawer-with-toolbar-wrapper.tsx @@ -0,0 +1,52 @@ +import React, { forwardRef, useEffect, useState } from 'react'; + +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; + +import { getDrawerWithToolbarWrapperStyles } from './drawer-with-toolbar-wrapper.styles'; +import type { DrawerWithToolbarWrapperProps } from './drawer-with-toolbar-wrapper.types'; + +/** + * @internal + * + * This layout wrapper is used to position the toolbar and drawer together. When the drawer is open, the toolbar and drawer will shift to the right. + * + * If the drawer is overlay, a box shadow will be applied to the left side of this component. + */ +export const DrawerWithToolbarWrapper = forwardRef< + HTMLDivElement, + DrawerWithToolbarWrapperProps +>( + ( + { + children, + className, + isDrawerOpen, + displayMode, + }: DrawerWithToolbarWrapperProps, + forwardedRef + ) => { + const { theme } = useDarkMode(); + const [shouldAnimate, setShouldAnimate] = useState(false); + + useEffect(() => { + if (isDrawerOpen) setShouldAnimate(true); + }, [isDrawerOpen]); + + return ( +
+ {children} +
+ ); + } +); + +DrawerWithToolbarWrapper.displayName = 'DrawerWithToolbarWrapper'; diff --git a/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/drawer-with-toolbar-wrapper.types.ts b/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/drawer-with-toolbar-wrapper.types.ts new file mode 100644 index 00000000000..a57b93e09d9 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/drawer-with-toolbar-wrapper.types.ts @@ -0,0 +1,14 @@ +import type { HTMLElementProps } from '@leafygreen-ui/lib'; + +import type { DrawerProps } from '../drawer/drawer.types'; + +type PickedDrawerProps = Required>; + +export interface DrawerWithToolbarWrapperProps + extends HTMLElementProps<'div'>, + PickedDrawerProps { + /** + * Determines if the Drawer instance is open or closed + */ + isDrawerOpen: boolean; +} diff --git a/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/index.ts b/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/index.ts new file mode 100644 index 00000000000..cd903c21869 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer-with-toolbar-wrapper/index.ts @@ -0,0 +1 @@ +export { DrawerWithToolbarWrapper } from './drawer-with-toolbar-wrapper'; diff --git a/packages/compass-components/src/components/drawer/drawer/drawer.constants.ts b/packages/compass-components/src/components/drawer/drawer/drawer.constants.ts new file mode 100644 index 00000000000..67db13f77d9 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer/drawer.constants.ts @@ -0,0 +1,2 @@ +export const HEADER_HEIGHT = 48; +export const MOBILE_BREAKPOINT = 390; diff --git a/packages/compass-components/src/components/drawer/drawer/drawer.styles.ts b/packages/compass-components/src/components/drawer/drawer/drawer.styles.ts new file mode 100644 index 00000000000..81bfba2bbbe --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer/drawer.styles.ts @@ -0,0 +1,265 @@ +import { css, cx, keyframes } from '@leafygreen-ui/emotion'; +import { createUniqueClassName, type Theme } from '@leafygreen-ui/lib'; +import { + addOverflowShadow, + color, + Side, + spacing, + transitionDuration, +} from '@leafygreen-ui/tokens'; + +import { PANEL_WIDTH } from '../constants'; + +import { HEADER_HEIGHT, MOBILE_BREAKPOINT } from './drawer.constants'; +import { DisplayMode } from './drawer.types'; + +export const drawerTransitionDuration = transitionDuration.slower; + +export const drawerClassName = createUniqueClassName('lg-drawer'); + +// Because of .show() and .close() in the drawer component, transitioning from 0px to (x)px does not transition correctly. Having the drawer start at the open position while hidden, moving to the closed position, and then animating to the open position is a workaround to get the animation to work. +// These styles are used for a standalone drawer in overlay mode since it is not part of a grid layout. +const drawerIn = keyframes` + 0% { + transform: translate3d(0%, 0, 0); + opacity: 0; + visibility: hidden; + } + 1% { + transform: translate3d(100%, 0, 0); + opacity: 1; + visibility: visible; + } + 100% { + transform: translate3d(0%, 0, 0); + } +`; + +// Keep the drawer opacity at 1 until the end of the animation. The inner container opacity is transitioned separately. +const drawerOut = keyframes` + 0% { + transform: translate3d(0%, 0, 0); + } + 99% { + transform: translate3d(100%, 0, 0); + opacity: 1; + } + 100% { + opacity: 0; + visibility: hidden; + } +`; + +const getBaseStyles = ({ theme }: { theme: Theme }) => css` + all: unset; + background-color: ${color[theme].background.primary.default}; + border: 1px solid ${color[theme].border.secondary.default}; + width: 100%; + max-width: ${PANEL_WIDTH}px; + height: 100%; + overflow: hidden; + box-sizing: border-box; + + @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + max-width: 100%; + height: 50vh; + } +`; + +const overlayOpenStyles = css` + opacity: 1; + animation-name: ${drawerIn}; + + // On mobile, the drawer should be positioned at the bottom of the screen when closed, and slide up to the top when opened. + @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + transform: none; + } +`; + +const overlayClosedStyles = css` + pointer-events: none; + animation-name: ${drawerOut}; + + // On mobile, the drawer should be positioned at the bottom of the screen when closed, and slide up to the top when opened. + @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + transform: translate3d(0, 100%, 0); + opacity: 0; + } +`; + +const getOverlayStyles = ({ + open, + shouldAnimate, + zIndex, +}: { + open: boolean; + shouldAnimate: boolean; + zIndex: number; +}) => + cx( + css` + position: absolute; + z-index: ${zIndex}; + top: 0; + bottom: 0; + right: 0; + overflow: visible; + + // By default, the drawer is positioned off-screen to the right. + transform: translate3d(100%, 0, 0); + animation-timing-function: ease-in-out; + animation-duration: ${drawerTransitionDuration}ms; + animation-fill-mode: forwards; + + @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + top: unset; + left: 0; + // Since the drawer has position: fixed, we can use normal transitions + animation: none; + position: fixed; + transform: translate3d(0, 100%, 0); + transition: transform ${drawerTransitionDuration}ms ease-in-out, + opacity ${drawerTransitionDuration}ms ease-in-out + ${open ? '0ms' : `${drawerTransitionDuration}ms`}; + } + `, + { + [overlayOpenStyles]: open, + [overlayClosedStyles]: !open && shouldAnimate, // This ensures that the drawer does not animate closed on initial render + } + ); + +const getDisplayModeStyles = ({ + displayMode, + open, + shouldAnimate, + zIndex, +}: { + displayMode: DisplayMode; + open: boolean; + shouldAnimate: boolean; + zIndex: number; +}) => + cx({ + [getOverlayStyles({ open, shouldAnimate, zIndex })]: + displayMode === DisplayMode.Overlay, + }); + +export const getDrawerStyles = ({ + className, + displayMode, + open, + shouldAnimate, + theme, + zIndex, +}: { + className?: string; + displayMode: DisplayMode; + open: boolean; + shouldAnimate: boolean; + theme: Theme; + zIndex: number; +}) => + cx( + getBaseStyles({ theme }), + getDisplayModeStyles({ displayMode, open, shouldAnimate, zIndex }), + className, + drawerClassName + ); + +export const getDrawerShadowStyles = ({ + theme, + displayMode, +}: { + theme: Theme; + displayMode: DisplayMode; +}) => + cx( + css` + height: 100%; + background-color: ${color[theme].background.primary.default}; + `, + { + [css` + ${addOverflowShadow({ isInside: false, side: Side.Left, theme })}; + + @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + ${addOverflowShadow({ isInside: false, side: Side.Top, theme })}; + } + `]: displayMode === DisplayMode.Overlay, + } + ); + +const getBaseInnerContainerStyles = ({ theme }: { theme: Theme }) => css` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background-color: ${color[theme].background.primary.default}; + opacity: 0; + transition-property: opacity; + transition-duration: ${transitionDuration.faster}ms; + transition-timing-function: linear; +`; + +const getInnerOpenContainerStyles = css` + transition-property: opacity; + transition-duration: ${transitionDuration.slowest}ms; + transition-timing-function: linear; + opacity: 1; +`; + +export const getInnerContainerStyles = ({ + theme, + open, +}: { + theme: Theme; + open: boolean; +}) => + cx(getBaseInnerContainerStyles({ theme }), { + [getInnerOpenContainerStyles]: open, + }); + +export const getHeaderStyles = ({ theme }: { theme: Theme }) => css` + height: ${HEADER_HEIGHT}px; + padding: ${spacing[400]}px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid ${color[theme].border.secondary.default}; + transition-property: box-shadow; + transition-duration: ${transitionDuration.faster}ms; + transition-timing-function: ease-in-out; +`; + +const baseChildrenContainerStyles = css` + height: 100%; + overflow: hidden; +`; + +export const getChildrenContainerStyles = ({ + hasShadowTop, + theme, +}: { + hasShadowTop: boolean; + theme: Theme; +}) => + cx(baseChildrenContainerStyles, { + [addOverflowShadow({ isInside: true, side: Side.Top, theme })]: + hasShadowTop, + }); + +const baseInnerChildrenContainerStyles = css` + height: 100%; +`; + +const scrollContainerStyles = css` + padding: ${spacing[400]}px; + overflow-y: auto; + overscroll-behavior: contain; +`; + +export const innerChildrenContainerStyles = cx( + baseInnerChildrenContainerStyles, + scrollContainerStyles +); diff --git a/packages/compass-components/src/components/drawer/drawer/drawer.tsx b/packages/compass-components/src/components/drawer/drawer/drawer.tsx new file mode 100644 index 00000000000..75a02e8a373 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer/drawer.tsx @@ -0,0 +1,185 @@ +import React, { forwardRef, useEffect, useRef, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; + +import { + useIdAllocator, + useIsomorphicLayoutEffect, + useMergeRefs, +} from '@leafygreen-ui/hooks'; +import IconButton from '@leafygreen-ui/icon-button'; +import LeafyGreenProvider, { + useDarkMode, +} from '@leafygreen-ui/leafygreen-provider'; +import { usePolymorphic } from '@leafygreen-ui/polymorphic'; +import { BaseFontSize } from '@leafygreen-ui/tokens'; +import { Body } from '@leafygreen-ui/typography'; + +import { useDrawerStackContext } from '../drawer-stack-context'; +import { getLgIds } from '../utils'; + +import { + drawerTransitionDuration, + getChildrenContainerStyles, + getDrawerShadowStyles, + getDrawerStyles, + getHeaderStyles, + getInnerContainerStyles, + innerChildrenContainerStyles, +} from './drawer.styles'; +import { DisplayMode, type DrawerProps } from './drawer.types'; +import { Icon } from '../../leafygreen'; + +export const Drawer = forwardRef( + ( + { + children, + className, + 'data-lgid': dataLgId, + displayMode = DisplayMode.Overlay, + id: idProp, + onClose, + open = false, + title, + ...rest + }, + fwdRef + ) => { + const { darkMode, theme } = useDarkMode(); + const { Component } = usePolymorphic<'dialog' | 'div'>( + displayMode === DisplayMode.Overlay ? 'dialog' : 'div' + ); + const { getDrawerIndex, registerDrawer, unregisterDrawer } = + useDrawerStackContext(); + const [shouldAnimate, setShouldAnimate] = useState(false); + const ref = useRef(null); + const drawerRef = useMergeRefs([fwdRef, ref]); + + const lgIds = getLgIds(dataLgId); + const id = useIdAllocator({ prefix: 'drawer', id: idProp }); + const titleId = useIdAllocator({ prefix: 'drawer' }); + + // Track when intercept element is no longer visible to add shadow below drawer header + const { ref: interceptRef, inView: isInterceptInView } = useInView({ + initialInView: true, + fallbackInView: true, + }); + + const showCloseButton = !!onClose; + // This will use the default value of 0 if not wrapped in a DrawerStackProvider. If using a Drawer + Toolbar, the DrawerStackProvider will not be necessary. + const drawerIndex = getDrawerIndex(id); + + useIsomorphicLayoutEffect(() => { + const drawerElement = ref.current; + + if (!drawerElement || drawerElement instanceof HTMLDivElement) { + return; + } + + if (open) { + drawerElement.show(); + setShouldAnimate(true); + } else { + drawerElement.close(); + } + }, [ref, open]); + + useEffect(() => { + if (open) { + registerDrawer(id); + } else { + setTimeout(() => unregisterDrawer(id), drawerTransitionDuration); + } + }, [id, open, registerDrawer, unregisterDrawer]); + + /** + * Focuses the first focusable element in the drawer when the animation ends. We have to manually handle this because we are hiding the drawer with visibility: hidden, which breaks the default focus behavior of dialog element. + * + */ + const handleAnimationEnd = () => { + const drawerElement = ref.current; + + // Check if the drawerElement is null or is a div, which means it is not a dialog element. + if (!drawerElement || drawerElement instanceof HTMLDivElement) { + return; + } + + if (open) { + const firstFocusable = drawerElement.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + (firstFocusable as HTMLElement)?.focus(); + } + }; + + return ( + + +
+
+
+ + {title} + + {showCloseButton && ( + + + + )} +
+
+
+ {/* Empty span element used to track if children container has scrolled down */} + {} + {children} +
+
+
+
+
+
+ ); + } +); + +Drawer.displayName = 'Drawer'; diff --git a/packages/compass-components/src/components/drawer/drawer/drawer.types.ts b/packages/compass-components/src/components/drawer/drawer/drawer.types.ts new file mode 100644 index 00000000000..31647570b74 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer/drawer.types.ts @@ -0,0 +1,47 @@ +import type React from 'react'; + +import type { + DarkModeProps, + HTMLElementProps, + LgIdProps, +} from '@leafygreen-ui/lib'; + +/** + * Options to control how the drawer element is displayed + * @param Embedded will display a drawer as a `
` element that takes up the full parent container height and on the same elevation as container page content. It is recommended to wrap an embedded drawer within the `DrawerLayout` container + * @param Overlay will display a drawer as a `` element that takes up the full parent container height and elevated above container page content. It is recommended to wrap an overlay drawer within the `DrawerLayout` container + */ +export const DisplayMode = { + Embedded: 'embedded', + Overlay: 'overlay', +} as const; +export type DisplayMode = typeof DisplayMode[keyof typeof DisplayMode]; + +export interface DrawerProps + extends Omit, 'title'>, + DarkModeProps, + LgIdProps { + /** + * Options to display the drawer element + * @defaultValue 'overlay' + * @param Embedded will display a drawer as a `
` element that takes up the full parent container height and on the same elevation as container page content. It is recommended to wrap an embedded drawer within the `DrawerLayout` container + * @param Overlay will display a drawer as a `` element that takes up the full parent container height and elevated above container page content. It is recommended to wrap an overlay drawer within the `DrawerLayout` container + */ + displayMode?: DisplayMode; + + /** + * Determines if the Drawer is open or closed + * @defaultValue false + */ + open?: boolean; + + /** + * Event handler called on close button click. If provided, a close button will be rendered in the Drawer header. + */ + onClose?: React.MouseEventHandler; + + /** + * Title of the Drawer + */ + title: React.ReactNode; +} diff --git a/packages/compass-components/src/components/drawer/drawer/index.ts b/packages/compass-components/src/components/drawer/drawer/index.ts new file mode 100644 index 00000000000..6a6390cc331 --- /dev/null +++ b/packages/compass-components/src/components/drawer/drawer/index.ts @@ -0,0 +1,4 @@ +export { Drawer } from './drawer'; +export { MOBILE_BREAKPOINT } from './drawer.constants'; +export { drawerClassName } from './drawer.styles'; +export { DisplayMode, type DrawerProps } from './drawer.types'; diff --git a/packages/compass-components/src/components/drawer/embedded-drawer-layout/embedded-drawer-layout.styles.ts b/packages/compass-components/src/components/drawer/embedded-drawer-layout/embedded-drawer-layout.styles.ts new file mode 100644 index 00000000000..5eda206c380 --- /dev/null +++ b/packages/compass-components/src/components/drawer/embedded-drawer-layout/embedded-drawer-layout.styles.ts @@ -0,0 +1,69 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { breakpoints } from '@leafygreen-ui/tokens'; + +import { GRID_AREA } from '../constants'; +import { PANEL_WIDTH, TOOLBAR_WIDTH } from '../constants'; +import { MOBILE_BREAKPOINT } from '../drawer'; +import { drawerTransitionDuration } from '../drawer/drawer.styles'; + +const baseStyles = css` + width: 100%; + display: grid; + grid-template-columns: auto 0; + transition-property: grid-template-columns, grid-template-rows; + transition-timing-function: ease-in-out; + transition-duration: ${drawerTransitionDuration}ms; + overflow: hidden; + position: relative; + height: 100%; +`; + +const drawerBaseStyles = css` + @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + grid-template-columns: unset; + grid-template-rows: 100% 0; + } +`; + +// If there is no toolbar and the drawer is open, we need to shift the layout by the panel width; +const drawerOpenStyles = css` + grid-template-columns: auto ${PANEL_WIDTH}px; + + @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + grid-template-rows: 50% 50%; + } +`; + +const withToolbarBaseStyles = css` + grid-template-columns: auto ${TOOLBAR_WIDTH}px; + grid-template-areas: '${GRID_AREA.content} ${GRID_AREA.drawer}'; +`; + +// If there is a toolbar and the drawer is open, we need to shift the layout by toolbar width + panel width; +const withToolbarOpenStyles = css` + grid-template-columns: auto ${PANEL_WIDTH + TOOLBAR_WIDTH}px; + + @media only screen and (max-width: ${breakpoints.Tablet}px) { + grid-template-columns: auto ${TOOLBAR_WIDTH}px; + } +`; + +export const getEmbeddedDrawerLayoutStyles = ({ + className, + isDrawerOpen, + hasToolbar = false, +}: { + className?: string; + isDrawerOpen: boolean; + hasToolbar?: boolean; +}) => + cx( + baseStyles, + { + [withToolbarBaseStyles]: hasToolbar, + [withToolbarOpenStyles]: isDrawerOpen && hasToolbar, + [drawerBaseStyles]: !hasToolbar, + [drawerOpenStyles]: isDrawerOpen && !hasToolbar, + }, + className + ); diff --git a/packages/compass-components/src/components/drawer/embedded-drawer-layout/embedded-drawer-layout.tsx b/packages/compass-components/src/components/drawer/embedded-drawer-layout/embedded-drawer-layout.tsx new file mode 100644 index 00000000000..b555c05dc0f --- /dev/null +++ b/packages/compass-components/src/components/drawer/embedded-drawer-layout/embedded-drawer-layout.tsx @@ -0,0 +1,42 @@ +import React, { forwardRef } from 'react'; + +import { getEmbeddedDrawerLayoutStyles } from './embedded-drawer-layout.styles'; +import type { EmbeddedDrawerLayoutProps } from './embedded-drawer-layout.types'; + +/** + * @internal + * + * This layout wrapper is used to create a layout that has 2 grid columns. The main content is on the left and the drawer is on the right. + * + * Since this layout is used for embedded drawers, when the drawer is open, the layout will shift to the right by the width of the drawer + toolbar if it exists. + * + */ +export const EmbeddedDrawerLayout = forwardRef< + HTMLDivElement, + EmbeddedDrawerLayoutProps +>( + ( + { + children, + className, + isDrawerOpen = false, + hasToolbar = false, + }: EmbeddedDrawerLayoutProps, + forwardedRef + ) => { + return ( +
+ {children} +
+ ); + } +); + +EmbeddedDrawerLayout.displayName = 'EmbeddedDrawerLayout'; diff --git a/packages/compass-components/src/components/drawer/embedded-drawer-layout/embedded-drawer-layout.types.ts b/packages/compass-components/src/components/drawer/embedded-drawer-layout/embedded-drawer-layout.types.ts new file mode 100644 index 00000000000..550a66cc25b --- /dev/null +++ b/packages/compass-components/src/components/drawer/embedded-drawer-layout/embedded-drawer-layout.types.ts @@ -0,0 +1,8 @@ +import type { BaseLayoutComponentProps } from '../layout-component/layout-component.styles'; + +export type EmbeddedDrawerLayoutProps = BaseLayoutComponentProps & { + /** + * Determines if the Drawer is open. This will shift the layout to the right by the width of the drawer + toolbar if it exists if the display mode is set to 'embedded'. + */ + isDrawerOpen?: boolean; +}; diff --git a/packages/compass-components/src/components/drawer/embedded-drawer-layout/index.tsx b/packages/compass-components/src/components/drawer/embedded-drawer-layout/index.tsx new file mode 100644 index 00000000000..f3bb4e52e6b --- /dev/null +++ b/packages/compass-components/src/components/drawer/embedded-drawer-layout/index.tsx @@ -0,0 +1,2 @@ +export { EmbeddedDrawerLayout } from './embedded-drawer-layout'; +export { type EmbeddedDrawerLayoutProps } from './embedded-drawer-layout.types'; diff --git a/packages/compass-components/src/components/drawer/index.ts b/packages/compass-components/src/components/drawer/index.ts new file mode 100644 index 00000000000..42c42b2add4 --- /dev/null +++ b/packages/compass-components/src/components/drawer/index.ts @@ -0,0 +1,13 @@ +export { + DisplayMode, + Drawer, + drawerClassName, + type DrawerProps, +} from './drawer'; +export { DrawerLayout, type DrawerLayoutProps } from './drawer-layout'; +export { + DrawerStackProvider, + useDrawerStackContext, +} from './drawer-stack-context'; +export { useDrawerToolbarContext } from './drawer-toolbar-context'; +export { getLgIds } from './utils'; diff --git a/packages/compass-components/src/components/drawer/layout-component/index.ts b/packages/compass-components/src/components/drawer/layout-component/index.ts new file mode 100644 index 00000000000..6c7f7d5d1e9 --- /dev/null +++ b/packages/compass-components/src/components/drawer/layout-component/index.ts @@ -0,0 +1,2 @@ +export { LayoutComponent } from './layout-component'; +export { type LayoutComponentProps } from './layout-component.styles'; diff --git a/packages/compass-components/src/components/drawer/layout-component/layout-component.styles.ts b/packages/compass-components/src/components/drawer/layout-component/layout-component.styles.ts new file mode 100644 index 00000000000..07dfb1c8bb3 --- /dev/null +++ b/packages/compass-components/src/components/drawer/layout-component/layout-component.styles.ts @@ -0,0 +1,27 @@ +import type { DarkModeProps, HTMLElementProps } from '@leafygreen-ui/lib'; + +import type { DisplayMode } from '../drawer'; +import type { EmbeddedDrawerLayoutProps } from '../embedded-drawer-layout'; +import type { OverlayDrawerLayoutProps } from '../overlay-drawer-layout'; + +export type LayoutComponentProps = { + displayMode: DisplayMode; +} & DarkModeProps & + ( + | EmbeddedDrawerLayoutProps + | (OverlayDrawerLayoutProps & { isDrawerOpen?: never }) + ); + +// This interface is used to define the common properties for OverlayDrawerLayout and EmbeddedDrawerLayout +export interface BaseLayoutComponentProps + extends Omit, 'children'> { + /** + * Determines if the Toolbar is present in the layout + */ + hasToolbar?: boolean; + + /** + * The content to be rendered inside the Drawer + */ + children: React.ReactNode; +} diff --git a/packages/compass-components/src/components/drawer/layout-component/layout-component.tsx b/packages/compass-components/src/components/drawer/layout-component/layout-component.tsx new file mode 100644 index 00000000000..572a0af6248 --- /dev/null +++ b/packages/compass-components/src/components/drawer/layout-component/layout-component.tsx @@ -0,0 +1,52 @@ +import React, { forwardRef } from 'react'; + +import LeafyGreenProvider, { + useDarkMode, +} from '@leafygreen-ui/leafygreen-provider'; + +import { DisplayMode } from '../drawer'; +import { EmbeddedDrawerLayout } from '../embedded-drawer-layout'; +import { OverlayDrawerLayout } from '../overlay-drawer-layout'; + +import type { LayoutComponentProps } from './layout-component.styles'; + +/** + * @internal + * + * LayoutComponent is a wrapper component that provides a layout for displaying content with a drawer. + * It can be used in both overlay and embedded modes. + */ +export const LayoutComponent = forwardRef( + ( + { + children, + displayMode, + darkMode: darkModeProp, + isDrawerOpen = false, + ...rest + }: LayoutComponentProps, + forwardRef + ) => { + const { darkMode } = useDarkMode(darkModeProp); + + return ( + + {displayMode === DisplayMode.Overlay ? ( + + {children} + + ) : ( + + {children} + + )} + + ); + } +); + +LayoutComponent.displayName = 'LayoutComponent'; diff --git a/packages/compass-components/src/components/drawer/overlay-drawer-layout/index.ts b/packages/compass-components/src/components/drawer/overlay-drawer-layout/index.ts new file mode 100644 index 00000000000..1305ed9f47e --- /dev/null +++ b/packages/compass-components/src/components/drawer/overlay-drawer-layout/index.ts @@ -0,0 +1,2 @@ +export { OverlayDrawerLayout } from './overlay-drawer-layout'; +export { type OverlayDrawerLayoutProps } from './overlay-drawer-layout.types'; diff --git a/packages/compass-components/src/components/drawer/overlay-drawer-layout/overlay-drawer-layout.styles.ts b/packages/compass-components/src/components/drawer/overlay-drawer-layout/overlay-drawer-layout.styles.ts new file mode 100644 index 00000000000..7196ef8b7c3 --- /dev/null +++ b/packages/compass-components/src/components/drawer/overlay-drawer-layout/overlay-drawer-layout.styles.ts @@ -0,0 +1,38 @@ +import { css, cx } from '@leafygreen-ui/emotion'; + +import { GRID_AREA, TOOLBAR_WIDTH } from '../constants'; + +const baseStyles = css` + width: 100%; + position: relative; + height: inherit; +`; + +const drawerBaseStyles = css` + display: grid; + grid-template-columns: auto 0px; + overflow: hidden; +`; + +const toolbarBaseStyles = css` + display: grid; + grid-template-columns: auto ${TOOLBAR_WIDTH}px; + grid-template-areas: '${GRID_AREA.content} ${GRID_AREA.drawer}'; + height: 100%; +`; + +export const getOverlayDrawerLayoutStyles = ({ + className, + hasToolbar = false, +}: { + className?: string; + hasToolbar?: boolean; +}) => + cx( + baseStyles, + { + [toolbarBaseStyles]: hasToolbar, + [drawerBaseStyles]: !hasToolbar, + }, + className + ); diff --git a/packages/compass-components/src/components/drawer/overlay-drawer-layout/overlay-drawer-layout.tsx b/packages/compass-components/src/components/drawer/overlay-drawer-layout/overlay-drawer-layout.tsx new file mode 100644 index 00000000000..aeb54d6aa79 --- /dev/null +++ b/packages/compass-components/src/components/drawer/overlay-drawer-layout/overlay-drawer-layout.tsx @@ -0,0 +1,36 @@ +import React, { forwardRef } from 'react'; + +import { getOverlayDrawerLayoutStyles } from './overlay-drawer-layout.styles'; +import type { OverlayDrawerLayoutProps } from './overlay-drawer-layout.types'; + +/** + * @internal + * + * This layout wrapper is used to create a layout that has 2 grid columns. The main content is on the left and the drawer is on the right. + * + * Since this layout is used for overlay drawers, when the drawer is open, the layout will not shift. Instead the shifting is handled by the children of this component. + * + */ +export const OverlayDrawerLayout = forwardRef< + HTMLDivElement, + OverlayDrawerLayoutProps +>( + ( + { children, className, hasToolbar = false }: OverlayDrawerLayoutProps, + forwardedRef + ) => { + return ( +
+ {children} +
+ ); + } +); + +OverlayDrawerLayout.displayName = 'OverlayDrawerLayout'; diff --git a/packages/compass-components/src/components/drawer/overlay-drawer-layout/overlay-drawer-layout.types.ts b/packages/compass-components/src/components/drawer/overlay-drawer-layout/overlay-drawer-layout.types.ts new file mode 100644 index 00000000000..bdda28fa048 --- /dev/null +++ b/packages/compass-components/src/components/drawer/overlay-drawer-layout/overlay-drawer-layout.types.ts @@ -0,0 +1,3 @@ +import type { BaseLayoutComponentProps } from '../layout-component/layout-component.styles'; + +export type OverlayDrawerLayoutProps = BaseLayoutComponentProps; diff --git a/packages/compass-components/src/components/drawer/utils/get-lg-ids.ts b/packages/compass-components/src/components/drawer/utils/get-lg-ids.ts new file mode 100644 index 00000000000..b2724cec531 --- /dev/null +++ b/packages/compass-components/src/components/drawer/utils/get-lg-ids.ts @@ -0,0 +1,10 @@ +import type { LgIdString } from '@leafygreen-ui/lib'; + +export const DEFAULT_LGID_ROOT = 'lg-drawer'; + +export const getLgIds = (root: LgIdString = DEFAULT_LGID_ROOT) => + ({ + root, + closeButton: `${root}-close_button`, + toolbar: `${root}-toolbar`, + } as const); diff --git a/packages/compass-components/src/components/drawer/utils/index.ts b/packages/compass-components/src/components/drawer/utils/index.ts new file mode 100644 index 00000000000..a9a390b5345 --- /dev/null +++ b/packages/compass-components/src/components/drawer/utils/index.ts @@ -0,0 +1 @@ +export { DEFAULT_LGID_ROOT, getLgIds } from './get-lg-ids'; diff --git a/packages/compass-components/src/components/leafygreen.tsx b/packages/compass-components/src/components/leafygreen.tsx index dd75a3543f9..01fb30c4892 100644 --- a/packages/compass-components/src/components/leafygreen.tsx +++ b/packages/compass-components/src/components/leafygreen.tsx @@ -134,6 +134,16 @@ const TextInput: typeof LeafyGreenTextInput = React.forwardRef( TextInput.displayName = 'TextInput'; +export { + Drawer, + DrawerLayout, + DisplayMode as DrawerDisplayMode, + DrawerStackProvider, + useDrawerStackContext, + useDrawerToolbarContext, + type DrawerLayoutProps, +} from './drawer'; + // 3. Export the leafygreen components. export { AtlasNavGraphic, diff --git a/packages/compass-components/src/components/toolbar/constants.ts b/packages/compass-components/src/components/toolbar/constants.ts new file mode 100644 index 00000000000..c043dbbe3fa --- /dev/null +++ b/packages/compass-components/src/components/toolbar/constants.ts @@ -0,0 +1,2 @@ +export const TOOLBAR_WIDTH = 48; +export const ICON_BUTTON_HEIGHT = 48; diff --git a/packages/compass-components/src/components/toolbar/context/index.ts b/packages/compass-components/src/components/toolbar/context/index.ts new file mode 100644 index 00000000000..b8ca146d438 --- /dev/null +++ b/packages/compass-components/src/components/toolbar/context/index.ts @@ -0,0 +1,5 @@ +export { ToolbarContextProvider, useToolbarContext } from './toolbar-context'; +export { + ToolbarDescendantsContext, + useToolbarDescendantsContext, +} from './toolbar-descendants-context'; diff --git a/packages/compass-components/src/components/toolbar/context/toolbar-context.tsx b/packages/compass-components/src/components/toolbar/context/toolbar-context.tsx new file mode 100644 index 00000000000..944f23ea00f --- /dev/null +++ b/packages/compass-components/src/components/toolbar/context/toolbar-context.tsx @@ -0,0 +1,48 @@ +import React, { + createContext, + type PropsWithChildren, + useContext, + useMemo, +} from 'react'; + +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; + +import { getLgIds } from '../utils'; + +import type { ToolbarProviderProps } from './toolbar-context.types'; + +export const ToolbarContext = createContext({ + focusedIndex: undefined, + shouldFocus: false, + lgIds: getLgIds(), + handleOnIconButtonClick: () => {}, +}); + +export const useToolbarContext = () => + useContext(ToolbarContext); + +export const ToolbarContextProvider = ({ + children, + focusedIndex, + shouldFocus, + lgIds, + handleOnIconButtonClick, + darkMode = false, +}: PropsWithChildren) => { + const ToolbarProvider = ToolbarContext.Provider; + + const toolbarProviderData = useMemo(() => { + return { + focusedIndex, + shouldFocus, + lgIds, + handleOnIconButtonClick, + }; + }, [focusedIndex, shouldFocus, lgIds, handleOnIconButtonClick]); + + return ( + + {children} + + ); +}; diff --git a/packages/compass-components/src/components/toolbar/context/toolbar-context.types.ts b/packages/compass-components/src/components/toolbar/context/toolbar-context.types.ts new file mode 100644 index 00000000000..5a7e89020ca --- /dev/null +++ b/packages/compass-components/src/components/toolbar/context/toolbar-context.types.ts @@ -0,0 +1,29 @@ +import type { DarkModeProps } from '@leafygreen-ui/lib'; + +import type { GetLgIdsReturnType } from '../utils'; + +export type ToolbarProviderProps = DarkModeProps & { + /** + * The index of the currently focused item in the toolbar. + */ + focusedIndex?: number; + + /** + * Whether the toolbar should focus the currently focused item. This will prevent this component from hijacking focus on initial page load. + */ + shouldFocus?: boolean; + + /** + * LGIDs for Toolbar components. + */ + lgIds: GetLgIdsReturnType; + + /** + * Callback to handle clicks on ToolbarIconButtons. + */ + handleOnIconButtonClick: ( + event: React.MouseEvent, + focusedIndex: number, + onClick?: (e: React.MouseEvent) => void + ) => void; +}; diff --git a/packages/compass-components/src/components/toolbar/context/toolbar-descendants-context.tsx b/packages/compass-components/src/components/toolbar/context/toolbar-descendants-context.tsx new file mode 100644 index 00000000000..c9e035d5976 --- /dev/null +++ b/packages/compass-components/src/components/toolbar/context/toolbar-descendants-context.tsx @@ -0,0 +1,11 @@ +import { + createDescendantsContext, + useDescendantsContext, +} from '@leafygreen-ui/descendants'; + +export const ToolbarDescendantsContext = + createDescendantsContext('ToolbarDescendantsContext'); + +export function useToolbarDescendantsContext() { + return useDescendantsContext(ToolbarDescendantsContext); +} diff --git a/packages/compass-components/src/components/toolbar/index.ts b/packages/compass-components/src/components/toolbar/index.ts new file mode 100644 index 00000000000..db8b53d9ee6 --- /dev/null +++ b/packages/compass-components/src/components/toolbar/index.ts @@ -0,0 +1,7 @@ +export * from './constants'; +export { Toolbar, toolbarClassName, type ToolbarProps } from './toolbar'; +export { + ToolbarIconButton, + type ToolbarIconButtonProps, +} from './toolbar-icon-button'; +export { DEFAULT_LGID_ROOT, getLgIds, type GetLgIdsReturnType } from './utils'; diff --git a/packages/compass-components/src/components/toolbar/toolbar-icon-button/index.ts b/packages/compass-components/src/components/toolbar/toolbar-icon-button/index.ts new file mode 100644 index 00000000000..ed80f67b197 --- /dev/null +++ b/packages/compass-components/src/components/toolbar/toolbar-icon-button/index.ts @@ -0,0 +1,2 @@ +export { ToolbarIconButton } from './toolbar-icon-button'; +export { type ToolbarIconButtonProps } from './toolbar-icon-button.types'; diff --git a/packages/compass-components/src/components/toolbar/toolbar-icon-button/toolbar-icon-button.styles.ts b/packages/compass-components/src/components/toolbar/toolbar-icon-button/toolbar-icon-button.styles.ts new file mode 100644 index 00000000000..74a16188a3f --- /dev/null +++ b/packages/compass-components/src/components/toolbar/toolbar-icon-button/toolbar-icon-button.styles.ts @@ -0,0 +1,55 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; +import { borderRadius } from '@leafygreen-ui/tokens'; + +import { ICON_BUTTON_HEIGHT } from '../constants'; + +export const baseIconButtonStyles = css` + &, + &:hover, + &[data-hover='true'], + &::before { + border-radius: ${borderRadius[150]}px; + } +`; + +export const getIconButtonActiveStyles = ({ theme }: { theme: Theme }) => + cx( + css` + background: ${theme === Theme.Light + ? palette.green.light3 + : palette.green.dark3}; + + color: ${theme === Theme.Light + ? palette.green.dark2 + : palette.green.light1}; + ` + ); + +export const getIconButtonStyles = ({ + active, + theme, + disabled, + className, +}: { + active: boolean; + theme: Theme; + disabled: boolean; + className?: string; +}) => + cx( + css` + ${baseIconButtonStyles} + `, + { + [getIconButtonActiveStyles({ theme })]: active && !disabled, + }, + className + ); + +export const triggerStyles = css` + display: flex; + height: ${ICON_BUTTON_HEIGHT}px; + align-items: center; +`; diff --git a/packages/compass-components/src/components/toolbar/toolbar-icon-button/toolbar-icon-button.tsx b/packages/compass-components/src/components/toolbar/toolbar-icon-button/toolbar-icon-button.tsx new file mode 100644 index 00000000000..a4015541e6c --- /dev/null +++ b/packages/compass-components/src/components/toolbar/toolbar-icon-button/toolbar-icon-button.tsx @@ -0,0 +1,101 @@ +import React, { type ComponentPropsWithoutRef, useEffect } from 'react'; + +import { useDescendant } from '@leafygreen-ui/descendants'; +import Icon from '@leafygreen-ui/icon'; +import IconButton from '@leafygreen-ui/icon-button'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { getNodeTextContent } from '@leafygreen-ui/lib'; +import Tooltip, { Align } from '@leafygreen-ui/tooltip'; + +import { ToolbarDescendantsContext, useToolbarContext } from '../context'; + +import { + getIconButtonStyles, + triggerStyles, +} from './toolbar-icon-button.styles'; +import { type ToolbarIconButtonProps } from './toolbar-icon-button.types'; + +export const ToolbarIconButton = React.forwardRef< + HTMLButtonElement, + ToolbarIconButtonProps +>( + ( + { + className, + onClick, + label, + glyph, + disabled = false, + active = false, + 'aria-label': ariaLabel, + ...rest + }: ToolbarIconButtonProps, + forwardedRef + ) => { + const { theme } = useDarkMode(); + const { index, ref } = useDescendant( + ToolbarDescendantsContext, + forwardedRef + ); + const { focusedIndex, shouldFocus, lgIds, handleOnIconButtonClick } = + useToolbarContext(); + const isFocusable = index === focusedIndex; + + if (focusedIndex === undefined) { + // eslint-disable-next-line no-console + console.error( + 'ToolbarIconButton should only be used inside the Toolbar component.' + ); + } + + if (glyph === undefined) { + // eslint-disable-next-line no-console + console.error( + 'A glpyh is required for ToolbarIconButton. Please provide a valid glyph. The list of available glyphs can be found in the LG Icon README, https://github.com/mongodb/leafygreen-ui/blob/main/packages/icon/README.md#properties.' + ); + } + + useEffect(() => { + // shouldFocus prevents this component from hijacking focus on initial page load. + if (isFocusable && shouldFocus) ref.current?.focus(); + }, [isFocusable, ref, shouldFocus]); + + return ( + + ) => + handleOnIconButtonClick(event, index, onClick) + } + disabled={disabled} + data-testid={`${lgIds.iconButton}-${index}`} + data-lgid={`${lgIds.iconButton}-${index}`} + data-active={active} + ref={ref} + {...(rest as ComponentPropsWithoutRef<'button'>)} + > + + +
+ } + > + {label} + + ); + } +); + +ToolbarIconButton.displayName = 'ToolbarIconButton'; diff --git a/packages/compass-components/src/components/toolbar/toolbar-icon-button/toolbar-icon-button.types.ts b/packages/compass-components/src/components/toolbar/toolbar-icon-button/toolbar-icon-button.types.ts new file mode 100644 index 00000000000..89e3803418d --- /dev/null +++ b/packages/compass-components/src/components/toolbar/toolbar-icon-button/toolbar-icon-button.types.ts @@ -0,0 +1,31 @@ +import type { GlyphName } from '@leafygreen-ui/icon'; +import type { BaseIconButtonProps as IconButtonProps } from '@leafygreen-ui/icon-button'; + +type ButtonProps = Omit< + IconButtonProps, + | 'tabIndex' + | 'href' + | 'as' + | 'ref' + | 'children' + | 'size' + | 'darkMode' + | 'onClick' +>; + +export interface ToolbarIconButtonProps extends ButtonProps { + /** + * The LG Icon that will render in the button + */ + glyph: GlyphName; + + /** + * The text that will render in the tooltip on hover + */ + label: React.ReactNode; + + /** + * Callback fired when the ToolbarIconButton is clicked + */ + onClick?: (event: React.MouseEvent) => void; +} diff --git a/packages/compass-components/src/components/toolbar/toolbar/index.ts b/packages/compass-components/src/components/toolbar/toolbar/index.ts new file mode 100644 index 00000000000..8af49631bdf --- /dev/null +++ b/packages/compass-components/src/components/toolbar/toolbar/index.ts @@ -0,0 +1,3 @@ +export { Toolbar } from './toolbar'; +export { toolbarClassName } from './toolbar.styles'; +export { type ToolbarProps } from './toolbar.types'; diff --git a/packages/compass-components/src/components/toolbar/toolbar/toolbar.styles.ts b/packages/compass-components/src/components/toolbar/toolbar/toolbar.styles.ts new file mode 100644 index 00000000000..40bc9974e18 --- /dev/null +++ b/packages/compass-components/src/components/toolbar/toolbar/toolbar.styles.ts @@ -0,0 +1,35 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { createUniqueClassName, type Theme } from '@leafygreen-ui/lib'; +import { color, focusRing } from '@leafygreen-ui/tokens'; + +import { TOOLBAR_WIDTH } from '../constants'; + +export const toolbarClassName = createUniqueClassName('lg-toolbar'); + +export const getBaseStyles = ({ + theme, + className, +}: { + theme: Theme; + className?: string; +}) => + cx( + css` + height: 100%; + width: ${TOOLBAR_WIDTH}px; + display: flex; + flex-direction: column; + align-items: center; + background: ${color[theme].background.primary.default}; + border: 1px solid ${color[theme].border.secondary.default}; + + // Show the focus ring on the toolbar itself when a child element is focused and only when navigating with a keyboard + &:has(:focus-visible) { + box-shadow: ${focusRing[theme].default}; + // So the focus ring overlaps sibling elements + z-index: 1; + } + `, + toolbarClassName, + className + ); diff --git a/packages/compass-components/src/components/toolbar/toolbar/toolbar.tsx b/packages/compass-components/src/components/toolbar/toolbar/toolbar.tsx new file mode 100644 index 00000000000..7cfaab6f791 --- /dev/null +++ b/packages/compass-components/src/components/toolbar/toolbar/toolbar.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; + +import { + DescendantsProvider, + useInitDescendants, +} from '@leafygreen-ui/descendants'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; + +import { ToolbarContextProvider, ToolbarDescendantsContext } from '../context'; +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils'; + +import { getBaseStyles } from './toolbar.styles'; +import { type ToolbarProps } from './toolbar.types'; + +export const Toolbar = React.forwardRef( + ( + { + className, + children, + darkMode: darkModeProp, + 'data-lgid': dataLgId = DEFAULT_LGID_ROOT, + ...rest + }: ToolbarProps, + forwardedRef + ) => { + const { darkMode, theme } = useDarkMode(darkModeProp); + const { descendants, dispatch } = useInitDescendants( + ToolbarDescendantsContext + ); + const [focusedIndex, setFocusedIndex] = useState(0); + const childrenLength = descendants?.length ?? 0; + const [isUsingKeyboard, setIsUsingKeyboard] = useState(false); + + const lgIds = getLgIds(dataLgId); + + /** + * Implements roving tabindex + * https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex + * @param event Keyboard event + */ + const handleKeyDown = (event: React.KeyboardEvent) => { + // Note: Arrow keys don't fire a keyPress event — need to use onKeyDownCapture + // We only handle up and down arrow keys + switch (event.key) { + case keyMap.ArrowDown: + event.stopPropagation(); + event.preventDefault(); + setIsUsingKeyboard(true); + setFocusedIndex((focusedIndex + 1) % childrenLength); + break; + case keyMap.ArrowUp: + event.stopPropagation(); + event.preventDefault(); + setIsUsingKeyboard(true); + setFocusedIndex((focusedIndex - 1 + childrenLength) % childrenLength); + break; + default: + break; + } + }; + + /** + * Callback to handle click events on ToolbarIconButtons. + * Also updates the focused index to ensure that the correct button is focused when using the up/down arrows. + * @param event MouseEvent + * @param focusedIndex number + * @param onClick MouseEvent + */ + const handleOnIconButtonClick = ( + event: React.MouseEvent, + focusedIndex: number, + onClick?: (e: React.MouseEvent) => void + ) => { + onClick?.(event); + // This ensures that on click, the buttons tabIndex is set to 0 so that when the up/down arrows are pressed, the correct button is focused + setFocusedIndex(focusedIndex); + }; + + return ( + + +
setIsUsingKeyboard(false)} + onMouseDown={() => setIsUsingKeyboard(false)} + data-lgid={lgIds.root} + data-testid={lgIds.root} + {...rest} + > + {children} +
+
+
+ ); + } +); + +Toolbar.displayName = 'Toolbar'; diff --git a/packages/compass-components/src/components/toolbar/toolbar/toolbar.types.ts b/packages/compass-components/src/components/toolbar/toolbar/toolbar.types.ts new file mode 100644 index 00000000000..1be22f0ce54 --- /dev/null +++ b/packages/compass-components/src/components/toolbar/toolbar/toolbar.types.ts @@ -0,0 +1,9 @@ +import type { ComponentPropsWithRef } from 'react'; + +import type { DarkModeProps, LgIdProps } from '@leafygreen-ui/lib'; +export interface ToolbarProps + extends ComponentPropsWithRef<'div'>, + DarkModeProps, + LgIdProps { + children: React.ReactNode; +} diff --git a/packages/compass-components/src/components/toolbar/utils/get-lg-ids.ts b/packages/compass-components/src/components/toolbar/utils/get-lg-ids.ts new file mode 100644 index 00000000000..fe01b95443a --- /dev/null +++ b/packages/compass-components/src/components/toolbar/utils/get-lg-ids.ts @@ -0,0 +1,12 @@ +export const DEFAULT_LGID_ROOT = 'lg-toolbar'; + +export const getLgIds = (root: `lg-${string}` = DEFAULT_LGID_ROOT) => { + const ids = { + root, + iconButton: `${root}-icon_button`, + iconButtonTooltip: `${root}-icon_button-tooltip`, + } as const; + return ids; +}; + +export type GetLgIdsReturnType = ReturnType; diff --git a/packages/compass-components/src/components/toolbar/utils/index.ts b/packages/compass-components/src/components/toolbar/utils/index.ts new file mode 100644 index 00000000000..a24b0ab00a7 --- /dev/null +++ b/packages/compass-components/src/components/toolbar/utils/index.ts @@ -0,0 +1,5 @@ +export { + DEFAULT_LGID_ROOT, + getLgIds, + type GetLgIdsReturnType, +} from './get-lg-ids';