diff --git a/CHANGELOG.md b/CHANGELOG.md index 73df8d261d..d77c05b587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.27.0](https://github.com/okta/odyssey/compare/v1.26.0...v1.27.0) (2024-11-14) + +### Features + +- adds drag-n-drop feature to the sidenav ([#2405](https://github.com/okta/odyssey/issues/2405)) ([aca8dad](https://github.com/okta/odyssey/commit/aca8dadd7130847112483f49bdc52b065bde3c88)) + +## [1.26.0](https://github.com/okta/odyssey/compare/v1.25.0...v1.26.0) (2024-11-04) + +### Features + +- adds side-nav collapse handle ([#2385](https://github.com/okta/odyssey/issues/2385)) ([1582be5](https://github.com/okta/odyssey/commit/1582be5669000c6dcec5881ba8178406a457a3b0)) +- adds the ability to render an encapsulated Unified UI Shell ([#2373](https://github.com/okta/odyssey/issues/2373)) ([f964a29](https://github.com/okta/odyssey/commit/f964a29c7eb956fc05cb16fd51963a03c6b08507)) + +### Bug Fixes + +- enforce AppTile image heighgt ([#2400](https://github.com/okta/odyssey/issues/2400)) ([7e91875](https://github.com/okta/odyssey/commit/7e9187582cf1c33e6da8297c778549aebbe81d88)) + ## [1.25.0](https://github.com/okta/odyssey/compare/v1.24.1...v1.25.0) (2024-10-15) ### Features diff --git a/lerna.json b/lerna.json index 5161e8c333..e2dc5414d9 100644 --- a/lerna.json +++ b/lerna.json @@ -4,5 +4,5 @@ "npmClient": "yarn", "packages": ["packages/*"], "useNx": false, - "version": "1.25.0" + "version": "1.27.0" } diff --git a/packages/browserslist-config-odyssey/CHANGELOG.md b/packages/browserslist-config-odyssey/CHANGELOG.md index caf418d15b..fa2545e8af 100644 --- a/packages/browserslist-config-odyssey/CHANGELOG.md +++ b/packages/browserslist-config-odyssey/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.27.0](https://github.com/okta/odyssey/compare/v1.26.0...v1.27.0) (2024-11-14) + +**Note:** Version bump only for package @okta/browserslist-config-odyssey + +## [1.26.0](https://github.com/okta/odyssey/compare/v1.25.0...v1.26.0) (2024-11-04) + +**Note:** Version bump only for package @okta/browserslist-config-odyssey + ## [1.25.0](https://github.com/okta/odyssey/compare/v1.24.1...v1.25.0) (2024-10-15) **Note:** Version bump only for package @okta/browserslist-config-odyssey diff --git a/packages/browserslist-config-odyssey/package.json b/packages/browserslist-config-odyssey/package.json index 19c3d1496b..1c8c9b39d2 100644 --- a/packages/browserslist-config-odyssey/package.json +++ b/packages/browserslist-config-odyssey/package.json @@ -1,6 +1,6 @@ { "name": "@okta/browserslist-config-odyssey", - "version": "1.25.0", + "version": "1.27.0", "description": "Browserslist config for Odyssey, Okta's design system", "author": "Okta, Inc.", "license": "Apache-2.0", diff --git a/packages/odyssey-babel-preset/CHANGELOG.md b/packages/odyssey-babel-preset/CHANGELOG.md index 11a79fd68a..a6b6dc0b13 100644 --- a/packages/odyssey-babel-preset/CHANGELOG.md +++ b/packages/odyssey-babel-preset/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.27.0](https://github.com/okta/odyssey/compare/v1.26.0...v1.27.0) (2024-11-14) + +**Note:** Version bump only for package @okta/odyssey-babel-preset + +## [1.26.0](https://github.com/okta/odyssey/compare/v1.25.0...v1.26.0) (2024-11-04) + +**Note:** Version bump only for package @okta/odyssey-babel-preset + ## [1.25.0](https://github.com/okta/odyssey/compare/v1.24.1...v1.25.0) (2024-10-15) **Note:** Version bump only for package @okta/odyssey-babel-preset diff --git a/packages/odyssey-babel-preset/package.json b/packages/odyssey-babel-preset/package.json index 92c0b55db8..e7493493b6 100644 --- a/packages/odyssey-babel-preset/package.json +++ b/packages/odyssey-babel-preset/package.json @@ -1,6 +1,6 @@ { "name": "@okta/odyssey-babel-preset", - "version": "1.25.0", + "version": "1.27.0", "description": "Babel preset for Odyssey, Okta's design system", "author": "Okta, Inc.", "license": "Apache-2.0", diff --git a/packages/odyssey-design-tokens/CHANGELOG.md b/packages/odyssey-design-tokens/CHANGELOG.md index 2a4f0cea7c..00a7484f7f 100644 --- a/packages/odyssey-design-tokens/CHANGELOG.md +++ b/packages/odyssey-design-tokens/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.27.0](https://github.com/okta/odyssey/compare/v1.26.0...v1.27.0) (2024-11-14) + +**Note:** Version bump only for package @okta/odyssey-design-tokens + +## [1.26.0](https://github.com/okta/odyssey/compare/v1.25.0...v1.26.0) (2024-11-04) + +**Note:** Version bump only for package @okta/odyssey-design-tokens + ## [1.25.0](https://github.com/okta/odyssey/compare/v1.24.1...v1.25.0) (2024-10-15) **Note:** Version bump only for package @okta/odyssey-design-tokens diff --git a/packages/odyssey-design-tokens/package.json b/packages/odyssey-design-tokens/package.json index 49c43b3fe8..44b7a85469 100644 --- a/packages/odyssey-design-tokens/package.json +++ b/packages/odyssey-design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "@okta/odyssey-design-tokens", - "version": "1.25.0", + "version": "1.27.0", "description": "Design tokens for Odyssey, Okta's design system", "author": "Okta, Inc.", "license": "Apache-2.0", diff --git a/packages/odyssey-react-mui/CHANGELOG.md b/packages/odyssey-react-mui/CHANGELOG.md index 87b32ec5c4..5e45495e8c 100644 --- a/packages/odyssey-react-mui/CHANGELOG.md +++ b/packages/odyssey-react-mui/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.27.0](https://github.com/okta/odyssey/compare/v1.26.0...v1.27.0) (2024-11-14) + +### Features + +- adds drag-n-drop feature to the sidenav ([#2405](https://github.com/okta/odyssey/issues/2405)) ([aca8dad](https://github.com/okta/odyssey/commit/aca8dadd7130847112483f49bdc52b065bde3c88)) + +## [1.26.0](https://github.com/okta/odyssey/compare/v1.25.0...v1.26.0) (2024-11-04) + +### Features + +- adds side-nav collapse handle ([#2385](https://github.com/okta/odyssey/issues/2385)) ([1582be5](https://github.com/okta/odyssey/commit/1582be5669000c6dcec5881ba8178406a457a3b0)) +- adds the ability to render an encapsulated Unified UI Shell ([#2373](https://github.com/okta/odyssey/issues/2373)) ([f964a29](https://github.com/okta/odyssey/commit/f964a29c7eb956fc05cb16fd51963a03c6b08507)) + +### Bug Fixes + +- enforce AppTile image heighgt ([#2400](https://github.com/okta/odyssey/issues/2400)) ([7e91875](https://github.com/okta/odyssey/commit/7e9187582cf1c33e6da8297c778549aebbe81d88)) + ## [1.25.0](https://github.com/okta/odyssey/compare/v1.24.1...v1.25.0) (2024-10-15) ### Features diff --git a/packages/odyssey-react-mui/i18n.config.json b/packages/odyssey-react-mui/i18n.config.json index 6bd5941fe5..cc36e1352b 100644 --- a/packages/odyssey-react-mui/i18n.config.json +++ b/packages/odyssey-react-mui/i18n.config.json @@ -1,9 +1,10 @@ { + "namespace": "enduser", "resourceFile": "odyssey-react-mui.properties", "resourceFilePath": "packages/odyssey-react-mui/src/properties", "translationsFilePath": "packages/odyssey-react-mui/src/properties/translations", "commitPrefix": "chore(odyssey-react-mui): ", "reviewers": ["okta/eng-globalizationcore", "okta/design-system"], "slackChannel": "odyssey", - "guardian": "guardian-odyssey" + "guardian": "" } diff --git a/packages/odyssey-react-mui/package.json b/packages/odyssey-react-mui/package.json index a03b650355..5feb385800 100644 --- a/packages/odyssey-react-mui/package.json +++ b/packages/odyssey-react-mui/package.json @@ -1,6 +1,6 @@ { "name": "@okta/odyssey-react-mui", - "version": "1.25.0", + "version": "1.27.0", "description": "React MUI components for Odyssey, Okta's design system", "author": "Okta, Inc.", "license": "Apache-2.0", @@ -71,6 +71,9 @@ "directory": "packages/odyssey-react-mui" }, "dependencies": { + "@dnd-kit/core": "6.0.3", + "@dnd-kit/sortable": "7.0.0", + "@dnd-kit/utilities": "3.2.0", "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.0", diff --git a/packages/odyssey-react-mui/src/Box.tsx b/packages/odyssey-react-mui/src/Box.tsx index 39358e507b..2402b29f36 100644 --- a/packages/odyssey-react-mui/src/Box.tsx +++ b/packages/odyssey-react-mui/src/Box.tsx @@ -11,7 +11,7 @@ */ import { Box as MuiBox, BoxProps as MuiBoxProps } from "@mui/material"; -import { ReactNode, forwardRef, memo } from "react"; +import { AriaRole, ReactNode, forwardRef, memo } from "react"; import type { HtmlProps } from "./HtmlProps"; @@ -19,17 +19,19 @@ export type BoxProps = { children?: ReactNode; component?: MuiBoxProps["component"]; id?: MuiBoxProps["id"]; + role?: AriaRole; sx?: MuiBoxProps["sx"]; } & Pick; const Box = forwardRef( - ({ children, component, id, sx, testId, translate }, ref) => ( + ({ children, component, id, role, sx, testId, translate }, ref) => ( diff --git a/packages/odyssey-react-mui/src/Button.tsx b/packages/odyssey-react-mui/src/Buttons/BaseButton.tsx similarity index 75% rename from packages/odyssey-react-mui/src/Button.tsx rename to packages/odyssey-react-mui/src/Buttons/BaseButton.tsx index e980cf9514..31142c5961 100644 --- a/packages/odyssey-react-mui/src/Button.tsx +++ b/packages/odyssey-react-mui/src/Buttons/BaseButton.tsx @@ -16,21 +16,22 @@ import { HTMLAttributes, memo, ReactElement, + ReactNode, useCallback, useImperativeHandle, useMemo, useRef, } from "react"; -import { useButton } from "./ButtonContext"; -import type { HtmlProps } from "./HtmlProps"; -import { FocusHandle } from "./inputUtils"; +import { useButton } from "../Buttons"; +import type { HtmlProps } from "../HtmlProps"; +import { FocusHandle } from "../inputUtils"; import { MuiPropsContext, MuiPropsContextType, useMuiProps, -} from "./MuiPropsContext"; -import { Tooltip } from "./Tooltip"; +} from "../MuiPropsContext"; +import { Tooltip } from "../Tooltip"; export const buttonSizeValues = ["small", "medium", "large"] as const; export const buttonTypeValues = ["button", "submit", "reset"] as const; @@ -43,7 +44,7 @@ export const buttonVariantValues = [ "floatingAction", ] as const; -export type ButtonProps = { +export type BaseButtonProps = { /** * The ref forwarded to the Button */ @@ -85,64 +86,41 @@ export type ButtonProps = { * The click event handler for the Button */ onClick?: MuiButtonProps["onClick"]; -} & ( - | { - /** - * The icon element to display at the end of the Button - */ - endIcon?: ReactElement; - /** - * The text content of the Button - */ - label: string; - /** - * The icon element to display at the start of the Button - */ - startIcon?: ReactElement; - } - | { - /** - * The icon element to display at the end of the Button - */ - endIcon?: ReactElement; - /** - * The text content of the Button - */ - label?: string | "" | undefined; - /** - * The icon element to display at the start of the Button - */ - startIcon: ReactElement; - } - | { - /** - * The icon element to display at the end of the Button - */ - endIcon: ReactElement; - /** - * The text content of the Button - */ - label?: never; - /** - * The icon element to display at the start of the Button - */ - startIcon?: ReactElement; - } -) & - Pick< - HtmlProps, - | "ariaControls" - | "ariaDescribedBy" - | "ariaExpanded" - | "ariaHasPopup" - | "ariaLabel" - | "ariaLabelledBy" - | "tabIndex" - | "testId" - | "translate" - >; + /** + * The contents of the button. Only available internal to Odyssey here in BaseButton. If set, label is ignored. + */ + children?: ReactNode; + /** + * The icon element to display at the end of the Button + */ + endIcon?: ReactElement; + /** + * The text content of the Button + */ + label?: string; + /** + * The icon element to display at the start of the Button + */ + startIcon?: ReactElement; +}; + +// These are split and exported separately from the above because wrappers of this (e.g. Button) will +// want to omit children, which they cannot do from the combined union type. Instead, they should +// omit from BaseButtonProps, then union with the AdditionalBaseButtonProps (as seen in Button) +export type AdditionalBaseButtonProps = Pick< + HtmlProps, + | "ariaControls" + | "ariaDescribedBy" + | "ariaExpanded" + | "ariaHasPopup" + | "ariaLabel" + | "ariaLabelledBy" + | "tabIndex" + | "testId" + | "translate" +>; -const Button = ({ +const BaseButton = ({ ariaControls, ariaDescribedBy, ariaExpanded, @@ -156,6 +134,7 @@ const Button = ({ isDisabled, isFullWidth: isFullWidthProp, label = "", + children, onClick, size = "medium", startIcon, @@ -165,7 +144,7 @@ const Button = ({ translate, type = "button", variant: variantProp, -}: ButtonProps) => { +}: BaseButtonProps & AdditionalBaseButtonProps) => { const muiProps = useMuiProps(); // We're deprecating the "tertiary" variant, so map it to @@ -225,7 +204,7 @@ const Button = ({ type={type} variant={variant} > - {label} + {children ?? label} ); }, @@ -242,6 +221,7 @@ const Button = ({ isDisabled, isFullWidth, label, + children, onClick, size, startIcon, @@ -264,7 +244,7 @@ const Button = ({ return renderButton(muiProps); }; -const MemoizedButton = memo(Button); -MemoizedButton.displayName = "Button"; +const MemoizedBaseButton = memo(BaseButton); +MemoizedBaseButton.displayName = "BaseButton"; -export { MemoizedButton as Button }; +export { MemoizedBaseButton as BaseButton }; diff --git a/packages/odyssey-react-mui/src/MenuButton.tsx b/packages/odyssey-react-mui/src/Buttons/BaseMenuButton.tsx similarity index 57% rename from packages/odyssey-react-mui/src/MenuButton.tsx rename to packages/odyssey-react-mui/src/Buttons/BaseMenuButton.tsx index 6d2bf60edf..c23a4e7093 100644 --- a/packages/odyssey-react-mui/src/MenuButton.tsx +++ b/packages/odyssey-react-mui/src/Buttons/BaseMenuButton.tsx @@ -18,18 +18,28 @@ import { useState, ReactNode, } from "react"; -import { Menu as MuiMenu, PopoverOrigin } from "@mui/material"; +import { + Menu as MuiMenu, + Popover as MuiPopover, + PopoverOrigin, +} from "@mui/material"; -import { Button, buttonSizeValues, buttonVariantValues, useUniqueId } from "./"; -import { ChevronDownIcon, MoreIcon } from "./icons.generated"; -import { FieldComponentProps } from "./FieldComponentProps"; +import { useOdysseyDesignTokens } from "../OdysseyDesignTokensContext"; +import { Box, buttonSizeValues, buttonVariantValues, useUniqueId } from ".."; +import { BaseButton } from "./BaseButton"; +import { ChevronDownIcon, MoreIcon } from "../icons.generated"; +import { FieldComponentProps } from "../FieldComponentProps"; import { MenuContext, MenuContextType } from "./MenuContext"; -import { NullElement } from "./NullElement"; -import type { HtmlProps } from "./HtmlProps"; +import { NullElement } from "../NullElement"; +import type { HtmlProps } from "../HtmlProps"; export const menuAlignmentValues = ["left", "right"] as const; -export type MenuButtonProps = { +export type BaseMenuButtonProps = { + /** + * The button children for the triggering Button. Only available internal to Odyssey here in BaseMenuButton. If set, buttonLabel is ignored. + */ + buttonChildren?: ReactNode; /** * The label on the triggering Button */ @@ -38,14 +48,14 @@ export type MenuButtonProps = { * The variant of the triggering Button */ buttonVariant?: (typeof buttonVariantValues)[number]; - /** - * The components within the Menu. - */ - children: ReactNode | NullElement; /** * The end Icon on the trigggering Button */ endIcon?: ReactElement; + /** + * Whether to omit the endIcon if not set (rather than use a default value for it based on overflow) + */ + omitEndIcon?: boolean; /** * The id of the Button */ @@ -71,7 +81,12 @@ export type MenuButtonProps = { * The tooltip text for the Button if it's icon-only */ tooltipText?: string; -} & Pick< +}; + +// These are split and exported separately from the above because wrappers of this (e.g. MenuButton) will +// want to omit buttonChildren, which they cannot do from the combined union type. Instead, they should +// omit from BaseMenuButtonProps, then union with the AdditionalBaseMenuButtonProps (as seen in MenuButton) +export type AdditionalBaseMenuButtonProps = Pick< HtmlProps, "ariaDescribedBy" | "ariaLabel" | "ariaLabelledBy" | "testId" | "translate" > & @@ -86,26 +101,52 @@ export type MenuButtonProps = { Partial> & { buttonLabel?: undefined | ""; }) + ) & + ( + | { + /** + * The components within the Menu. + */ + children: ReactNode | NullElement; + /** + * popoverConten is disallowed if children are present + */ + popoverContent?: never; + } + | { + /** + * children is disallowed if popoverContent is present + */ + children?: never; + /** + * The content for the popover that is triggered on click. + */ + popoverContent: ReactNode | NullElement; + } ); -const MenuButton = ({ +const BaseMenuButton = ({ ariaLabel, ariaLabelledBy, ariaDescribedBy, + buttonChildren, buttonLabel = "", buttonVariant = "secondary", children, + popoverContent, shouldCloseOnSelect = true, endIcon: endIconProp, id: idOverride, isDisabled, isOverflow, menuAlignment = "left", + omitEndIcon = false, size, testId, tooltipText, translate, -}: MenuButtonProps) => { +}: BaseMenuButtonProps & AdditionalBaseMenuButtonProps) => { + const odysseyDesignTokens = useOdysseyDesignTokens(); const [anchorEl, setAnchorEl] = useState(null); const isOpen = Boolean(anchorEl); @@ -134,7 +175,7 @@ const MenuButton = ({ [closeMenu, openMenu, shouldCloseOnSelect], ); - const endIcon = endIconProp ? ( + const endIcon = omitEndIcon ? undefined : endIconProp ? ( endIconProp ) : isOverflow ? ( @@ -160,9 +201,10 @@ const MenuButton = ({ [menuAlignment], ); + console.log("has children?", !!children, children); return (
-
); }; -const MemoizedMenuButton = memo(MenuButton); -MemoizedMenuButton.displayName = "MenuButton"; +const MemoizedBaseMenuButton = memo(BaseMenuButton); +MemoizedBaseMenuButton.displayName = "BaseMenuButton"; -export { MemoizedMenuButton as MenuButton }; +export { MemoizedBaseMenuButton as BaseMenuButton }; diff --git a/packages/odyssey-react-mui/src/Buttons/Button.tsx b/packages/odyssey-react-mui/src/Buttons/Button.tsx new file mode 100644 index 0000000000..3da258cd93 --- /dev/null +++ b/packages/odyssey-react-mui/src/Buttons/Button.tsx @@ -0,0 +1,30 @@ +/*! + * Copyright (c) 2022-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { memo } from "react"; +import { + AdditionalBaseButtonProps, + BaseButton, + BaseButtonProps, +} from "./BaseButton"; + +export type ButtonProps = Omit & + AdditionalBaseButtonProps; + +const Button = (props: ButtonProps) => { + return ; +}; + +const MemoizedButton = memo(Button); +MemoizedButton.displayName = "Button"; + +export { MemoizedButton as Button }; diff --git a/packages/odyssey-react-mui/src/ButtonContext.tsx b/packages/odyssey-react-mui/src/Buttons/ButtonContext.tsx similarity index 100% rename from packages/odyssey-react-mui/src/ButtonContext.tsx rename to packages/odyssey-react-mui/src/Buttons/ButtonContext.tsx diff --git a/packages/odyssey-react-mui/src/Buttons/MenuButton.tsx b/packages/odyssey-react-mui/src/Buttons/MenuButton.tsx new file mode 100644 index 0000000000..dbd8ddc478 --- /dev/null +++ b/packages/odyssey-react-mui/src/Buttons/MenuButton.tsx @@ -0,0 +1,35 @@ +/*! + * Copyright (c) 2022-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { memo } from "react"; +import { + AdditionalBaseMenuButtonProps, + BaseMenuButton, + BaseMenuButtonProps, +} from "./BaseMenuButton"; + +export const menuAlignmentValues = ["left", "right"] as const; + +export type MenuButtonProps = Omit< + BaseMenuButtonProps, + "buttonChildren" | "omitEndIcon" +> & + AdditionalBaseMenuButtonProps; + +const MenuButton = (props: MenuButtonProps) => { + return ; +}; + +const MemoizedMenuButton = memo(MenuButton); +MemoizedMenuButton.displayName = "MenuButton"; + +export { MemoizedMenuButton as MenuButton }; diff --git a/packages/odyssey-react-mui/src/MenuContext.ts b/packages/odyssey-react-mui/src/Buttons/MenuContext.ts similarity index 100% rename from packages/odyssey-react-mui/src/MenuContext.ts rename to packages/odyssey-react-mui/src/Buttons/MenuContext.ts diff --git a/packages/odyssey-react-mui/src/MenuItem.tsx b/packages/odyssey-react-mui/src/Buttons/MenuItem.tsx similarity index 98% rename from packages/odyssey-react-mui/src/MenuItem.tsx rename to packages/odyssey-react-mui/src/Buttons/MenuItem.tsx index d62639bc38..b876724a28 100644 --- a/packages/odyssey-react-mui/src/MenuItem.tsx +++ b/packages/odyssey-react-mui/src/Buttons/MenuItem.tsx @@ -18,7 +18,7 @@ import { menuItemClasses } from "@mui/material/MenuItem"; import { memo, useCallback, useContext, type ReactNode } from "react"; import { MenuContext } from "./MenuContext"; -import type { HtmlProps } from "./HtmlProps"; +import type { HtmlProps } from "../HtmlProps"; export type MenuItemProps = { /** diff --git a/packages/odyssey-react-mui/src/Buttons/index.ts b/packages/odyssey-react-mui/src/Buttons/index.ts new file mode 100644 index 0000000000..70ffe164ef --- /dev/null +++ b/packages/odyssey-react-mui/src/Buttons/index.ts @@ -0,0 +1,22 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +export { + buttonSizeValues, + buttonTypeValues, + buttonVariantValues, +} from "./BaseButton"; +export * from "./Button"; +export * from "./ButtonContext"; +export { menuAlignmentValues } from "./BaseMenuButton"; +export * from "./MenuButton"; +export * from "./MenuItem"; diff --git a/packages/odyssey-react-mui/src/Card.tsx b/packages/odyssey-react-mui/src/Card.tsx index abfc62698d..e4dacf33e7 100644 --- a/packages/odyssey-react-mui/src/Card.tsx +++ b/packages/odyssey-react-mui/src/Card.tsx @@ -24,10 +24,8 @@ import { } from "@mui/material"; import styled from "@emotion/styled"; -import { Button } from "./Button"; -import { ButtonContext } from "./ButtonContext"; +import { Button, ButtonContext, MenuButton, MenuButtonProps } from "./Buttons"; import { MoreIcon } from "./icons.generated"; -import { MenuButton, MenuButtonProps } from "./MenuButton"; import { DesignTokens, useOdysseyDesignTokens, diff --git a/packages/odyssey-react-mui/src/DataTable/DataTable.tsx b/packages/odyssey-react-mui/src/DataTable/DataTable.tsx index e60e041c7f..49bd710eab 100644 --- a/packages/odyssey-react-mui/src/DataTable/DataTable.tsx +++ b/packages/odyssey-react-mui/src/DataTable/DataTable.tsx @@ -51,7 +51,6 @@ import { } from "./DataTableRowActions"; import { useRowReordering } from "./useRowReordering"; import { DataTableSettings } from "./DataTableSettings"; -import { MenuButton, MenuButtonProps } from "../MenuButton"; import { Box } from "../Box"; import { DataTableRowSelectionState, DataTableRowData } from "."; import { @@ -61,7 +60,7 @@ import { import { useScrollIndication } from "./useScrollIndication"; import styled from "@emotion/styled"; import { EmptyState } from "../EmptyState"; -import { Button } from "../Button"; +import { Button, MenuButton, MenuButtonProps } from "../Buttons"; import { Callout } from "../Callout"; export type DataTableColumn = MRT_ColumnDef & { diff --git a/packages/odyssey-react-mui/src/DataTable/DataTableRowActions.tsx b/packages/odyssey-react-mui/src/DataTable/DataTableRowActions.tsx index 2e80e6407f..d8472e642d 100644 --- a/packages/odyssey-react-mui/src/DataTable/DataTableRowActions.tsx +++ b/packages/odyssey-react-mui/src/DataTable/DataTableRowActions.tsx @@ -12,10 +12,8 @@ import { MRT_Row, MRT_RowData } from "material-react-table"; import { Fragment, ReactElement, memo, useCallback } from "react"; -import { Button } from "../Button"; -import { MenuItem } from "../MenuItem"; +import { Button, MenuButton, MenuButtonProps, MenuItem } from "../Buttons"; import { Box as MuiBox } from "@mui/material"; -import { MenuButton, MenuButtonProps } from "../MenuButton"; import { ArrowBottomIcon, ArrowDownIcon, diff --git a/packages/odyssey-react-mui/src/DataTable/DataTableSettings.tsx b/packages/odyssey-react-mui/src/DataTable/DataTableSettings.tsx index 2da2769c6a..cbfe7452cd 100644 --- a/packages/odyssey-react-mui/src/DataTable/DataTableSettings.tsx +++ b/packages/odyssey-react-mui/src/DataTable/DataTableSettings.tsx @@ -12,8 +12,7 @@ import { Dispatch, SetStateAction, memo, useCallback, useMemo } from "react"; import { Checkbox as MuiCheckbox } from "@mui/material"; -import { MenuButton } from "../MenuButton"; -import { MenuItem } from "../MenuItem"; +import { MenuButton, MenuItem } from "../Buttons"; import { ListIcon, ShowIcon } from "../icons.generated"; import { densityValues } from "./constants"; import { DataTableProps } from "./DataTable"; diff --git a/packages/odyssey-react-mui/src/Dialog.tsx b/packages/odyssey-react-mui/src/Dialog.tsx index a890a1caf9..33972d5cbf 100644 --- a/packages/odyssey-react-mui/src/Dialog.tsx +++ b/packages/odyssey-react-mui/src/Dialog.tsx @@ -18,7 +18,7 @@ import { DialogContentText, DialogActions, } from "@mui/material"; -import { Button } from "./Button"; +import { Button } from "./Buttons"; import { CloseIcon } from "./icons.generated"; import { cloneElement, diff --git a/packages/odyssey-react-mui/src/Drawer.tsx b/packages/odyssey-react-mui/src/Drawer.tsx index 465791e423..cb3eb68460 100644 --- a/packages/odyssey-react-mui/src/Drawer.tsx +++ b/packages/odyssey-react-mui/src/Drawer.tsx @@ -24,7 +24,7 @@ import { Drawer as MuiDrawer } from "@mui/material"; import styled from "@emotion/styled"; import { useTranslation } from "react-i18next"; -import { Button } from "./Button"; +import { Button } from "./Buttons"; import { CloseIcon } from "./icons.generated"; import { DesignTokens, diff --git a/packages/odyssey-react-mui/src/FileUploader/FileUploader.tsx b/packages/odyssey-react-mui/src/FileUploader/FileUploader.tsx index 88d6bf824d..84e250e214 100644 --- a/packages/odyssey-react-mui/src/FileUploader/FileUploader.tsx +++ b/packages/odyssey-react-mui/src/FileUploader/FileUploader.tsx @@ -21,7 +21,7 @@ import { import styled from "@emotion/styled"; import { useTranslation } from "react-i18next"; -import { Button } from "../Button"; +import { Button } from "../Buttons"; import { UploadIcon } from "../icons.generated"; import { Field, RenderFieldComponentProps } from "../Field"; import { FieldComponentProps } from "../FieldComponentProps"; diff --git a/packages/odyssey-react-mui/src/Form.tsx b/packages/odyssey-react-mui/src/Form.tsx index 3d17f14199..673550082f 100644 --- a/packages/odyssey-react-mui/src/Form.tsx +++ b/packages/odyssey-react-mui/src/Form.tsx @@ -13,7 +13,7 @@ import { FormEventHandler, memo, ReactElement } from "react"; import styled from "@emotion/styled"; -import { Button } from "./Button"; +import { Button } from "./Buttons"; import { Callout } from "./Callout"; import { FieldComponentProps } from "./FieldComponentProps"; import type { HtmlProps } from "./HtmlProps"; diff --git a/packages/odyssey-react-mui/src/Pagination/Pagination.tsx b/packages/odyssey-react-mui/src/Pagination/Pagination.tsx index dbebed52f5..96b00d0d59 100644 --- a/packages/odyssey-react-mui/src/Pagination/Pagination.tsx +++ b/packages/odyssey-react-mui/src/Pagination/Pagination.tsx @@ -13,7 +13,7 @@ import { InputBase } from "@mui/material"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Paragraph } from "../Typography"; -import { Button } from "../Button"; +import { Button } from "../Buttons"; import { ArrowLeftIcon, ArrowRightIcon } from "../icons.generated"; import styled from "@emotion/styled"; import { diff --git a/packages/odyssey-react-mui/src/Surface.tsx b/packages/odyssey-react-mui/src/Surface.tsx index d9ac36c2aa..1b1efdb449 100644 --- a/packages/odyssey-react-mui/src/Surface.tsx +++ b/packages/odyssey-react-mui/src/Surface.tsx @@ -19,14 +19,22 @@ import { useOdysseyDesignTokens, } from "./OdysseyDesignTokensContext"; import { OdysseyThemeProvider } from "./OdysseyThemeProvider"; +import { useContrastModeContext, ContrastMode } from "./useContrastMode"; const StyledContainer = styled(MuiPaper, { - shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "contrastMode", })<{ + contrastMode: ContrastMode; odysseyDesignTokens: DesignTokens; -}>(({ odysseyDesignTokens }) => ({ +}>(({ contrastMode, odysseyDesignTokens }) => ({ borderRadius: odysseyDesignTokens.Spacing4, - padding: odysseyDesignTokens.Spacing4, + padding: odysseyDesignTokens.Spacing5, + border: + contrastMode === "lowContrast" + ? `1px solid ${odysseyDesignTokens.HueNeutral100}` + : "none", + boxShadow: "none", })); export type SurfaceProps = { @@ -35,9 +43,13 @@ export type SurfaceProps = { const Surface = ({ children }: SurfaceProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); + const { contrastMode } = useContrastModeContext(); return ( - + {children} ); diff --git a/packages/odyssey-react-mui/src/Toast.tsx b/packages/odyssey-react-mui/src/Toast.tsx index ba6741a820..d51bd0ef3e 100644 --- a/packages/odyssey-react-mui/src/Toast.tsx +++ b/packages/odyssey-react-mui/src/Toast.tsx @@ -14,7 +14,7 @@ import { useEffect, memo, useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Alert, AlertTitle, Snackbar } from "@mui/material"; -import { Button } from "./Button"; +import { Button } from "./Buttons"; import { HtmlProps } from "./HtmlProps"; import { CloseIcon } from "./icons.generated"; import { Link } from "./Link"; diff --git a/packages/odyssey-react-mui/src/index.ts b/packages/odyssey-react-mui/src/index.ts index bafdce48ab..d8db0b990e 100644 --- a/packages/odyssey-react-mui/src/index.ts +++ b/packages/odyssey-react-mui/src/index.ts @@ -64,7 +64,7 @@ export { badgeContentMaxValues } from "./Badge"; export * from "./Banner"; export * from "./Box"; export * from "./Breadcrumbs"; -export * from "./Button"; +export * from "./Buttons"; export * from "./Callout"; export * from "./Card"; export * from "./Checkbox"; @@ -87,8 +87,6 @@ export * from "./Form"; export * from "./HintLink"; export * from "./IconWithTooltip"; export * from "./Link"; -export * from "./MenuButton"; -export * from "./MenuItem"; export * from "./NativeSelect"; export * from "./NullElement"; export * from "./OdysseyCacheProvider"; diff --git a/packages/odyssey-react-mui/src/labs/AppTile.tsx b/packages/odyssey-react-mui/src/labs/AppTile.tsx index 9538c7f3b6..50367059a0 100644 --- a/packages/odyssey-react-mui/src/labs/AppTile.tsx +++ b/packages/odyssey-react-mui/src/labs/AppTile.tsx @@ -25,7 +25,7 @@ import { } from "@mui/material"; import styled from "@emotion/styled"; -import { Button } from ".././Button"; +import { Button } from "../Buttons"; import { DesignTokens, useOdysseyDesignTokens, diff --git a/packages/odyssey-react-mui/src/labs/DataFilters.tsx b/packages/odyssey-react-mui/src/labs/DataFilters.tsx index 62e81d1647..0a1dfa30a3 100644 --- a/packages/odyssey-react-mui/src/labs/DataFilters.tsx +++ b/packages/odyssey-react-mui/src/labs/DataFilters.tsx @@ -33,7 +33,7 @@ import styled from "@emotion/styled"; import { Autocomplete } from "../Autocomplete"; import { Box } from "../Box"; -import { Button } from "../Button"; +import { Button } from "../Buttons"; import { CheckboxGroup } from "../CheckboxGroup"; import { Checkbox } from "../Checkbox"; import { diff --git a/packages/odyssey-react-mui/src/labs/DataTable.tsx b/packages/odyssey-react-mui/src/labs/DataTable.tsx index 6e4e420011..833e61ea19 100644 --- a/packages/odyssey-react-mui/src/labs/DataTable.tsx +++ b/packages/odyssey-react-mui/src/labs/DataTable.tsx @@ -53,7 +53,7 @@ import { paginationTypeValues, } from "./DataTablePagination"; import { DataFilter, DataFilters } from "./DataFilters"; -import { Button } from "../Button"; +import { Button } from "../Buttons"; import { Box } from "../Box"; import { MenuButton, MenuItem } from ".."; import { ArrowUnsortedIcon } from "../icons.generated"; diff --git a/packages/odyssey-react-mui/src/labs/DataTablePagination.tsx b/packages/odyssey-react-mui/src/labs/DataTablePagination.tsx index d6f8918148..9609c407b3 100644 --- a/packages/odyssey-react-mui/src/labs/DataTablePagination.tsx +++ b/packages/odyssey-react-mui/src/labs/DataTablePagination.tsx @@ -12,7 +12,7 @@ import { memo } from "react"; import { Box } from "../Box"; -import { Button } from "../Button"; +import { Button } from "../Buttons"; import { Support } from "../Typography"; import { ArrowLeftIcon, ArrowRightIcon } from "../icons.generated"; diff --git a/packages/odyssey-react-mui/src/labs/DataView/BulkActionsMenu.tsx b/packages/odyssey-react-mui/src/labs/DataView/BulkActionsMenu.tsx index bc39f705fc..f51a8a2c0a 100644 --- a/packages/odyssey-react-mui/src/labs/DataView/BulkActionsMenu.tsx +++ b/packages/odyssey-react-mui/src/labs/DataView/BulkActionsMenu.tsx @@ -16,9 +16,8 @@ import styled from "@emotion/styled"; import { useTranslation } from "react-i18next"; import { Box } from "../../Box"; -import { Button } from "../../Button"; +import { Button, MenuButton } from "../../Buttons"; import { ChevronDownIcon } from "../../icons.generated"; -import { MenuButton } from "../../MenuButton"; import { UniversalProps } from "./componentTypes"; import { DesignTokens, diff --git a/packages/odyssey-react-mui/src/labs/DataView/DataCard.tsx b/packages/odyssey-react-mui/src/labs/DataView/DataCard.tsx index 19360acbf5..176069be5e 100644 --- a/packages/odyssey-react-mui/src/labs/DataView/DataCard.tsx +++ b/packages/odyssey-react-mui/src/labs/DataView/DataCard.tsx @@ -29,14 +29,17 @@ import styled from "@emotion/styled"; import { useTranslation } from "react-i18next"; import { Box } from "../../Box"; -import { Button } from "../../Button"; -import { ButtonContext } from "../../ButtonContext"; +import { + Button, + ButtonContext, + MenuButton, + MenuButtonProps, +} from "../../Buttons"; import { DesignTokens, useOdysseyDesignTokens, } from "../../OdysseyDesignTokensContext"; import { Heading5, Paragraph, Support } from "../../Typography"; -import { MenuButton, MenuButtonProps } from "../../MenuButton"; import { ChevronDownIcon, ChevronUpIcon, diff --git a/packages/odyssey-react-mui/src/labs/DataView/DataView.tsx b/packages/odyssey-react-mui/src/labs/DataView/DataView.tsx index c3b883c818..abeba6a5e4 100644 --- a/packages/odyssey-react-mui/src/labs/DataView/DataView.tsx +++ b/packages/odyssey-react-mui/src/labs/DataView/DataView.tsx @@ -35,7 +35,7 @@ import { DataFilters } from "../DataFilters"; import { EmptyState } from "../../EmptyState"; import { fetchData } from "./fetchData"; import { LayoutSwitcher } from "./LayoutSwitcher"; -import { MenuButton } from "../../MenuButton"; +import { MenuButton } from "../../Buttons"; import { MoreIcon } from "../../icons.generated"; import { TableSettings } from "./TableSettings"; import { Pagination, usePagination } from "../../Pagination"; @@ -101,6 +101,9 @@ const DataView = ({ getRowId: getRowIdProp, hasFilters, hasPagination, + hasPageInput, + hasRowCountInput, + hasRowCountLabel, hasSearch, hasSearchSubmitButton, hasRowReordering, @@ -461,6 +464,9 @@ const DataView = ({ {hasPagination && ( column.grow === true); + const dataTable = useMaterialReactTable({ data: !isEmpty && !isNoResults ? data : [], columns, @@ -332,7 +333,7 @@ const TableLayoutContent = ({ >), "mrt-row-actions": { header: "", - grow: true, + grow: !hasColumnWithGrow, muiTableBodyCellProps: { align: "right" as const, sx: { @@ -372,7 +373,9 @@ const TableLayoutContent = ({ ref: tableContentRef, className: !shouldDisplayRowActions && tableLayoutOptions.hasColumnResizing - ? "ods-hide-spacer-column" + ? hasColumnWithGrow + ? "ods-hide-spacer-column" + : "ods-hide-spacer-column ods-column-grow" : "", }, muiTableContainerProps: { diff --git a/packages/odyssey-react-mui/src/labs/DataView/TableSettings.tsx b/packages/odyssey-react-mui/src/labs/DataView/TableSettings.tsx index 7abc448c36..b9d724075c 100644 --- a/packages/odyssey-react-mui/src/labs/DataView/TableSettings.tsx +++ b/packages/odyssey-react-mui/src/labs/DataView/TableSettings.tsx @@ -17,8 +17,7 @@ import { useTranslation } from "react-i18next"; import { densityValues } from "./constants"; import { ListIcon, ShowIcon } from "../../icons.generated"; -import { MenuButton } from "../../MenuButton"; -import { MenuItem } from "../../MenuItem"; +import { MenuButton, MenuItem } from "../../Buttons"; import { TableLayoutProps, TableState } from "./componentTypes"; export type TableSettingsProps = { diff --git a/packages/odyssey-react-mui/src/labs/DataView/componentTypes.ts b/packages/odyssey-react-mui/src/labs/DataView/componentTypes.ts index 8a41f7039e..c557495e59 100644 --- a/packages/odyssey-react-mui/src/labs/DataView/componentTypes.ts +++ b/packages/odyssey-react-mui/src/labs/DataView/componentTypes.ts @@ -18,6 +18,7 @@ import { MRT_TableOptions, MRT_VisibilityState, } from "material-react-table"; +import { ReactNode } from "react"; import { availableLayouts, availableCardLayouts } from "./constants"; import { DataFilter } from "../DataFilters"; @@ -30,8 +31,8 @@ import { import { DataTableRowActionsProps } from "../../DataTable/DataTableRowActions"; import { MenuButtonProps } from "../.."; import { paginationTypeValues } from "../DataTablePagination"; -import { ReactNode } from "react"; import { DataCardProps } from "./DataCard"; +import { type PaginationProps } from "../../Pagination"; export type DataLayout = (typeof availableLayouts)[number]; export type CardLayout = (typeof availableCardLayouts)[number]; @@ -90,7 +91,10 @@ export type UniversalProps = { resultsPerPage?: number; searchDelayTime?: number; totalRows?: number; -}; +} & Pick< + PaginationProps, + "hasPageInput" | "hasRowCountInput" | "hasRowCountLabel" +>; export type TableLayoutProps = { columns: DataTableColumn[]; diff --git a/packages/odyssey-react-mui/src/labs/DatePicker.tsx b/packages/odyssey-react-mui/src/labs/DatePicker.tsx index f3a537dfd5..ba88cfea78 100644 --- a/packages/odyssey-react-mui/src/labs/DatePicker.tsx +++ b/packages/odyssey-react-mui/src/labs/DatePicker.tsx @@ -32,7 +32,7 @@ import { DateTime } from "luxon"; import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; import styled from "@emotion/styled"; -import { Button } from "../Button"; +import { Button } from "../Buttons"; import { ArrowLeftIcon, ArrowRightIcon, diff --git a/packages/odyssey-react-mui/src/labs/PageTemplate.tsx b/packages/odyssey-react-mui/src/labs/PageTemplate.tsx index 2d3e345f77..4ecb333f7d 100644 --- a/packages/odyssey-react-mui/src/labs/PageTemplate.tsx +++ b/packages/odyssey-react-mui/src/labs/PageTemplate.tsx @@ -20,6 +20,7 @@ import { import { DocumentationIcon } from "../icons.generated"; import { Heading4, Subordinate } from "../Typography"; import { Link } from "../Link"; +import { useHasUiShell } from "./UiShell"; export type PageTemplateProps = { /** @@ -72,16 +73,20 @@ type TemplateContentProps = { const TemplateContainer = styled("div", { shouldForwardProp: (prop) => - prop !== "odysseyDesignTokens" && prop !== "isFullWidth", + prop !== "odysseyDesignTokens" && + prop !== "hasUiShell" && + prop !== "isFullWidth", })<{ - odysseyDesignTokens: DesignTokens; + hasUiShell: boolean; isFullWidth: boolean; -}>(({ odysseyDesignTokens, isFullWidth }) => ({ + odysseyDesignTokens: DesignTokens; +}>(({ hasUiShell, isFullWidth, odysseyDesignTokens }) => ({ maxWidth: isFullWidth ? "100%" : `calc(1440px + ${odysseyDesignTokens.Spacing6} + ${odysseyDesignTokens.Spacing6})`, - marginInline: isFullWidth ? odysseyDesignTokens.Spacing6 : "auto", - padding: odysseyDesignTokens.Spacing6, + marginInline: + isFullWidth && !hasUiShell ? odysseyDesignTokens.Spacing6 : "auto", + padding: hasUiShell ? 0 : odysseyDesignTokens.Spacing6, })); const TemplateHeader = styled("div")(() => ({ @@ -163,24 +168,27 @@ const TemplateContent = styled("div", { ); const PageTemplate = ({ - title, + children, description, documentationLink, documentationText, + drawer, + isFullWidth = false, primaryCallToActionComponent, secondaryCallToActionComponent, tertiaryCallToActionComponent, - children, - drawer, - isFullWidth = false, + title, }: PageTemplateProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); const { isOpen: isDrawerOpen, variant: drawerVariant } = drawer?.props ?? {}; + const hasUiShell = useHasUiShell(); + return ( diff --git a/packages/odyssey-react-mui/src/labs/SideNav/NavAccordion.tsx b/packages/odyssey-react-mui/src/labs/SideNav/NavAccordion.tsx index a3fff71264..f2ccc34162 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/NavAccordion.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/NavAccordion.tsx @@ -17,10 +17,10 @@ import { AccordionSummary as MuiAccordionSummary, AccordionProps as MuiAccordionProps, } from "@mui/material"; -import { ReactNode, memo } from "react"; +import { PropsWithChildren, ReactNode, memo } from "react"; import type { HtmlProps } from "../../HtmlProps"; -import { ChevronRightIcon } from "../../icons.generated"; +import { ChevronDownIcon } from "../../icons.generated"; import { DesignTokens, useOdysseyDesignTokens, @@ -29,10 +29,6 @@ import { Support } from "../../Typography"; import { useUniqueId } from "../../useUniqueId"; export type NavAccordionProps = { - /** - * The content of the Accordion itself - */ - children: ReactNode; /** * The label text for the AccordionSummary */ @@ -76,8 +72,7 @@ const AccordionLabelContainer = styled("span", { isIconVisible: boolean; }>(({ odysseyDesignTokens, isIconVisible }) => ({ width: "100%", - marginLeft: isIconVisible ? odysseyDesignTokens.Spacing2 : 0, - fontSize: odysseyDesignTokens.TypographyScale0, + marginInlineStart: isIconVisible ? odysseyDesignTokens.Spacing3 : 0, fontWeight: odysseyDesignTokens.TypographyWeightHeading, color: odysseyDesignTokens.TypographyColorHeading, })); @@ -86,26 +81,32 @@ const AccordionSummaryContainer = styled(MuiAccordionSummary, { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop !== "isCompact" && - prop != "isDisabled", + prop !== "isDisabled", })<{ odysseyDesignTokens: DesignTokens; isCompact?: boolean; isDisabled?: boolean; }>(({ odysseyDesignTokens, isCompact, isDisabled }) => ({ - minHeight: isCompact - ? `${odysseyDesignTokens.Spacing6}` - : `${odysseyDesignTokens.Spacing7}`, - padding: isCompact - ? `${odysseyDesignTokens.Spacing0} ${odysseyDesignTokens.Spacing4}` - : `${odysseyDesignTokens.Spacing2} ${odysseyDesignTokens.Spacing4}`, - "&:hover": { - backgroundColor: !isDisabled ? odysseyDesignTokens.HueBlue50 : "inherit", - "& span": { - color: isDisabled - ? "default" - : `${odysseyDesignTokens.TypographyColorAction}`, - }, + borderRadius: odysseyDesignTokens.BorderRadiusMain, + paddingBlock: odysseyDesignTokens.Spacing3, + paddingInline: odysseyDesignTokens.Spacing4, + + "&:focus-visible": { + backgroundColor: "unset", + outline: "none", + boxShadow: `inset 0 0 0 2px ${odysseyDesignTokens.PalettePrimaryMain}`, }, + + ...(isCompact && { + paddingBlock: odysseyDesignTokens.Spacing2, + minHeight: "unset", + }), + + ...(!isDisabled && { + "&:hover": { + backgroundColor: odysseyDesignTokens.HueNeutral50, + }, + }), })); const NavAccordion = ({ @@ -118,7 +119,7 @@ const NavAccordion = ({ isExpanded, translate, startIcon, -}: NavAccordionProps) => { +}: PropsWithChildren) => { const id = useUniqueId(idOverride); const headerId = `${id}-header`; const contentId = `${id}-content`; @@ -135,7 +136,7 @@ const NavAccordion = ({ } + expandIcon={} id={headerId} odysseyDesignTokens={odysseyDesignTokens} isCompact={isCompact} diff --git a/packages/odyssey-react-mui/src/labs/SideNav/OktaLogo.tsx b/packages/odyssey-react-mui/src/labs/SideNav/OktaLogo.tsx index 2196b44fc1..fff50ebfc0 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/OktaLogo.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/OktaLogo.tsx @@ -21,6 +21,7 @@ const OktaLogo = () => { fill="none" xmlns="http://www.w3.org/2000/svg" > + Okta { + test("can show the default Okta logo", async () => { + render( + + + , + ); + + expect(screen.getByTitle("Okta")).toBeInTheDocument(); + }); + + test("can show a custom logo", async () => { + render( + + + , + ); + + expect(screen.getByAltText("Custom logo")).toBeInTheDocument(); + }); + + test("can show header text", async () => { + const headerText = "Header text"; + + render( + + + , + ); + + expect( + screen.getByRole("heading", { name: headerText }), + ).toBeInTheDocument(); + }); + + test("is collapsible", async () => { + const menuItemText = "Users"; + + render( + + + , + ); + + expect(screen.getByText(menuItemText)).toBeVisible(); + + const collapseButton = screen.getByLabelText("Close navigation"); + fireEvent.click(collapseButton); + + expect(screen.getByText(menuItemText)).not.toBeVisible; + + const expandButton = screen.getByLabelText("Open navigation"); + fireEvent.click(expandButton); + + expect(screen.getByText(menuItemText)).toBeVisible(); + }); + + test("can fire onCollapse event", async () => { + const menuItemText = "Users"; + const mockOnCollapse = jest.fn(); + + render( + + + , + ); + + const collapseButton = screen.getByLabelText("Close navigation"); + fireEvent.click(collapseButton); + + expect(mockOnCollapse).toBeCalled(); + }); + + test("can fire onExpand event", async () => { + const menuItemText = "Users"; + const mockOnExpand = jest.fn(); + + render( + + + , + ); + + const collapseButton = screen.getByLabelText("Close navigation"); + fireEvent.click(collapseButton); + + const expandButton = screen.getByLabelText("Open navigation"); + fireEvent.click(expandButton); + + expect(mockOnExpand).toBeCalled(); + }); + + test("shows loading skeleton state", async () => { + const menuItemText = "Menu item"; + + render( + + + , + ); + + expect(screen.queryByText(menuItemText)).not.toBeInTheDocument(); + }); + + test("shows footer links", async () => { + const footerItemLabel = "Footer item"; + render( + + + , + ); + + const footer = screen.getByRole("menubar"); + expect(within(footer).getByText(footerItemLabel)).toBeVisible(); + }); + + test("shows custom footer component", async () => { + const footerComponentText = "This is a custom footer component."; + const footerComponent =

{footerComponentText}

; + + render( + + + , + ); + + expect(screen.getByText(footerComponentText)).toBeVisible(); + }); + + test("displays sidenav link", async () => { + const accordionInner = "Accordion inside"; + const accordionOuter = "Accordion outside"; + const headingText = "Heading"; + const menuClickableText = "Clickable"; + const menuLinkText = "Link"; + + render( + + {}, + }, + { + id: "menuHeading", + label: headingText, + isSectionHeader: true, + }, + { + id: "menuLink", + href: "#", + label: menuLinkText, + }, + { + id: "accordionOuter", + label: accordionOuter, + nestedNavItems: [ + { + id: "accordionInner", + href: "#", + label: accordionInner, + }, + ], + }, + ]} + /> + , + ); + + expect(screen.getByRole("link", { name: menuLinkText })).toBeVisible(); + expect( + screen.getByRole("button", { name: menuClickableText }), + ).toBeVisible(); + expect(screen.getByRole("heading", { name: headingText })).toBeVisible(); + + const accordion = screen.getByText(accordionOuter); + expect(screen.getByText(accordionInner)).not.toBeVisible(); + fireEvent.click(accordion); + expect(screen.getByText(accordionInner)).toBeVisible(); + }); + + test("can show notification badge", async () => { + const menuItemText = "Menu item text"; + const badgeCount = 9; + + render( + + + , + ); + + expect(screen.getByRole("listitem")).toHaveTextContent(`${badgeCount}`); + }); +}); diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNav.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNav.tsx index ed5276421f..a1fbc64a66 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNav.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNav.tsx @@ -10,33 +10,37 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import styled from "@emotion/styled"; +import styled, { CSSObject } from "@emotion/styled"; import { memo, useMemo, useState, useCallback, - KeyboardEvent, useRef, useEffect, + KeyboardEventHandler, } from "react"; +import { Skeleton } from "@mui/material"; +import { useTranslation } from "react-i18next"; import { NavAccordion } from "./NavAccordion"; import { DesignTokens, useOdysseyDesignTokens, } from "../../OdysseyDesignTokensContext"; +import { OdysseyThemeProvider } from "../../OdysseyThemeProvider"; import type { SideNavProps } from "./types"; -import { OktaLogo } from "./OktaLogo"; -import { HandleIcon } from "./HandleIcon"; -import { CollapseIcon } from "./CollapseIcon"; import { SideNavHeader } from "./SideNavHeader"; import { SideNavItemContent, - SideNavListItemContainer, + StyledSideNavListItem, } from "./SideNavItemContent"; import { SideNavFooterContent } from "./SideNavFooterContent"; import { SideNavItemContentContext } from "./SideNavItemContentContext"; +import { SideNavToggleButton } from "./SideNavToggleButton"; +import { SortableList } from "./SortableList/SortableList"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { arrayMove } from "@dnd-kit/sortable"; export const DEFAULT_SIDE_NAV_WIDTH = "300px"; @@ -44,13 +48,7 @@ export const DEFAULT_SIDE_NAV_WIDTH = "300px"; // to align it in the middle of the nav header text export const SIDENAV_COLLAPSE_ICON_POSITION = "77px"; -const SideNavContainer = styled("div")(() => ({ - display: "flex", - height: "100%", - overflow: "hidden", -})); - -const SideNavCollapsedContainer = styled("div", { +const StyledCollapsibleContent = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop !== "isSideNavCollapsed", })( @@ -61,27 +59,23 @@ const SideNavCollapsedContainer = styled("div", { odysseyDesignTokens: DesignTokens; isSideNavCollapsed: boolean; }) => ({ - display: "flex", - "&:before": { - height: "100%", - width: 0, - content: '""', - }, - "&:has(svg:hover)": { - "&:before": { - width: isSideNavCollapsed ? "8px" : 0, - transitionProperty: "width, background-color", - transitionDuration: odysseyDesignTokens.TransitionDurationMain, - transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, - backgroundColor: isSideNavCollapsed - ? odysseyDesignTokens.HueNeutral200 - : "transparent", - }, - }, + position: "relative", + display: "inline-grid", + gridTemplateColumns: DEFAULT_SIDE_NAV_WIDTH, + // gridTemplateRows: "max-content 1fr max-content", + height: "100%", + transition: `grid-template-columns ${odysseyDesignTokens.TransitionDurationMain}, opacity 300ms`, + transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, + overflow: "hidden", + + ...(isSideNavCollapsed && { + gridTemplateColumns: 0, + opacity: 0, + }), }), ); -const ToggleSideNavHandleContainer = styled("div", { +const StyledOpacityTransitionContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop !== "isSideNavCollapsed", })( @@ -92,60 +86,70 @@ const ToggleSideNavHandleContainer = styled("div", { odysseyDesignTokens: DesignTokens; isSideNavCollapsed: boolean; }) => ({ - height: 0, - cursor: "pointer", - marginTop: SIDENAV_COLLAPSE_ICON_POSITION, - "& svg:nth-of-type(2)": { + display: "inline-grid", + gridTemplateRows: "max-content 1fr max-content", + height: "100%", + transition: `opacity 50ms`, + transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, + overflow: "hidden", + + ...(isSideNavCollapsed && { opacity: 0, - width: 0, - transform: isSideNavCollapsed ? "rotate(180deg)" : "rotate(0deg)", - }, - "&:hover, &:focus-visible": { - "& svg:nth-of-type(2)": { - opacity: 1, - width: "32px", - padding: odysseyDesignTokens.Spacing2, - transitionProperty: "opacity", - transitionDuration: odysseyDesignTokens.TransitionDurationMain, - transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, - }, - "& svg:nth-of-type(1)": { - opacity: 0, - width: 0, - transitionProperty: "opacity", - transitionDuration: odysseyDesignTokens.TransitionDurationMain, - transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, - }, - }, + }), }), ); -const SideNavExpandContainer = styled("nav", { +const StyledSideNav = styled("nav", { shouldForwardProp: (prop) => - prop !== "odysseyDesignTokens" && - prop !== "isSideNavCollapsed" && - prop !== "expandedWidth", + prop !== "odysseyDesignTokens" && prop !== "isSideNavCollapsed", })( ({ odysseyDesignTokens, isSideNavCollapsed, - expandedWidth, }: { odysseyDesignTokens: DesignTokens; isSideNavCollapsed: boolean; - expandedWidth: string; }) => ({ + position: "relative", + display: "inline-block", + height: "100%", backgroundColor: odysseyDesignTokens.HueNeutralWhite, - flexDirection: "column", - display: "flex", - opacity: isSideNavCollapsed ? 0 : 1, - visibility: isSideNavCollapsed ? "hidden" : "visible", - width: isSideNavCollapsed ? 0 : expandedWidth, - minWidth: isSideNavCollapsed ? 0 : expandedWidth, - transitionProperty: "opacity", - transitionDuration: odysseyDesignTokens.TransitionDurationMain, - transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, - borderRight: `${odysseyDesignTokens.BorderWidthMain} ${odysseyDesignTokens.BorderStyleMain} ${odysseyDesignTokens.HueNeutral50}`, + + "&::after": { + backgroundColor: odysseyDesignTokens.HueNeutral200, + content: "''", + height: "100%", + opacity: 0, + position: "absolute", + right: 0, + top: 0, + transform: `translateX(0)`, + transition: `opacity ${odysseyDesignTokens.TransitionDurationMain}, transform ${odysseyDesignTokens.TransitionDurationMain}`, + width: odysseyDesignTokens.Spacing2, + zIndex: 2, + }, + + "&:has([data-sidenav-toggle='true']:hover), &:has([data-sidenav-toggle='true']:focus)": + { + ...(isSideNavCollapsed && { + "&::after": { + opacity: 1, + transform: `translateX(100%)`, + }, + + "[data-sidenav-toggle='true']": { + transform: `translate3d(calc(100% + ${odysseyDesignTokens.Spacing3}), 0, 0)`, + }, + }), + }, + + "[data-sidenav-toggle='true']": { + position: "absolute", + top: SIDENAV_COLLAPSE_ICON_POSITION, + right: 0, + transition: `transform ${odysseyDesignTokens.TransitionDurationMain}`, + transform: `translate3d(100%, 0, 0)`, + }, }), ); @@ -160,12 +164,12 @@ const SideNavHeaderContainer = styled("div", { hasContentScrolled: boolean; odysseyDesignTokens: DesignTokens; }) => ({ - position: "sticky", - top: 0, + flexShrink: 0, // The bottom border should appear only if the scrollable region has been scrolled - ...(hasContentScrolled && { - borderBottom: `${odysseyDesignTokens.BorderWidthMain} ${odysseyDesignTokens.BorderStyleMain} ${odysseyDesignTokens.HueNeutral50}`, - }), + ...(hasContentScrolled && + ({ + borderBottom: `${odysseyDesignTokens.BorderWidthMain} ${odysseyDesignTokens.BorderStyleMain} ${odysseyDesignTokens.HueNeutral50}`, + } satisfies CSSObject)), }), ); @@ -175,21 +179,28 @@ const SideNavListContainer = styled("ul")(() => ({ listStyleType: "none", })); -const SideNavScrollableContainer = styled("div")(() => ({ - flex: 1, +const SideNavScrollableContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + flex: "1 1 100%", overflowY: "auto", + paddingInline: odysseyDesignTokens.Spacing2, })); -const SectionHeader = styled("li", { +const SectionHeaderContainer = styled("li", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + paddingBlock: odysseyDesignTokens.Spacing1, + paddingInline: odysseyDesignTokens.Spacing4, +})); + +const SectionHeader = styled("h3", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ fontFamily: odysseyDesignTokens.TypographyFamilyHeading, fontSize: odysseyDesignTokens.TypographySizeOverline, fontWeight: odysseyDesignTokens.TypographyWeightHeadingBold, color: odysseyDesignTokens.HueNeutral600, - paddingTop: odysseyDesignTokens.Spacing3, - paddingBottom: odysseyDesignTokens.Spacing3, - paddingLeft: odysseyDesignTokens.Spacing4, textTransform: "uppercase", })); @@ -204,15 +215,14 @@ const SideNavFooter = styled("div", { isContentScrollable: boolean; odysseyDesignTokens: DesignTokens; }) => ({ - position: "sticky", - bottom: 0, - paddingTop: odysseyDesignTokens.Spacing2, + flexShrink: 0, transitionProperty: "box-shadow", transitionDuration: odysseyDesignTokens.TransitionDurationMain, transitionTiming: odysseyDesignTokens.TransitionTimingMain, + backgroundColor: odysseyDesignTokens.HueNeutralWhite, // The box shadow should appear above the footer only if the scrollable region has overflow ...(isContentScrollable && { - boxShadow: odysseyDesignTokens.DepthHigh, + boxShadow: "0px -8px 8px -8px rgba(39, 39, 39, 0.08)", }), }), ); @@ -220,39 +230,65 @@ const SideNavFooter = styled("div", { const SideNavFooterItemsContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ - paddingTop: odysseyDesignTokens.Spacing2, - paddingBottom: odysseyDesignTokens.Spacing2, + paddingBlock: odysseyDesignTokens.Spacing4, + // paddingBlockEnd: odysseyDesignTokens.Spacing4, + paddingInline: odysseyDesignTokens.Spacing5, display: "flex", - justifyContent: "center", flexWrap: "wrap", alignItems: "center", fontSize: odysseyDesignTokens.TypographySizeOverline, - "& a": { + + a: { color: `${odysseyDesignTokens.TypographyColorHeading} !important`, - }, - "& a:hover": { - textDecoration: "none", - }, - "& a:visited": { - color: odysseyDesignTokens.TypographyColorHeading, + + "&:hover": { + textDecoration: "none", + }, + "&:visited": { + color: odysseyDesignTokens.TypographyColorHeading, + }, }, })); +const LoadingItemContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + alignItems: "center", + display: "flex", + gap: odysseyDesignTokens.Spacing2, + paddingBlock: odysseyDesignTokens.Spacing2, + paddingInline: odysseyDesignTokens.Spacing4, +})); + const getHasScrollableContent = (scrollableContainer: HTMLElement) => scrollableContainer.scrollHeight > scrollableContainer.clientHeight; +const LoadingItem = () => { + const odysseyDesignTokens: DesignTokens = useOdysseyDesignTokens(); + return ( + + + + + ); +}; + const SideNav = ({ - customCompanyLogo, - expandedWidth = DEFAULT_SIDE_NAV_WIDTH, + appName, footerComponent, footerItems, - hasCustomCompanyLogo, hasCustomFooter, isCollapsible, isCompact, - appName, + isLoading, + logoProps, onCollapse, onExpand, + onSort, sideNavItems, }: SideNavProps) => { const [isSideNavCollapsed, setSideNavCollapsed] = useState(false); @@ -262,6 +298,8 @@ const SideNav = ({ const resizeObserverRef = useRef(null); const intersectionObserverRef = useRef(null); const odysseyDesignTokens: DesignTokens = useOdysseyDesignTokens(); + const { t } = useTranslation(); + const [sideNavItemsList, updateSideNavItemsList] = useState(sideNavItems); useEffect(() => { const updateIsContentScrollable = () => { @@ -343,7 +381,7 @@ const SideNav = ({ } cancelAnimationFrame(resizeObserverDebounceTimer); // Ensure timer is cleared on component unmount }; - }, []); + }, [sideNavItems]); const scrollIntoViewRef = useRef(null); /** @@ -353,8 +391,8 @@ const SideNav = ({ */ const firstSideNavItemIdWithIsSelected = useMemo(() => { const flattenedItems = sideNavItems.flatMap((sideNavItem) => - sideNavItem.children - ? [sideNavItem, ...sideNavItem.children] + sideNavItem.nestedNavItems + ? [sideNavItem, ...sideNavItem.nestedNavItems] : sideNavItem, ); const firstItemWithIsSelected = flattenedItems.find( @@ -391,39 +429,77 @@ const SideNav = ({ [isCompact], ); - const processedSideNavItems = useMemo( - () => - sideNavItems.map((item) => ({ - ...item, - children: item.children?.map((childProps) => { - return ( + const setSelectedItem = useCallback( + (selectedItemId: string) => { + const updatedSideNavItems = sideNavItemsList.map((item) => { + if (item.id === selectedItemId) { + item.isSelected = true; + } else if (item.isSelected) { + delete item.isSelected; + } + + return item.nestedNavItems + ? { + ...item, + nestedNavItems: item.nestedNavItems.map((childItem) => { + if (childItem.id === selectedItemId) { + childItem.isSelected = true; + } else if (childItem.isSelected) { + delete childItem.isSelected; + } + return childItem; + }), + } + : item; + }); + updateSideNavItemsList(updatedSideNavItems); + }, + [sideNavItemsList], + ); + + const processedSideNavItems = useMemo(() => { + return sideNavItemsList?.map((item) => ({ + ...item, + childNavItems: item.nestedNavItems?.map((childProps) => { + return { + id: childProps.id, + isSelected: childProps.isSelected, + isDisabled: childProps.isDisabled, + navItem: ( - ); - }), - })), - [ - getRefIfThisIsFirstNodeWithIsSelected, - sideNavItems, - sideNavItemContentProviderValue, - ], - ); + ), + }; + }), + })); + }, [ + getRefIfThisIsFirstNodeWithIsSelected, + sideNavItemsList, + sideNavItemContentProviderValue, + setSelectedItem, + ]); const sideNavExpandClickHandler = useCallback(() => { isSideNavCollapsed ? onExpand?.() : onCollapse?.(); setSideNavCollapsed(!isSideNavCollapsed); }, [isSideNavCollapsed, setSideNavCollapsed, onExpand, onCollapse]); - const sideNavExpandKeyHandler = useCallback( - (event: KeyboardEvent) => { + const sideNavExpandKeyHandler = useCallback< + KeyboardEventHandler + >( + (event) => { if (event?.key === "Enter" || event?.code === "Space") { event.preventDefault(); sideNavExpandClickHandler(); @@ -432,132 +508,175 @@ const SideNav = ({ [sideNavExpandClickHandler], ); - return ( - - - - + const setSortedItems = useCallback( + (parentId: string, activeIndex: number, overIndex: number) => { + const sortedSideNavItems = sideNavItemsList.map((item) => + item.id === parentId && item.nestedNavItems + ? { + ...item, + nestedNavItems: arrayMove( + item.nestedNavItems, + activeIndex, + overIndex, + ), } - /> - - - - {processedSideNavItems?.map((item) => { - const { - id, - label, - isSectionHeader, - startIcon, - children, - isDefaultExpanded, - isDisabled, - isExpanded, - } = item; - - if (isSectionHeader) { - return ( - - {label} - - ); - } else if (children) { - return ( - - - - {children} - - - - ); - } else { - return ( - - - - ); - } - })} - - - {(footerItems || hasCustomFooter) && ( - - {hasCustomFooter - ? footerComponent - : footerItems && ( - - - - )} - - )} - + : item, + ); + updateSideNavItemsList(sortedSideNavItems); + onSort?.(sortedSideNavItems); + }, + [onSort, sideNavItemsList], + ); + + return ( + {isCollapsible && ( - + )} + + - - - - - - )} - + + + + + + {isLoading + ? [...Array(6)].map((_, index) => ) + : processedSideNavItems?.map((item) => { + const { + id, + label, + isSectionHeader, + startIcon, + childNavItems, + isSortable, + isDefaultExpanded, + isDisabled, + isExpanded, + } = item; + + if (isSectionHeader) { + return ( + + + {label} + + + ); + } else if (childNavItems) { + return ( + + + + {isSortable ? ( + ( + + {sortableItem.navItem} + + )} + /> + ) : ( + childNavItems.map((item) => item.navItem) + )} + + + + ); + } else { + return ( + + + + ); + } + })} + + + {!isLoading && (footerItems || hasCustomFooter) && ( + + {hasCustomFooter + ? footerComponent + : footerItems && ( + + + + )} + + )} + + + + ); }; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavFooterContent.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavFooterContent.tsx index c05a4c650b..2fbef7d2f6 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNavFooterContent.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavFooterContent.tsx @@ -11,10 +11,30 @@ */ import { memo, useMemo } from "react"; -import { useOdysseyDesignTokens } from "../../OdysseyDesignTokensContext"; +import styled from "@emotion/styled"; + +import { + useOdysseyDesignTokens, + DesignTokens, +} from "../../OdysseyDesignTokensContext"; import type { SideNavFooterItem } from "./types"; import { Box } from "../../Box"; import { Link } from "../../Link"; +import { useTranslation } from "react-i18next"; + +const StyledFooterNav = styled("nav")({ + display: "flex", +}); + +const StyledFooterItemContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + "& + &": { + marginInlineStart: odysseyDesignTokens.Spacing4, + paddingInlineStart: odysseyDesignTokens.Spacing4, + borderInlineStart: `1px solid ${odysseyDesignTokens.HueNeutral300}`, + }, +})); const SideNavFooterContent = ({ footerItems, @@ -22,41 +42,29 @@ const SideNavFooterContent = ({ footerItems: SideNavFooterItem[]; }) => { const odysseyDesignTokens = useOdysseyDesignTokens(); + const { t } = useTranslation(); - const footerContent = useMemo(() => { - return footerItems?.map((item, index) => ( - { + return footerItems?.map((item) => ( + {item.href ? ( - - {item.label} - + {item.label} ) : ( - - {item.label} - - )} - {index < footerItems.length - 1 && ( - - | - + {item.label} )} - + )); }, [footerItems, odysseyDesignTokens]); - return footerContent; + return ( + + {memoizedFooterContent} + + ); }; const MemoizedSideNavFooterContent = memo(SideNavFooterContent); MemoizedSideNavFooterContent.displayName = "SideNavFooterContent"; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavHeader.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavHeader.tsx index 25f5dff705..7232e6d614 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNavHeader.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavHeader.tsx @@ -11,76 +11,91 @@ */ import styled from "@emotion/styled"; -import { memo, useMemo, type ReactElement } from "react"; +import { memo } from "react"; +import { Skeleton } from "@mui/material"; + import { type DesignTokens, useOdysseyDesignTokens, } from "../../OdysseyDesignTokensContext"; -import { Box } from "../../Box"; +import { SideNavLogo } from "./SideNavLogo"; +import { SideNavProps } from "./types"; import { Heading6 } from "../../Typography"; import { TOP_NAV_HEIGHT } from "../TopNav"; +const SideNavHeaderContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + position: "relative", + display: "flex", + flexDirection: "column", + backgroundColor: odysseyDesignTokens.HueNeutralWhite, + zIndex: 1, +})); + const SideNavLogoContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + display: "flex", + alignItems: "center", height: TOP_NAV_HEIGHT, - padding: odysseyDesignTokens.Spacing3, - borderColor: odysseyDesignTokens.HueNeutral50, - borderStyle: odysseyDesignTokens.BorderStyleMain, - borderWidth: 0, - borderBottomWidth: odysseyDesignTokens.BorderWidthMain, + padding: odysseyDesignTokens.Spacing4, + + "svg, img": { + maxHeight: "100%", + width: "auto", + maxWidth: "100%", + }, })); -const SideNavHeaderContainer = styled("div", { +const SideNavHeadingContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ display: "flex", justifyContent: "space-between", alignItems: "center", - paddingLeft: odysseyDesignTokens.Spacing4, - paddingRight: odysseyDesignTokens.Spacing4, - paddingTop: odysseyDesignTokens.Spacing3, - paddingBottom: odysseyDesignTokens.Spacing3, + padding: odysseyDesignTokens.Spacing4, + width: "100%", + + ["& .MuiTypography-root"]: { + margin: 0, + width: "100%", + }, })); -export type SideNavHeader = { +export type SideNavHeaderProps = { /** * The app's name. */ appName: string; /** - * Company logo that displays above the app name. + * If the side nav currently has no items, it will be loading. */ - companyLogo: ReactElement; -}; + isLoading?: boolean; +} & Pick; -const SideNavHeader = ({ appName, companyLogo }: SideNavHeader) => { +const SideNavHeader = ({ + appName, + isLoading, + logoProps, +}: SideNavHeaderProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); - const sideNavHeaderStyles = useMemo( - () => ({ - marginTop: odysseyDesignTokens.Spacing2, - }), - [odysseyDesignTokens], - ); - return ( - + - {companyLogo} + {isLoading ? ( + // The skeleton takes the hardcoded dimensions of the Okta logo + + ) : ( + + )} - - - {appName} - - - + + {isLoading ? : appName} + + ); }; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContent.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContent.tsx index ff497a3c7d..f17db5e69b 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContent.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContent.tsx @@ -32,7 +32,7 @@ import { } from "./SideNavItemContentContext"; import { ExternalLinkIcon } from "../../icons.generated"; -export const SideNavListItemContainer = styled("li", { +export const StyledSideNavListItem = styled("li", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop !== "isSelected", })<{ @@ -42,11 +42,14 @@ export const SideNavListItemContainer = styled("li", { }>(({ odysseyDesignTokens, isSelected }) => ({ display: "flex", alignItems: "center", - backgroundColor: isSelected ? odysseyDesignTokens.HueNeutral50 : "unset", - margin: `${odysseyDesignTokens.Spacing1} 0`, - "&:last-child": { - marginBottom: odysseyDesignTokens.Spacing2, - }, + backgroundColor: "unset", + borderRadius: odysseyDesignTokens.BorderRadiusMain, + transition: `backgroundColor ${odysseyDesignTokens.TransitionDurationMain}, color ${odysseyDesignTokens.TransitionDurationMain}`, + + ...(isSelected && { + color: `${odysseyDesignTokens.TypographyColorAction} !important`, + backgroundColor: odysseyDesignTokens.HueBlue50, + }), })); const scrollToNode = (node: HTMLElement | null) => { @@ -63,67 +66,127 @@ type ScrollIntoViewHandle = { scrollIntoView: () => void; }; -const GetNavItemContentStyles = ({ +export const getBaseNavItemContentStyles = ({ odysseyDesignTokens, - contextValue, isDisabled, + isSelected, }: { odysseyDesignTokens: DesignTokens; - contextValue: SideNavItemContentContextValue; isDisabled?: boolean; -}) => { - return { - display: "flex", - alignItems: "center", - width: "100%", - textDecoration: "none", - color: `${isDisabled ? odysseyDesignTokens.TypographyColorDisabled : odysseyDesignTokens.TypographyColorHeading} !important`, - minHeight: contextValue.isCompact - ? odysseyDesignTokens.Spacing6 - : odysseyDesignTokens.Spacing7, - padding: contextValue.isCompact - ? `${odysseyDesignTokens.Spacing0} ${odysseyDesignTokens.Spacing4} ${odysseyDesignTokens.Spacing0} calc(${odysseyDesignTokens.Spacing4} * ${contextValue.depth})` - : `${odysseyDesignTokens.Spacing2} ${odysseyDesignTokens.Spacing4} ${odysseyDesignTokens.Spacing2} calc(${odysseyDesignTokens.Spacing4} * ${contextValue.depth})`, - "&:focus-visible": { - borderRadius: 0, - outlineColor: odysseyDesignTokens.FocusOutlineColorPrimary, - outlineStyle: odysseyDesignTokens.FocusOutlineStyle, - outlineWidth: odysseyDesignTokens.FocusOutlineWidthMain, - textDecoration: "none", - outlineOffset: 0, - color: isDisabled - ? "default" - : `${odysseyDesignTokens.TypographyColorAction} !important`, - backgroundColor: !isDisabled - ? odysseyDesignTokens.HueNeutral50 - : "inherit", - }, - "&:hover": { + isSelected?: boolean; +}) => ({ + display: "flex", + alignItems: "center", + width: "100%", + textDecoration: "none", + color: `${odysseyDesignTokens.TypographyColorHeading} !important`, + minHeight: "unset", + paddingBlock: odysseyDesignTokens.Spacing3, + paddingInlineEnd: odysseyDesignTokens.Spacing4, + borderRadius: odysseyDesignTokens.BorderRadiusMain, + transition: `backgroundColor ${odysseyDesignTokens.TransitionDurationMain}, color ${odysseyDesignTokens.TransitionDurationMain}`, + cursor: "pointer", + + // `[data-sortable-container='true']:has(button:hover) &` - when the sortable item's drag handle is hovered we want to trigger the same hover behavior as if you were hovering the actual item + "&:hover, [data-sortable-container='true']:has(button:hover, button:focus, button:focus-visible) &": + { textDecoration: "none", - cursor: isDisabled ? "default" : "pointer", - color: isDisabled - ? "default" - : `${odysseyDesignTokens.TypographyColorAction} !important`, - backgroundColor: !isDisabled ? odysseyDesignTokens.HueBlue50 : "inherit", + backgroundColor: odysseyDesignTokens.HueNeutral50, + + ...(isSelected && { + backgroundColor: odysseyDesignTokens.HueBlue50, + color: odysseyDesignTokens.TypographyColorAction, + }), + + ...(isDisabled && { + backgroundColor: "unset", + }), }, - }; -}; + + ...(isSelected && { + color: `${odysseyDesignTokens.TypographyColorAction}`, + fontWeight: odysseyDesignTokens.TypographyWeightBodyBold, + }), + + ...(isDisabled && { + cursor: "default", + color: `${odysseyDesignTokens.TypographyColorDisabled} !important`, + }), + + "&:focus-visible, &:focus": { + outline: "none", + boxShadow: `inset 0 0 0 2px ${odysseyDesignTokens.PalettePrimaryMain}`, + }, +}); + +export const getNavItemContentStyles = ({ + odysseyDesignTokens, + contextValue, +}: { + odysseyDesignTokens: DesignTokens; + contextValue: SideNavItemContentContextValue; +}) => ({ + paddingInlineStart: `calc(${odysseyDesignTokens.Spacing4} * ${contextValue.depth} + ${odysseyDesignTokens.Spacing6})`, + + ...(contextValue.depth === 1 && { + paddingInlineStart: odysseyDesignTokens.Spacing4, + }), + + ...(contextValue.isCompact && { + paddingBlock: odysseyDesignTokens.Spacing1, + }), +}); const NavItemContentContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop != "contextValue" && - prop !== "isDisabled", -})(GetNavItemContentStyles); + prop !== "isDisabled" && + prop !== "isSelected", +})<{ + contextValue: SideNavItemContentContextValue; + odysseyDesignTokens: DesignTokens; + isSelected?: boolean; + isDisabled?: boolean; +}>(({ contextValue, odysseyDesignTokens, isDisabled, isSelected }) => ({ + ...getBaseNavItemContentStyles({ + odysseyDesignTokens, + isDisabled, + isSelected, + }), + + ...getNavItemContentStyles({ + odysseyDesignTokens, + contextValue, + }), +})); -const NavItemLinkContainer = styled(NavItemLink, { +const StyledNavItemLink = styled(NavItemLink, { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop != "contextValue" && - prop !== "isDisabled", -})(GetNavItemContentStyles); + prop !== "isDisabled" && + prop !== "isSelected", +})<{ + contextValue: SideNavItemContentContextValue; + odysseyDesignTokens: DesignTokens; + isSelected?: boolean; + isDisabled?: boolean; +}>(({ contextValue, odysseyDesignTokens, isDisabled, isSelected }) => ({ + ...getBaseNavItemContentStyles({ + odysseyDesignTokens, + isDisabled, + isSelected, + }), + + ...getNavItemContentStyles({ + odysseyDesignTokens, + contextValue, + }), +})); const SideNavItemContent = ({ + count, id, label, href, @@ -133,11 +196,13 @@ const SideNavItemContent = ({ statusLabel, endIcon, onClick, - isSelected, isDisabled, + isSelected, scrollRef, + onItemSelected, }: Pick< SideNavItem, + | "count" | "id" | "label" | "href" @@ -147,13 +212,14 @@ const SideNavItemContent = ({ | "statusLabel" | "endIcon" | "onClick" - | "isSelected" | "isDisabled" + | "isSelected" > & { /** * The ref used to scroll to this item */ scrollRef?: React.RefObject; + onItemSelected?(selectedItemId: string): void; }) => { const sidenavItemContentContext = useSideNavItemContent(); const contextValue = useMemo( @@ -175,24 +241,36 @@ const SideNavItemContent = ({ [], ); + const itemClickHandler = useCallback( + (id: string) => { + return () => { + onItemSelected?.(id); + onClick?.(); + }; + }, + [onClick, onItemSelected], + ); + const sideNavItemContentKeyHandler = useCallback( - (event: KeyboardEvent) => { + (id: string, event: KeyboardEvent) => { if (event?.key === "Enter") { event.preventDefault(); + onItemSelected?.(id); onClick?.(); } }, - [onClick], + [onClick, onItemSelected], ); return ( - { @@ -202,8 +280,10 @@ const SideNavItemContent = ({ odysseyDesignTokens={odysseyDesignTokens} contextValue={contextValue} isDisabled={isDisabled} + isSelected={isSelected} > ) => + sideNavItemContentKeyHandler(id, event) + } + isSelected={isSelected} > ) : ( - )} - + ) } - + ); }; + const MemoizedSideNavItemContent = memo(SideNavItemContent); MemoizedSideNavItemContent.displayName = "SideNavItemContent"; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContentContext.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContentContext.tsx index c1f87d5c2f..7da125bcf7 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContentContext.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContentContext.tsx @@ -14,12 +14,14 @@ import { createContext, useContext } from "react"; export type SideNavItemContentContextValue = { isCompact?: boolean; + isSortable?: boolean; depth: number; }; export const SideNavItemContentContext = createContext({ isCompact: false, + isSortable: false, depth: 1, }); diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemLinkContent.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemLinkContent.tsx index bb0979a88b..f46bded39c 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemLinkContent.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemLinkContent.tsx @@ -19,6 +19,7 @@ import { import { Box } from "../../Box"; import { Status } from "../../Status"; import type { SideNavItem } from "./types"; +import { Badge } from "../../Badge"; const SideNavItemLabelContainer = styled("div", { shouldForwardProp: (prop) => @@ -31,12 +32,12 @@ const SideNavItemLabelContainer = styled("div", { display: "flex", flexWrap: "wrap", alignItems: "center", - fontSize: odysseyDesignTokens.TypographyScale0, - fontWeight: odysseyDesignTokens.TypographyWeightHeading, - marginLeft: isIconVisible ? odysseyDesignTokens.Spacing2 : 0, + fontSize: odysseyDesignTokens.TypographySizeBody, + marginInlineStart: isIconVisible ? odysseyDesignTokens.Spacing3 : 0, })); const SideNavItemLinkContent = ({ + count, label, startIcon, endIcon, @@ -44,13 +45,16 @@ const SideNavItemLinkContent = ({ statusLabel, }: Pick< SideNavItem, - "label" | "startIcon" | "endIcon" | "severity" | "statusLabel" + "count" | "label" | "startIcon" | "endIcon" | "severity" | "statusLabel" >): ReactNode => { const odysseyDesignTokens = useOdysseyDesignTokens(); const sideNavItemContentStyles = useMemo( () => ({ - marginLeft: odysseyDesignTokens.Spacing2, + alignItems: "center", + display: "flex", + gap: odysseyDesignTokens.Spacing1, + marginInlineStart: odysseyDesignTokens.Spacing2, }), [odysseyDesignTokens], ); @@ -62,11 +66,17 @@ const SideNavItemLinkContent = ({ odysseyDesignTokens={odysseyDesignTokens} isIconVisible={Boolean(startIcon)} > - {Boolean(startIcon)} {label} - {severity && ( + {!count && severity && ( - + {severity && ( + + )} + + )} + {!severity && count && ( + + {count && } )} diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavLogo.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavLogo.tsx new file mode 100644 index 0000000000..f6983025df --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavLogo.tsx @@ -0,0 +1,41 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { memo, useMemo } from "react"; +import { OktaLogo } from "./OktaLogo"; +import { SideNavLogoProps } from "./types"; + +const SideNavLogo = ({ + imageAltText, + href, + logoComponent, + imageUrl, +}: SideNavLogoProps) => { + const logo = useMemo(() => { + if (logoComponent) { + return logoComponent; + } + + if (imageAltText && imageUrl) { + return {imageAltText}; + } + + return ; + }, [imageAltText, logoComponent, imageUrl]); + + return href ? {logo} : logo; +}; + +const MemoizedSideNavLogo = memo(SideNavLogo); +MemoizedSideNavLogo.displayName = "SideNavLogo"; + +export { MemoizedSideNavLogo as SideNavLogo }; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavToggleButton.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavToggleButton.tsx new file mode 100644 index 0000000000..8bb369f750 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavToggleButton.tsx @@ -0,0 +1,245 @@ +/*! + * Copyright (c) 2022-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { Button as MuiButton } from "@mui/material"; +import type { ButtonProps as MuiButtonProps } from "@mui/material"; +import { + HTMLAttributes, + memo, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from "react"; +import styled from "@emotion/styled"; +import { useTranslation } from "react-i18next"; + +import { FocusHandle } from "../../inputUtils"; +import { MuiPropsContext, MuiPropsContextType } from "../../MuiPropsContext"; +import { + DesignTokens, + useOdysseyDesignTokens, +} from "../../OdysseyDesignTokensContext"; +import { Tooltip } from "../../Tooltip"; + +const StyledToggleButton = styled(MuiButton, { + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isSideNavCollapsed", +})( + ({ + isSideNavCollapsed, + odysseyDesignTokens, + }: { + isSideNavCollapsed: boolean; + odysseyDesignTokens: DesignTokens; + }) => ({ + backgroundColor: "transparent", + position: "relative", + width: odysseyDesignTokens.Spacing6, + height: odysseyDesignTokens.Spacing6, + border: 0, + zIndex: 2, + + "&:focus-visible": { + boxShadow: `inset 0 0 0 2px ${odysseyDesignTokens.PalettePrimaryMain}`, + outline: "none", + }, + + "&:hover, &:focus": { + backgroundColor: "transparent", + + "#lineOne": { + animation: `lineOne-animate-to-collapse ${odysseyDesignTokens.TransitionDurationMain} cubic-bezier(0, 0, 0.2, 1)`, + animationFillMode: "forwards", + "@keyframes lineOne-animate-to-collapse": { + "0%": { + transform: "translate3d(-50%, -50%, 0)", + }, + "50%": { + transform: "translate3d(-50%, -50%, 0) rotate(-90deg) scaleY(.75)", + }, + "100%": { + transform: "translate3d(-50%, -27%, 0) rotate(-45deg) scaleY(.75)", + }, + }, + }, + + "#lineTwo": { + animation: `lineTwo-animate-to-collapse ${odysseyDesignTokens.TransitionDurationMain} cubic-bezier(0, 0, 0.2, 1)`, + animationFillMode: "forwards", + "@keyframes lineTwo-animate-to-collapse": { + "0%": { + transform: "translate3d(-50%, -50%, 0)", + }, + "50%": { + transform: "translate3d(-50%, -50%, 0) rotate(-90deg) scaleY(.75)", + }, + "100%": { + transform: "translate3d(-50%, -73%, 0) rotate(-135deg) scaleY(.75)", + }, + }, + }, + + ...(isSideNavCollapsed && { + "#lineOne": { + animation: `lineOne-animate-to-expand ${odysseyDesignTokens.TransitionDurationMain} cubic-bezier(0, 0, 0.2, 1)`, + animationFillMode: "forwards", + "@keyframes lineOne-animate-to-expand": { + "0%": { + transform: "translate3d(-50%, -50%, 0)", + }, + "50%": { + transform: "translate3d(-50%, -50%, 0) rotate(90deg) scaleY(.75)", + }, + "100%": { + transform: + "translate3d(-50%, -73%, 0) rotate(135deg) scaleY(.75)", + }, + }, + }, + + "#lineTwo": { + animation: `lineTwo-animate-to-expand ${odysseyDesignTokens.TransitionDurationMain} cubic-bezier(0, 0, 0.2, 1)`, + animationFillMode: "forwards", + "@keyframes lineTwo-animate-to-expand": { + "0%": { + transform: "translate3d(-50%, -50%, 0)", + }, + "50%": { + transform: "translate3d(-50%, -50%, 0) rotate(90deg) scaleY(.75)", + }, + "100%": { + transform: "translate3d(-50%, -27%, 0) rotate(45deg) scaleY(.75)", + }, + }, + }, + }), + }, + + span: { + position: "absolute", + top: "50%", + left: "50%", + width: "2px", + height: odysseyDesignTokens.Spacing4, + backgroundColor: odysseyDesignTokens.HueNeutral600, + transform: "translate3d(-50%, -50%, 0)", + transition: `transform ${odysseyDesignTokens.TransitionDurationMain}`, + }, + }), +); + +export type SideNavToggleButtonProps = { + /** + * The ref forwarded to the Button + */ + buttonRef?: React.RefObject; + /** + * The `id` of the item this button controls + */ + ariaControls: string; + /** + * The ID of the Button + */ + id?: string; + isSideNavCollapsed: boolean; + tabIndex?: HTMLAttributes["tabIndex"]; + /** + * The click event handler for the Button + */ + onClick?: MuiButtonProps["onClick"]; + onKeyDown?: MuiButtonProps["onKeyDown"]; +}; + +const SideNavToggleButton = ({ + ariaControls, + buttonRef, + id, + isSideNavCollapsed, + onClick, + tabIndex, +}: SideNavToggleButtonProps) => { + const odysseyDesignTokens = useOdysseyDesignTokens(); + const { t } = useTranslation(); + + const localButtonRef = useRef(null); + + useImperativeHandle( + buttonRef, + () => ({ + focus: () => { + localButtonRef.current?.focus(); + }, + }), + [], + ); + + const toggleLabel = useMemo( + () => + isSideNavCollapsed + ? t("sidenav.toggle.expand") + : t("sidenav.toggle.collapse"), + [isSideNavCollapsed, t], + ); + + const renderButton = useCallback( + (muiProps: MuiPropsContextType) => { + return ( + { + if (element) { + ( + localButtonRef as React.MutableRefObject + ).current = element; + //@ts-expect-error ref is not an optional prop on the props context type + muiProps?.ref?.(element); + } + }} + tabIndex={tabIndex} + variant="floating" + > + + + + ); + }, + [ + ariaControls, + id, + isSideNavCollapsed, + odysseyDesignTokens, + onClick, + tabIndex, + toggleLabel, + ], + ); + + return ( + + {renderButton} + + ); +}; + +const MemoizedSideNavToggleButton = memo(SideNavToggleButton); +MemoizedSideNavToggleButton.displayName = "SideNavToggleButton"; + +export { MemoizedSideNavToggleButton as SideNavToggleButton }; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SortableList/SortableItem.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SortableList/SortableItem.tsx new file mode 100644 index 0000000000..3f5f910b9e --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/SideNav/SortableList/SortableItem.tsx @@ -0,0 +1,202 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { createContext, useContext, useMemo } from "react"; +import type { CSSProperties, PropsWithChildren } from "react"; +import type { + DraggableSyntheticListeners, + UniqueIdentifier, +} from "@dnd-kit/core"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { useSortable } from "@dnd-kit/sortable"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { CSS } from "@dnd-kit/utilities"; +import styled from "@emotion/styled"; +import { + DesignTokens, + useOdysseyDesignTokens, +} from "../../../OdysseyDesignTokensContext"; +import { useTranslation } from "react-i18next"; + +type ItemProps = { + id: UniqueIdentifier; + isDisabled?: boolean; + isSelected?: boolean; +}; + +interface Context { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attributes: Record; + listeners: DraggableSyntheticListeners; + ref(node: HTMLElement | null): void; +} + +const SortableItemContext = createContext({ + attributes: {}, + listeners: undefined, + ref() {}, +}); + +const StyledSortableListItem = styled("li", { + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isSelected", +})<{ + odysseyDesignTokens: DesignTokens; + isSelected?: boolean; +}>(({ odysseyDesignTokens, isSelected }) => ({ + position: "relative", + + button: { + top: "50%", + left: odysseyDesignTokens.Spacing2, + transform: "translateY(-50%)", + }, + + svg: { + path: { + fill: "currentColor", + }, + }, + + "&:has(a:hover, button:hover, a:focus, button:focus, a:focus-visible, button:focus-visible, [role='button']:hover, [role='button']:focus, [role='button']:focus-visible)": + { + button: { + opacity: 1, + outlineWidth: 0, + }, + }, + + ...(isSelected && { + svg: { + path: { + fill: odysseyDesignTokens.TypographyColorAction, + }, + }, + }), +})); + +const StyledUl = styled("ul")({ + padding: 0, + listStyle: "none", + listStyleType: "none", +}); + +const StyledDragHandleButton = styled("button", { + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isDragging", +})<{ + odysseyDesignTokens: DesignTokens; + isDragging?: boolean; +}>(({ odysseyDesignTokens, isDragging }) => ({ + position: "absolute", + opacity: 0, + // paddingInlineStart: odysseyDesignTokens.Spacing4, + padding: odysseyDesignTokens.Spacing2, + // paddingBlock: 0, + border: "none", + backgroundColor: "transparent", + cursor: `${isDragging ? "grabbing" : "grab"}`, + transition: `opacity ${odysseyDesignTokens.TransitionDurationMain}`, + borderRadius: odysseyDesignTokens.BorderRadiusMain, + + svg: { + display: "flex", + }, + + "&:focus, &:focus-visible": { + outline: "none", + boxShadow: `inset 0 0 0 2px ${odysseyDesignTokens.PalettePrimaryMain}`, + }, +})); + +type DragHandleProps = { + isDisabled?: boolean; + isDragging?: boolean; +}; + +export const DragHandle = ({ isDragging }: DragHandleProps) => { + const { attributes, listeners, ref } = useContext(SortableItemContext); + const odysseyDesignTokens: DesignTokens = useOdysseyDesignTokens(); + const { t } = useTranslation(); + + return ( + + + + + + ); +}; + +export const SortableItem = ({ + id, + isDisabled, + isSelected, + children, +}: PropsWithChildren) => { + const { + attributes, + isDragging, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + } = useSortable({ id }); + const context: Context = useMemo( + () => ({ + attributes, + listeners, + ref: setActivatorNodeRef, + }), + [attributes, listeners, setActivatorNodeRef], + ); + const style: CSSProperties = { + opacity: isDragging ? 0.4 : undefined, + transform: CSS.Translate.toString(transform), + transition, + }; + + const odysseyDesignTokens: DesignTokens = useOdysseyDesignTokens(); + return ( + + + {!isDisabled && } + {children} + + + ); +}; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SortableList/SortableList.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SortableList/SortableList.tsx new file mode 100644 index 0000000000..9fcbf614e5 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/SideNav/SortableList/SortableList.tsx @@ -0,0 +1,122 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import React, { useMemo, useState } from "react"; +import type { ReactNode } from "react"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { Active, Announcements, UniqueIdentifier } from "@dnd-kit/core"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { + SortableContext, + sortableKeyboardCoordinates, +} from "@dnd-kit/sortable"; + +import { SortableItem } from "./SortableItem"; +import { SortableOverlay } from "./SortableOverlay"; +import { useTranslation } from "react-i18next"; + +export interface BaseItem { + id: UniqueIdentifier; + isDisabled: boolean | undefined; + isSelected: boolean | undefined; + navItem: ReactNode; +} + +interface ListProps { + parentId: string; + items: T[]; + onChange(parentId: string, activeIndex: number, overIndex: number): void; + renderItem(item: T): ReactNode; +} + +export const SortableList = ({ + parentId, + items, + onChange, + renderItem, +}: ListProps) => { + const [active, setActive] = useState(null); + const activeItem = useMemo( + () => items.find((item) => item.id === active?.id), + [active, items], + ); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const { t } = useTranslation(); + const announcements: Announcements = useMemo( + () => ({ + onDragStart: ({ active }) => { + return `${t("sortable.list.drag.start", { activeId: active.id })}`; + }, + onDragOver: ({ active, over }) => { + if (over) { + return `${t("sortable.list.drag.moved.over", { activeId: active.id, overId: over.id })}`; + } + return `${t("sortable.list.drag.nolonger.over", { activeId: active.id })}`; + }, + onDragEnd: ({ active, over }) => { + if (over) { + return `${t("sortable.list.drag.end.dropped.over", { activeId: active.id, overId: over.id })}`; + } + return `${t("sortable.list.drag.end.dropped", { activeId: active.id })}`; + }, + onDragCancel: ({ active }) => { + return `${t("sortable.list.drag.cancel", { activeId: active.id })}`; + }, + }), + [t], + ); + + return ( + { + setActive(active); + }} + onDragEnd={({ active, over }) => { + if (over && active.id !== over?.id) { + const activeIndex = items.findIndex(({ id }) => id === active.id); + const overIndex = items.findIndex(({ id }) => id === over.id); + onChange(parentId, activeIndex, overIndex); + } + setActive(null); + }} + onDragCancel={() => { + setActive(null); + }} + > + + {items.map((item) => ( + {renderItem(item)} + ))} + + + {activeItem ? renderItem(activeItem) : null} + + + ); +}; + +SortableList.Item = SortableItem; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SortableList/SortableOverlay.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SortableList/SortableOverlay.tsx new file mode 100644 index 0000000000..92dc9ba00f --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/SideNav/SortableList/SortableOverlay.tsx @@ -0,0 +1,34 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import type { PropsWithChildren } from "react"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { DragOverlay, defaultDropAnimationSideEffects } from "@dnd-kit/core"; +import type { DropAnimation } from "@dnd-kit/core"; + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +}; + +interface Props {} + +export function SortableOverlay({ children }: PropsWithChildren) { + return ( + {children} + ); +} diff --git a/packages/odyssey-react-mui/src/labs/SideNav/types.ts b/packages/odyssey-react-mui/src/labs/SideNav/types.ts index 8afede98ba..46590b4ae3 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/types.ts +++ b/packages/odyssey-react-mui/src/labs/SideNav/types.ts @@ -14,6 +14,41 @@ import type { ReactElement } from "react"; import type { HtmlProps } from "../../HtmlProps"; import type { statusSeverityValues } from "../../Status"; +export type SideNavLogoProps = { + href?: string; +} & ( + | { + /** + * a component to render as the logo + */ + logoComponent: ReactElement; + imageAltText?: never; + imageUrl?: never; + } + | { + /** + * The src url to render in an `img` tag + */ + imageUrl: string; + /** + * alt text for the img logo + */ + imageAltText: string; + logoComponent?: never; + } + | { + /** + * The src url to render in an `img` tag + */ + imageUrl?: never; + /** + * alt text for the img logo + */ + imageAltText?: never; + logoComponent?: never; + } +); + export type SideNavProps = { /** * Side Nav header text that is usually reserved to show the App name @@ -27,6 +62,14 @@ export type SideNavProps = { * Determines whether the side nav items use compact layout */ isCompact?: boolean; + /** + * Before the side nav has items, it will be in a loading state. + */ + isLoading?: boolean; + /** + * An optional logo component or src string for an img to display in the header. If not provided, will default to the Okta logo + */ + logoProps?: SideNavLogoProps; /** * Triggers when the side nav is collapsed */ @@ -36,66 +79,53 @@ export type SideNavProps = { */ onExpand?(): void; /** - * Nav items in the side nav + * Triggers when the item is reordered */ - sideNavItems: SideNavItem[]; + onSort?(reorderedItems: SideNavItem[]): void; /** - * A CSS length string indicating the customizable expanded width of the SideNav container. - * (it will be smaller if isCollapsible and collapsed) + * Nav items in the side nav */ - expandedWidth?: string; + sideNavItems: SideNavItem[]; } & ( | { /** - * An optional logo to display in the header. If not provided, will default to the Okta logo. + * The component to display as the footer; if present the `footerItems` are ignored and not rendered. */ - customCompanyLogo: ReactElement; - /** - * Use the built-in Okta logo or a custom one. - */ - hasCustomCompanyLogo: true; + footerComponent?: ReactElement; + footerItems?: never; + hasCustomFooter: true; } | { - customCompanyLogo?: never; - hasCustomCompanyLogo?: false; + footerComponent?: never; + /** + * Footer items in the side nav + */ + footerItems?: SideNavFooterItem[]; + hasCustomFooter?: false; } ) & - ( - | { - /** - * The component to display as the footer; if present the `footerItems` are ignored and not rendered. - */ - footerComponent?: ReactElement; - footerItems?: never; - hasCustomFooter: true; - } - | { - footerComponent?: never; - /** - * Footer items in the side nav - */ - footerItems?: SideNavFooterItem[]; - hasCustomFooter?: false; - } - ) & Pick; export type SideNavItem = { - id: string; - label: string; + /** + * The number to display as a count alongside the nav item + */ + count?: number; /** * The icon element to display at the end of the Nav Item */ endIcon?: ReactElement; + id: string; /** * Whether the item is disabled. When set to true the nav item is set to Disabled color, - * the link/item is not clickable, and item with children is not expandable. + * the link/item is not clickable, and item with nestedNavItems is not expandable. */ isDisabled?: boolean; /** * Whether the item is active/selected */ isSelected?: boolean; + label: string; /** * Event fired when the nav item is clicked */ @@ -118,34 +148,37 @@ export type SideNavItem = { target?: string; } & ( | { + nestedNavItems?: never; + href?: never; + isDefaultExpanded?: never; + isExpanded?: never; /** * Determines if the side nav item is a section header */ isSectionHeader: true; - href?: never; - children?: never; - isDefaultExpanded?: never; - isExpanded?: never; + isSortable?: never; } | { + nestedNavItems?: never; /** * link added to the nav item. if it is undefined, static text will be displayed. * fires onClick event when it is passed */ href?: string; - children?: never; - isSectionHeader?: never; isDefaultExpanded?: never; isExpanded?: never; + isSectionHeader?: never; + isSortable?: never; } | { /** - * An array of side nav items to be displayed as children within Accordion + * An array of side nav items to be displayed as nestedNavItems within Accordion */ - children?: Array>; + nestedNavItems?: Array>; endIcon?: never; + href?: never; /** - * Whether the accordion (nav item with children) is expanded by default + * Whether the accordion (nav item with nestedNavItems) is expanded by default */ isDefaultExpanded?: boolean; /** @@ -153,8 +186,11 @@ export type SideNavItem = { * Setting this prop enables control over the accordion. */ isExpanded?: boolean; + /** + * If true, enables sorting for the accordion items + */ isSectionHeader?: never; - href?: never; + isSortable?: boolean; } ); diff --git a/packages/odyssey-react-mui/src/labs/TopNav/TopNav.tsx b/packages/odyssey-react-mui/src/labs/TopNav/TopNav.tsx index cc7cde44dc..dd8de2bb69 100644 --- a/packages/odyssey-react-mui/src/labs/TopNav/TopNav.tsx +++ b/packages/odyssey-react-mui/src/labs/TopNav/TopNav.tsx @@ -21,40 +21,70 @@ import { export const TOP_NAV_HEIGHT = `${64 / 14}rem`; -export type TopNavProps = { - /** - * React components that render into the left side of the top nav. - */ - leftSideComponent?: ReactElement; - /** - * React components that render into the right side of the top nav. - */ - rightSideComponent?: ReactElement; -} & Pick; +const StyledLeftSideContainer = styled("div")(() => ({ + flexGrow: 1, +})); + +const StyledRightSideContainer = styled("div")(() => ({ + flexShrink: 0, +})); const StyledTopNavContainer = styled("div", { - shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isScrolled", })<{ odysseyDesignTokens: DesignTokens; -}>(({ odysseyDesignTokens }) => ({ + isScrolled?: boolean; +}>(({ odysseyDesignTokens, isScrolled }) => ({ alignItems: "center", backgroundColor: odysseyDesignTokens.HueNeutral50, + boxShadow: isScrolled ? odysseyDesignTokens.DepthMedium : undefined, + clipPath: "inset(0 0 -100vh 0)", display: "flex", + gap: odysseyDesignTokens.Spacing4, height: "100%", justifyContent: "space-between", maxHeight: TOP_NAV_HEIGHT, minHeight: TOP_NAV_HEIGHT, paddingBlock: odysseyDesignTokens.Spacing2, - paddingInline: odysseyDesignTokens.Spacing6, + paddingInline: odysseyDesignTokens.Spacing8, + transition: `box-shadow ${odysseyDesignTokens.TransitionDurationMain} ${odysseyDesignTokens.TransitionTimingMain}`, + zIndex: 1, })); -const TopNav = ({ leftSideComponent, rightSideComponent }: TopNavProps) => { +export type TopNavProps = { + /** + * Whether or not the underlying content has been scrolled + */ + isScrolled?: boolean; + /** + * React components that render into the left side of the top nav. + */ + leftSideComponent?: ReactElement; + /** + * React components that render into the right side of the top nav. + */ + rightSideComponent?: ReactElement; +} & Pick; + +const TopNav = ({ + isScrolled, + leftSideComponent, + rightSideComponent, +}: TopNavProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); return ( - - {leftSideComponent ??
} - {rightSideComponent ??
} + + + {leftSideComponent ??
} + + + {rightSideComponent ??
} + ); }; diff --git a/packages/odyssey-react-mui/src/labs/TopNav/UserProfile.tsx b/packages/odyssey-react-mui/src/labs/TopNav/UserProfile.tsx index 82744df761..f2be78c8f4 100644 --- a/packages/odyssey-react-mui/src/labs/TopNav/UserProfile.tsx +++ b/packages/odyssey-react-mui/src/labs/TopNav/UserProfile.tsx @@ -18,6 +18,7 @@ import { useOdysseyDesignTokens, } from "../../OdysseyDesignTokensContext"; import { Subordinate } from "../../Typography"; +import { Box } from "../../Box"; const UserProfileContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", @@ -37,6 +38,7 @@ const UserProfileIconContainer = styled("div", { const UserProfileInfoContainer = styled("div")(() => ({ display: "flex", flexDirection: "column", + textAlign: "left", })); export type UserProfileProps = { @@ -52,9 +54,18 @@ export type UserProfileProps = { * Org name of the logged in user */ orgName: string; + /** + * The icon element to display after the username + */ + userNameEndIcon?: ReactElement; }; -const UserProfile = ({ profileIcon, userName, orgName }: UserProfileProps) => { +const UserProfile = ({ + profileIcon, + userName, + orgName, + userNameEndIcon, +}: UserProfileProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); return ( @@ -66,7 +77,20 @@ const UserProfile = ({ profileIcon, userName, orgName }: UserProfileProps) => { )} - {userName} + {userNameEndIcon ? ( + + {userName} + {userNameEndIcon} + + ) : ( + {userName} + )} {orgName} diff --git a/packages/odyssey-react-mui/src/labs/TopNav/UserProfileMenuButton.tsx b/packages/odyssey-react-mui/src/labs/TopNav/UserProfileMenuButton.tsx new file mode 100644 index 0000000000..5888380cd5 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/TopNav/UserProfileMenuButton.tsx @@ -0,0 +1,55 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { memo } from "react"; +import { UserProfile, UserProfileProps } from "./UserProfile"; +import { ChevronDownIcon } from "../../icons.generated"; +import { + AdditionalBaseMenuButtonProps, + BaseMenuButton, + BaseMenuButtonProps, +} from "../../Buttons/BaseMenuButton"; + +export type UserProfileMenuButtonProps = Omit & + AdditionalBaseMenuButtonProps & + UserProfileProps; + +const UserProfileMenuButton = (props: UserProfileMenuButtonProps) => { + const { + profileIcon, + userName, + orgName, + userNameEndIcon, + buttonVariant = "floating", + ...menuButtonProps + } = props; + return ( + } + /> + } + /> + ); +}; + +const MemoizedUserProfileMenuButton = memo(UserProfileMenuButton); +MemoizedUserProfileMenuButton.displayName = "UserProfileMenuButton"; + +export { MemoizedUserProfileMenuButton as UserProfileMenuButton }; diff --git a/packages/odyssey-react-mui/src/labs/TopNav/index.ts b/packages/odyssey-react-mui/src/labs/TopNav/index.ts index e70c5134c0..c4b40143af 100644 --- a/packages/odyssey-react-mui/src/labs/TopNav/index.ts +++ b/packages/odyssey-react-mui/src/labs/TopNav/index.ts @@ -12,3 +12,4 @@ export * from "./TopNav"; export * from "./UserProfile"; +export * from "./UserProfileMenuButton"; diff --git a/packages/odyssey-react-mui/src/labs/UiShell/UiShell.test.tsx b/packages/odyssey-react-mui/src/labs/UiShell/UiShell.test.tsx index 40cb6177ba..6d2a912e80 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/UiShell.test.tsx +++ b/packages/odyssey-react-mui/src/labs/UiShell/UiShell.test.tsx @@ -26,9 +26,8 @@ describe("UiShell", () => { appRootElement={appRootElement} onSubscriptionCreated={() => {}} optionalComponents={{ - topNavLeftSide:
, sideNavFooter:
, - companyLogo:
, + topNavLeftSide:
, topNavRightSide: ( { test("renders optionally-available `componentSlots`", async () => { const optionalComponentTestIds: Array< keyof Required["optionalComponents"] - > = ["companyLogo", "sideNavFooter"]; + > = ["sideNavFooter"]; // This is the subscription we give the component, and then once subscribed, we're going to immediately call it with new props. const subscribeToPropChanges: UiShellProps["subscribeToPropChanges"] = ( @@ -125,7 +124,6 @@ describe("UiShell", () => { ...defaultComponentProps, sideNavProps: { appName: "", - hasCustomCompanyLogo: true, hasCustomFooter: true, sideNavItems: [], }, diff --git a/packages/odyssey-react-mui/src/labs/UiShell/UiShellContent.tsx b/packages/odyssey-react-mui/src/labs/UiShell/UiShellContent.tsx index 6a799351d9..627e4fac5e 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/UiShellContent.tsx +++ b/packages/odyssey-react-mui/src/labs/UiShell/UiShellContent.tsx @@ -20,47 +20,56 @@ import { useOdysseyDesignTokens, type DesignTokens, } from "../../OdysseyDesignTokensContext"; +import { useScrollState } from "./useScrollState"; const StyledAppContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })<{ odysseyDesignTokens: DesignTokens; }>(({ odysseyDesignTokens }) => ({ + gridArea: "app-content", overflowX: "hidden", - overflowY: "scroll", + overflowY: "auto", paddingBlock: odysseyDesignTokens.Spacing5, - paddingInline: odysseyDesignTokens.Spacing6, + paddingInline: odysseyDesignTokens.Spacing8, })); -const StyledFlexibleContentContainer = styled("div", { +const StyledBannersContainer = styled("div")(() => ({ + gridArea: "banners", +})); + +const StyledSideNavContainer = styled("div")(() => ({ + gridArea: "side-nav", +})); + +const StyledShellContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })<{ odysseyDesignTokens: DesignTokens; }>(({ odysseyDesignTokens }) => ({ backgroundColor: odysseyDesignTokens.HueNeutral50, - display: "flex", - flexBasis: "100%", - flexDirection: "column", - flexGrow: 1, -})); - -const StyledRigidContentContainer = styled("div")(() => ({ - flexShrink: 0, - height: "100%", -})); - -const StyledShellContainer = styled("div")(() => ({ - display: "flex", - flexWrap: "nowrap", + display: "grid", + gridGap: 0, + gridTemplateAreas: ` + "banners banners" + "side-nav top-nav" + "side-nav app-content" + `, + gridTemplateColumns: "auto 1fr", + gridTemplateRows: "auto auto 1fr", height: "100vh", width: "100vw", })); +const StyledTopNavContainer = styled("div")(() => ({ + gridArea: "top-nav", +})); + export type UiShellNavComponentProps = { /** * Object that gets pass directly to the side nav component. */ - sideNavProps?: Omit; + sideNavProps?: Omit; /** * Object that gets pass directly to the top nav component. */ @@ -81,7 +90,6 @@ export type UiShellContentProps = { */ optionalComponents?: { banners?: ReactElement; - companyLogo?: SideNavProps["customCompanyLogo"]; sideNavFooter?: SideNavProps["footerComponent"]; topNavLeftSide?: TopNavProps["leftSideComponent"]; topNavRightSide?: TopNavProps["rightSideComponent"]; @@ -103,25 +111,20 @@ const UiShellContent = ({ topNavProps, }: UiShellContentProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); + const { isContentScrolled, scrollableContentRef } = useScrollState(); return ( - - + + + {optionalComponents?.banners} + + + {sideNavProps && ( )} - + - + + - - {optionalComponents?.banners} - - {appComponent} - - + + {appComponent} + ); }; diff --git a/packages/odyssey-react-mui/src/labs/UiShell/index.ts b/packages/odyssey-react-mui/src/labs/UiShell/index.ts index 098411173c..12f80169f0 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/index.ts +++ b/packages/odyssey-react-mui/src/labs/UiShell/index.ts @@ -11,7 +11,7 @@ */ export * from "./renderUiShell"; +export * from "./useHasUiShell"; export { UiShell, type UiShellProps } from "./UiShell"; - export { type UiShellNavComponentProps } from "./UiShellContent"; diff --git a/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.test.tsx b/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.test.tsx index f11299fece..bb8bcf134d 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.test.tsx +++ b/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.test.tsx @@ -54,7 +54,6 @@ describe("renderUiShell", () => { }); expect(slottedElements.banners).toBeInstanceOf(HTMLDivElement); - expect(slottedElements.companyLogo).toBeInstanceOf(HTMLDivElement); expect(slottedElements.sideNavFooter).toBeInstanceOf(HTMLDivElement); expect(slottedElements.topNavLeftSide).toBeInstanceOf(HTMLDivElement); expect(slottedElements.topNavRightSide).toBeInstanceOf(HTMLDivElement); diff --git a/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.tsx b/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.tsx index 3e0002e029..85add6f84b 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.tsx +++ b/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.tsx @@ -19,12 +19,13 @@ import { UiShell, UiShellProps } from "./UiShell"; import { renderReactInWebComponent } from "../../web-component/renderReactInWebComponent"; import { type UiShellNavComponentProps } from "./UiShellContent"; +export const uiShellDataAttribute = "data-unified-ui-shell"; + export const optionalComponentSlotNames: Record< keyof Required["optionalComponents"], string > = { banners: "banners", - companyLogo: "company-logo", sideNavFooter: "side-nav-footer", topNavLeftSide: "top-nav-left-side", topNavRightSide: "top-nav-right-side", @@ -60,6 +61,9 @@ export const renderUiShell = ({ const appRootElement = explicitAppRootElement || document.createElement("div"); + // Add this attribute so `PageTemplate` and potentially other components will know if they're in UI Shell with special padding already available. + uiShellRootElement.setAttribute(uiShellDataAttribute, ""); + const { publish: publishPropChanges, subscribe: subscribeToPropChanges } = createMessageBus>(); diff --git a/packages/odyssey-react-mui/src/labs/UiShell/useHasUiShell.ts b/packages/odyssey-react-mui/src/labs/UiShell/useHasUiShell.ts new file mode 100644 index 0000000000..ae5e852101 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/UiShell/useHasUiShell.ts @@ -0,0 +1,25 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { useEffect, useState } from "react"; + +import { uiShellDataAttribute } from "./renderUiShell"; + +export const useHasUiShell = () => { + const [hasUiShell, setHasUiShell] = useState(false); + + useEffect(() => { + setHasUiShell(Boolean(document.querySelector(`[${uiShellDataAttribute}]`))); + }, []); + + return hasUiShell; +}; diff --git a/packages/odyssey-react-mui/src/labs/UiShell/useScrollState.ts b/packages/odyssey-react-mui/src/labs/UiShell/useScrollState.ts new file mode 100644 index 0000000000..c5c251ae76 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/UiShell/useScrollState.ts @@ -0,0 +1,56 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { useEffect, useRef, useState } from "react"; + +export const useScrollState = < + ScrollableContentElement extends HTMLElement = HTMLDivElement, +>() => { + const [isContentScrolled, setIsContentScrolled] = useState(false); + + const scrollableContentRef = useRef(null); + + useEffect(() => { + if (scrollableContentRef.current) { + let requestedAnimationFrameId: number; + const scrollableContentElement = scrollableContentRef.current; + + const updateScrollState = () => { + cancelAnimationFrame(requestedAnimationFrameId); + + requestedAnimationFrameId = requestAnimationFrame(() => { + setIsContentScrolled(scrollableContentElement.scrollTop > 0); + }); + }; + + scrollableContentElement.addEventListener("scroll", updateScrollState); + + updateScrollState(); + + return () => { + scrollableContentElement.removeEventListener( + "scroll", + updateScrollState, + ); + + cancelAnimationFrame(requestedAnimationFrameId); + }; + } + + return () => {}; + }, []); + + return { + isContentScrolled, + scrollableContentRef, + }; +}; diff --git a/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties b/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties index d5436bd8d3..abd52ab805 100644 --- a/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties +++ b/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties @@ -1,3 +1,13 @@ +navigation.label = Main navigation +navigation.footer = Navigation secondary links +navigation.drag.handle = Drag handle +sortable.list.drag.start = Picked up draggable item {{activeId}} +sortable.list.drag.moved.over = Draggable item {{activeId}} was moved over droppable area {{overId}} +sortable.list.drag.nolonger.over = Draggable item {{activeId}} is no longer over a droppable area +sortable.list.drag.end.dropped.over = Draggable item {{activeId}} was dropped over droppable area {{overId}} +sortable.list.drag.end.dropped = Draggable item {{activeId}} was dropped +sortable.list.drag.cancel = Dragging was cancelled. Draggable item {{activeId}} was dropped + breadcrumbs.home.text = Home breadcrumbs.label.text = Breadcrumbs close.text = Close @@ -82,6 +92,8 @@ severity.error = error severity.info = info severity.success = success severity.warning = warning +sidenav.toggle.expand = Open navigation +sidenav.toggle.collapse = Close navigation switch.active = Active switch.inactive = Inactive table.columnvisibility.arialabel = Show/hide columns diff --git a/packages/odyssey-react-mui/src/theme/components.tsx b/packages/odyssey-react-mui/src/theme/components.tsx index 4c29202952..b9e215f0db 100644 --- a/packages/odyssey-react-mui/src/theme/components.tsx +++ b/packages/odyssey-react-mui/src/theme/components.tsx @@ -137,12 +137,6 @@ export const components = ({ verticalAlign: "middle", width: "1em", }, - "&.nav-accordion-summary": { - padding: `${odysseyTokens.Spacing2} ${odysseyTokens.Spacing4}`, - ".MuiAccordionSummary-expandIconWrapper.Mui-expanded": { - transform: "rotate(-90deg) !important", - }, - }, }), content: () => ({ marginBlock: 0, @@ -160,9 +154,7 @@ export const components = ({ paddingInline: odysseyTokens.Spacing3, paddingBlock: odysseyTokens.Spacing4, "&.nav-accordion-details": { - paddingTop: 0, - paddingBottom: 0, - paddingLeft: odysseyTokens.Spacing2, + padding: 0, }, }), }, @@ -2824,13 +2816,17 @@ export const components = ({ { borderTopRightRadius: odysseyTokens.Spacing2, borderBottomRightRadius: odysseyTokens.Spacing2, - flexGrow: 1, [`& .Mui-TableHeadCell-ResizeHandle-Wrapper`]: { display: "none", }, }, + [`.ods-column-grow .${tableHeadClasses.root} &:nth-last-of-type(2), .ods-column-grow .${tableBodyClasses.root} &:nth-last-of-type(2)`]: + { + flexGrow: 1, + }, + ...(ownerState.variant === "number" && { textAlign: "end", fontFeatureSettings: '"lnum", "tnum"', diff --git a/packages/odyssey-storybook/CHANGELOG.md b/packages/odyssey-storybook/CHANGELOG.md index deaedcd2ef..eca440664c 100644 --- a/packages/odyssey-storybook/CHANGELOG.md +++ b/packages/odyssey-storybook/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.27.0](https://github.com/okta/odyssey/compare/v1.26.0...v1.27.0) (2024-11-14) + +### Features + +- adds drag-n-drop feature to the sidenav ([#2405](https://github.com/okta/odyssey/issues/2405)) ([aca8dad](https://github.com/okta/odyssey/commit/aca8dadd7130847112483f49bdc52b065bde3c88)) + +## [1.26.0](https://github.com/okta/odyssey/compare/v1.25.0...v1.26.0) (2024-11-04) + +### Features + +- adds side-nav collapse handle ([#2385](https://github.com/okta/odyssey/issues/2385)) ([1582be5](https://github.com/okta/odyssey/commit/1582be5669000c6dcec5881ba8178406a457a3b0)) +- adds the ability to render an encapsulated Unified UI Shell ([#2373](https://github.com/okta/odyssey/issues/2373)) ([f964a29](https://github.com/okta/odyssey/commit/f964a29c7eb956fc05cb16fd51963a03c6b08507)) + ## [1.25.0](https://github.com/okta/odyssey/compare/v1.24.1...v1.25.0) (2024-10-15) ### Features diff --git a/packages/odyssey-storybook/package.json b/packages/odyssey-storybook/package.json index 941e39240a..064fd047e3 100644 --- a/packages/odyssey-storybook/package.json +++ b/packages/odyssey-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@okta/odyssey-storybook", - "version": "1.25.0", + "version": "1.27.0", "description": "Documentation for Odyssey, Okta's design system", "author": "Okta, Inc.", "license": "Apache-2.0", diff --git a/packages/odyssey-storybook/src/components/odyssey-labs/DataView/DataView.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-labs/DataView/DataView.stories.tsx index b983bffa17..a35f7471b4 100644 --- a/packages/odyssey-storybook/src/components/odyssey-labs/DataView/DataView.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-labs/DataView/DataView.stories.tsx @@ -1069,3 +1069,79 @@ export const CompactCards: StoryObj = { ); }, }; + +export const GrowColumnWithoutActions: StoryObj = { + render: function C() { + const [data, setData] = useState(personData); + const { getData } = useDataCallbacks(data, setData); + + const columns: DataColumns = [ + { + accessorKey: "order", + header: "ID", + enableColumnFilter: false, + grow: true, + }, + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "city", + header: "City", + }, + ]; + + return ( + + ); + }, +}; + +export const GrowColumnWithActions: StoryObj = { + render: function C() { + const [data, setData] = useState(personData); + const { getData } = useDataCallbacks(data, setData); + + const columns: DataColumns = [ + { + accessorKey: "order", + header: "ID", + enableColumnFilter: false, + grow: true, + }, + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "city", + header: "City", + }, + ]; + + const rowActions = useCallback( + () =>
+ + ), - optionalComponents: sharedOptionalComponents, + optionalComponents: { + ...sharedOptionalComponents, + banners: , + }, subscribeToPropChanges: (subscriber) => { subscriber({ sideNavProps: sharedSideNavProps, diff --git a/packages/odyssey-storybook/src/components/odyssey-labs/UserProfile/UserProfile.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-labs/UserProfile/UserProfile.stories.tsx new file mode 100644 index 0000000000..f0eac5aab3 --- /dev/null +++ b/packages/odyssey-storybook/src/components/odyssey-labs/UserProfile/UserProfile.stories.tsx @@ -0,0 +1,106 @@ +/*! + * Copyright (c) 2021-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { UserProfile, UserProfileProps } from "@okta/odyssey-react-mui/labs"; +import { Meta, StoryObj } from "@storybook/react"; +import { MuiThemeDecorator } from "../../../../.storybook/components"; +import icons from "../../../../.storybook/components/iconUtils"; +import { within } from "@storybook/testing-library"; +import { ChevronDownIcon, UserIcon } from "@okta/odyssey-react-mui/icons"; +import { PlaywrightProps } from "../../odyssey-mui/storybookTypes"; + +const storybookMeta: Meta = { + title: "Labs Components/UserProfile", + component: UserProfile, + argTypes: { + profileIcon: { + control: { type: "select" }, + options: Object.keys(icons), + mapping: icons, + description: "An optional icon to display ahead of the user profile", + table: { type: { summary: "" } }, + }, + userName: { + control: "text", + description: "Org name of the logged in user", + table: { type: { summary: "string" } }, + }, + orgName: { + control: "text", + description: "Org name of the logged in user", + table: { type: { summary: "string" } }, + }, + userNameEndIcon: { + control: { type: "select" }, + options: Object.keys(icons), + mapping: icons, + description: "An optional icon to display at the end of the user profile", + table: { type: { summary: "" } }, + }, + }, + args: { + userName: "test.user@test.com", + orgName: "ORG123", + }, + decorators: [MuiThemeDecorator], + tags: ["autodocs"], +}; + +export default storybookMeta; + +export const Default: StoryObj = {}; + +export const WithProfileIcon: StoryObj = { + args: { + profileIcon: , + }, + play: async ({ canvasElement, step }: PlaywrightProps) => { + await step("With profile icon", async () => { + const canvas = within(canvasElement); + const buttonPopover = canvas.queryByRole("button", { + name: "More actions", + }); + expect(buttonPopover).not.toBeNull(); + }); + }, +}; + +export const WithUserEndIcon: StoryObj = { + args: { + userNameEndIcon: , + }, + play: async ({ canvasElement, step }: PlaywrightProps) => { + await step("With profile icon", async () => { + const canvas = within(canvasElement); + const buttonPopover = canvas.queryByRole("button", { + name: "More actions", + }); + expect(buttonPopover).not.toBeNull(); + }); + }, +}; + +export const WithProfileAndUserEndIcon: StoryObj = { + args: { + profileIcon: , + userNameEndIcon: , + }, + play: async ({ canvasElement, step }: PlaywrightProps) => { + await step("With profile icon", async () => { + const canvas = within(canvasElement); + const buttonPopover = canvas.queryByRole("button", { + name: "More actions", + }); + expect(buttonPopover).not.toBeNull(); + }); + }, +}; diff --git a/packages/odyssey-storybook/src/components/odyssey-labs/UserProfileMenuButton/UserProfileMenuButton.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-labs/UserProfileMenuButton/UserProfileMenuButton.stories.tsx new file mode 100644 index 0000000000..ef2a7e4c18 --- /dev/null +++ b/packages/odyssey-storybook/src/components/odyssey-labs/UserProfileMenuButton/UserProfileMenuButton.stories.tsx @@ -0,0 +1,205 @@ +/*! + * Copyright (c) 2021-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { + Box, + Heading3, + Link, + menuAlignmentValues, + Subordinate, + useOdysseyDesignTokens, +} from "@okta/odyssey-react-mui"; +import { + UserProfileMenuButton, + UserProfileMenuButtonProps, +} from "@okta/odyssey-react-mui/labs"; +import { Meta, StoryObj } from "@storybook/react"; +import { MuiThemeDecorator } from "../../../../.storybook/components"; +import icons from "../../../../.storybook/components/iconUtils"; +import { within } from "@storybook/testing-library"; +import { UserIcon } from "@okta/odyssey-react-mui/icons"; +import { PlaywrightProps } from "../../odyssey-mui/storybookTypes"; +import { ReactNode } from "react"; + +const BoxWithBottomMargin = ({ children }: { children: ReactNode }) => { + const odysseyDesignTokens = useOdysseyDesignTokens(); + + return ( + + {children} + + ); +}; + +const storybookMeta: Meta = { + title: "Labs Components/UserProfileMenuButton", + component: UserProfileMenuButton, + argTypes: { + profileIcon: { + control: { type: "select" }, + options: Object.keys(icons), + mapping: icons, + description: "An optional icon to display ahead of the user profile", + table: { type: { summary: "" } }, + }, + userName: { + control: "text", + description: "Org name of the logged in user", + table: { type: { summary: "string" } }, + }, + orgName: { + control: "text", + description: "Org name of the logged in user", + table: { type: { summary: "string" } }, + }, + userNameEndIcon: { + control: { type: "select" }, + options: Object.keys(icons), + mapping: icons, + description: "An optional icon to display at the end of the user profile", + table: { type: { summary: "" } }, + }, + menuAlignment: { + options: menuAlignmentValues, + control: { type: "radio" }, + description: "The horizontal alignment of the popover.", + table: { + type: { + summary: menuAlignmentValues.join(" | "), + }, + defaultValue: { + summary: "left", + }, + }, + }, + popoverContent: { + control: "obj", + description: "The content to appear in the popover", + table: { + type: { + summary: "[ReactNode | NullElement]", + }, + }, + type: { + required: true, + name: "other", + value: "[ReactNode]", + }, + }, + }, + args: { + userName: "test.user@test.com", + orgName: "ORG123", + profileIcon: , + menuAlignment: "left", + popoverContent: ( + + + Add-Min O'Cloudy Tud + + + administrator1@clouditude.net + rain.okta1.com + + + My Settings + + + Sign Out + + + ), + }, + decorators: [MuiThemeDecorator], + tags: ["autodocs"], +}; + +export default storybookMeta; + +export const Default: StoryObj = {}; + +export const WithRightPopoverAlignment: StoryObj = { + args: { + menuAlignment: "right", + }, + render: function C(props: UserProfileMenuButtonProps) { + return ( + + + + ); + }, + play: async ({ + canvasElement, + step, + }: PlaywrightProps) => { + await step("With profile icon", async () => { + const canvas = within(canvasElement); + const buttonPopover = canvas.queryByRole("button", { + name: "More actions", + }); + expect(buttonPopover).not.toBeNull(); + }); + }, +}; + +export const WithoutProfileIcon: StoryObj = { + args: { + profileIcon: undefined, + }, + play: async ({ + canvasElement, + step, + }: PlaywrightProps) => { + await step("With profile icon", async () => { + const canvas = within(canvasElement); + const buttonPopover = canvas.queryByRole("button", { + name: "More actions", + }); + expect(buttonPopover).not.toBeNull(); + }); + }, +}; + +export const PrimaryVariant: StoryObj = { + args: { + buttonVariant: "primary", + }, +}; + +export const SecondaryVariant: StoryObj = { + args: { + buttonVariant: "secondary", + }, +}; + +export const DangerVariant: StoryObj = { + args: { + buttonVariant: "danger", + }, +}; + +export const DangerSecondaryVariant: StoryObj = { + args: { + buttonVariant: "dangerSecondary", + }, +}; + +export const FloatingActionVariant: StoryObj = { + args: { + buttonVariant: "floatingAction", + }, +}; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/MenuButton/MenuButton.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/MenuButton/MenuButton.stories.tsx index 201f076d68..3c78bf5c53 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/MenuButton/MenuButton.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/MenuButton/MenuButton.stories.tsx @@ -20,12 +20,19 @@ import { buttonVariantValues, MenuItem, menuAlignmentValues, + Subordinate, + Paragraph, + useOdysseyDesignTokens, + Heading5, + Link, } from "@okta/odyssey-react-mui"; import { GroupIcon, GlobeIcon, CalendarIcon, + QuestionCircleIcon, } from "@okta/odyssey-react-mui/icons"; +import type { ReactNode } from "react"; import { fieldComponentPropsMetaData } from "../../../fieldComponentPropsMetaData"; import icons from "../../../../.storybook/components/iconUtils"; @@ -35,6 +42,20 @@ import { expect } from "@storybook/jest"; import { axeRun } from "../../../axe-util"; import type { PlaywrightProps } from "../storybookTypes"; +const BoxWithBottomMargin = ({ children }: { children: ReactNode }) => { + const odysseyDesignTokens = useOdysseyDesignTokens(); + + return ( + + {children} + + ); +}; + const storybookMeta: Meta = { title: "MUI Components/Menu Button", component: MenuButton, @@ -100,11 +121,26 @@ const storybookMeta: Meta = { }, }, type: { - required: true, + required: false, name: "other", value: "[MenuItem | Divider | ListSubheader]", }, }, + popoverContent: { + control: "obj", + description: + "The contents to display in the popover (instead of children)", + table: { + type: { + summary: "[ReactNode | NullElement]", + }, + }, + type: { + required: false, + name: "other", + value: "ReactNode", + }, + }, endIcon: { control: { type: "select", @@ -415,3 +451,69 @@ export const Alignment: StoryObj = { ); }, }; + +export const HelpPopover: StoryObj = { + args: { + buttonLabel: "", + endIcon: , + buttonVariant: "secondary", + popoverContent: [ + + + Title + Caption + Body + + + + Link + + Caption + + + + Link + + Caption + + + + Link + + Caption + + + + Link + + Caption + + + + Link + + Caption + + + + + + + Body{" "} + + Link + + + + , + ], + id: "floating", + }, + play: async ({ canvasElement, step }: PlaywrightProps) => { + await step("Filter and Select from listbox", async () => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "More actions" }); + expect(button).toHaveAttribute("id", "floating-button"); + }); + }, +}; diff --git a/packages/odyssey-storybook/src/guidelines/Roadmap/roadmap.json b/packages/odyssey-storybook/src/guidelines/Roadmap/roadmap.json index 28aa528c13..1956dcc44a 100644 --- a/packages/odyssey-storybook/src/guidelines/Roadmap/roadmap.json +++ b/packages/odyssey-storybook/src/guidelines/Roadmap/roadmap.json @@ -17,6 +17,33 @@ "develop": "Complete", "deliverableTiming": "Q3 FY25" }, + { + "name": "Aerial account page", + "type": "Page template", + "status": "Not started", + "define": "-", + "design": "-", + "develop": "-", + "deliverableTiming": "-" + }, + { + "name": "Aerial org details page", + "type": "Page template", + "status": "Not started", + "define": "-", + "design": "-", + "develop": "-", + "deliverableTiming": "-" + }, + { + "name": "Aerial user page", + "type": "Page template", + "status": "Not started", + "define": "-", + "design": "-", + "develop": "-", + "deliverableTiming": "-" + }, { "name": "Autocomplete", "type": "Component", @@ -98,6 +125,15 @@ "develop": "Complete", "deliverableTiming": "FY24" }, + { + "name": "Configuration ", + "type": "Page template", + "status": "Not started", + "define": "-", + "design": "Complete", + "develop": "Complete", + "deliverableTiming": "-" + }, { "name": "Dashboard", "type": "Page template", @@ -126,7 +162,7 @@ "deliverableTiming": "TBD" }, { - "name": "DataView", + "name": "DataView - move", "type": "Component", "status": "In Labs", "define": "Complete", @@ -189,7 +225,7 @@ "deliverableTiming": "Q2 FY25" }, { - "name": "Empty states v2 \n(illustration)", + "name": "Empty states with illustration", "type": "Component", "status": "In progress", "define": "Complete", @@ -305,6 +341,15 @@ "develop": "-", "deliverableTiming": "-" }, + { + "name": "Policy", + "type": "TBD", + "status": "In progress", + "define": "In progress", + "design": "-", + "develop": "-", + "deliverableTiming": "Q4 FY25" + }, { "name": "Policy Recommender\n(AI Pattern)", "type": "Pattern", diff --git a/packages/odyssey-svgr/CHANGELOG.md b/packages/odyssey-svgr/CHANGELOG.md index 5c7e1e713b..67e4529cad 100644 --- a/packages/odyssey-svgr/CHANGELOG.md +++ b/packages/odyssey-svgr/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.27.0](https://github.com/okta/odyssey/compare/v1.26.0...v1.27.0) (2024-11-14) + +**Note:** Version bump only for package @okta/odyssey-svgr + +## [1.26.0](https://github.com/okta/odyssey/compare/v1.25.0...v1.26.0) (2024-11-04) + +**Note:** Version bump only for package @okta/odyssey-svgr + ## [1.25.0](https://github.com/okta/odyssey/compare/v1.24.1...v1.25.0) (2024-10-15) **Note:** Version bump only for package @okta/odyssey-svgr diff --git a/packages/odyssey-svgr/package.json b/packages/odyssey-svgr/package.json index 7ad6643465..a2e6dcd5b4 100644 --- a/packages/odyssey-svgr/package.json +++ b/packages/odyssey-svgr/package.json @@ -1,6 +1,6 @@ { "name": "@okta/odyssey-svgr", - "version": "1.25.0", + "version": "1.27.0", "description": "Configuration files for svgr icon conversion", "author": "Okta, Inc.", "license": "Apache-2.0", diff --git a/yarn.lock b/yarn.lock index 55eaa60b0b..10d7c20ea4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2689,6 +2689,66 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/accessibility@npm:^3.0.0": + version: 3.1.0 + resolution: "@dnd-kit/accessibility@npm:3.1.0" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10/750a0537877d5dde3753e9ef59d19628b553567e90fc3e3b14a79bded08f47f4a7161bc0d003d7cd6b3bd9e10aa233628dca07d2aa5a2120cac84555ba1653d8 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:6.0.3": + version: 6.0.3 + resolution: "@dnd-kit/core@npm:6.0.3" + dependencies: + "@dnd-kit/accessibility": "npm:^3.0.0" + "@dnd-kit/utilities": "npm:^3.2.0" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10/32585e23ec8c2b1d4d4ca55228c64d39def1f2124ee45d6861d8b1ccd50b71bcaebefb277ef7161273f3783b90bc90f7c755c9b97eee37d0d40a632a9b30832b + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:7.0.0": + version: 7.0.0 + resolution: "@dnd-kit/sortable@npm:7.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.0" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.0.0 + react: ">=16.8.0" + checksum: 10/01e712d902c63ce6794cfdb774388e4af40d42ee56f7ce5d6d6ffa81b240b1352bc9f1309cf515adfeab48349168183eef70f3e2746fe016d269713c850ed6ac + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:3.2.0": + version: 3.2.0 + resolution: "@dnd-kit/utilities@npm:3.2.0" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10/6756055f502f1b7cbd5cfa29e26c6be7589d48fe10aeb81ab46651f11bc84e06169ad35c4e1fcb91c548a626be3fd8e9ec4a0de0d60cad3ccd444827af66502e + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.2.0": + version: 3.2.2 + resolution: "@dnd-kit/utilities@npm:3.2.2" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10/6cfe46a5fcdaced943982e7ae66b08b89235493e106eb5bc833737c25905e13375c6ecc3aa0c357d136cb21dae3966213dba063f19b7a60b1235a29a7b05ff84 + languageName: node + linkType: hard + "@emotion/babel-plugin@npm:^11.11.0": version: 11.11.0 resolution: "@emotion/babel-plugin@npm:11.11.0" @@ -5240,6 +5300,9 @@ __metadata: dependencies: "@babel/cli": "npm:^7.23.9" "@babel/core": "npm:^7.23.9" + "@dnd-kit/core": "npm:6.0.3" + "@dnd-kit/sortable": "npm:7.0.0" + "@dnd-kit/utilities": "npm:3.2.0" "@emotion/cache": "npm:^11.11.0" "@emotion/react": "npm:^11.11.4" "@emotion/styled": "npm:^11.11.0"