diff --git a/.jscpd.json b/.jscpd.json index c675d5628949..a60bd2fad377 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -40,6 +40,7 @@ "packages/components/src/components/**/*.spec.tsx", "packages/components/src/components/tag/tag.spec.tsx", "**/**config.ts", + "**/navigation-items.ts", "packages/foundations/assets/icons/functional/fonts/sources/*.json" ], "absolute": true diff --git a/build-power-apps/DBUI/DBUI.cdsproj b/build-power-apps/DBUI/DBUI.cdsproj index cf0c9c0ad00b..3931ebd3d9b1 100644 --- a/build-power-apps/DBUI/DBUI.cdsproj +++ b/build-power-apps/DBUI/DBUI.cdsproj @@ -44,6 +44,10 @@ + + + + diff --git a/packages/components/overrides/angular/src/components/main-navigation/index.ts b/packages/components/overrides/angular/src/components/main-navigation/index.ts new file mode 100644 index 000000000000..48bd43cb7e94 --- /dev/null +++ b/packages/components/overrides/angular/src/components/main-navigation/index.ts @@ -0,0 +1 @@ +export { DBMainNavigation, DBMainNavigationModule } from './main-navigation'; diff --git a/packages/components/overrides/angular/src/components/sub-navigation/index.ts b/packages/components/overrides/angular/src/components/sub-navigation/index.ts new file mode 100644 index 000000000000..1368a7fcd4b6 --- /dev/null +++ b/packages/components/overrides/angular/src/components/sub-navigation/index.ts @@ -0,0 +1 @@ +export { DBSubNavigation, DBSubNavigationModule } from './sub-navigation'; diff --git a/packages/components/scripts/post-build/components.js b/packages/components/scripts/post-build/components.js index e74ce835260e..e3711908516d 100644 --- a/packages/components/scripts/post-build/components.js +++ b/packages/components/scripts/post-build/components.js @@ -16,6 +16,14 @@ * }]} */ const getComponents = () => [ + { + name: 'sub-navigation' + }, + + { + name: 'main-navigation' + }, + { name: 'navigation-item' }, @@ -33,12 +41,6 @@ const getComponents = () => [ to: 'const dialogRef = useRef(component);' } ], - vue: [ - { - from: 'immediate: true,', - to: 'immediate: true,\nflush: "post"' - } - ], webComponents: [{ from: '__prev.find', to: '!!__prev.find' }] } }, @@ -52,14 +54,6 @@ const getComponents = () => [ { name: 'checkbox', - overwrites: { - vue: [ - { - from: 'immediate: true,', - to: 'immediate: true,\nflush: "post"' - } - ] - }, config: { vue: { vModel: [{ modelValue: 'checked', binding: ':checked' }] @@ -69,14 +63,6 @@ const getComponents = () => [ { name: 'radio', - overwrites: { - vue: [ - { - from: 'immediate: true,', - to: 'immediate: true,\nflush: "post"' - } - ] - }, config: { vue: { vModel: [{ modelValue: 'checked', binding: ':checked' }] diff --git a/packages/components/scripts/post-build/react.js b/packages/components/scripts/post-build/react.js index 9454f034d1f2..00ebb5c7e372 100644 --- a/packages/components/scripts/post-build/react.js +++ b/packages/components/scripts/post-build/react.js @@ -27,6 +27,14 @@ module.exports = (tmp) => { { from: `checked={props.checked}`, to: `defaultChecked={props.checked}` + }, + { + from: 'if (ref.current)', + to: 'if (ref?.current)' + }, + { + from: '[ref.current]', + to: '[ref]' } ]; diff --git a/packages/components/scripts/post-build/vue.js b/packages/components/scripts/post-build/vue.js index 045f139113df..6c23791896ba 100644 --- a/packages/components/scripts/post-build/vue.js +++ b/packages/components/scripts/post-build/vue.js @@ -44,7 +44,6 @@ const updateVModelBindings = (input, bindings) => { return fileContent .split('\n') .map((line) => { - return line.replace('// VUE:', ''); }) .join('\n'); @@ -99,7 +98,17 @@ module.exports = (tmp) => { }); } - runReplacements([], component, 'vue', vueFile); + runReplacements( + [ + { + from: 'immediate: true,', + to: 'immediate: true,\nflush: "post"' + } + ], + component, + 'vue', + vueFile + ); } catch (error) { console.error('Error occurred:', error); } diff --git a/packages/components/src/components/checkbox/model.ts b/packages/components/src/components/checkbox/model.ts index 9e3d86667dc6..ffcc16166089 100644 --- a/packages/components/src/components/checkbox/model.ts +++ b/packages/components/src/components/checkbox/model.ts @@ -10,7 +10,7 @@ import { FormProps, FormState, FormCheckProps, - FormCheckState + InitializedState } from '../../shared/model'; export interface DBCheckboxDefaultProps { @@ -35,7 +35,7 @@ export type DBCheckboxProps = DBCheckboxDefaultProps & FormCheckProps; export type DBCheckboxDefaultState = { - _indeterminate: boolean; + _indeterminate?: boolean; }; export type DBCheckboxState = DBCheckboxDefaultState & @@ -43,4 +43,4 @@ export type DBCheckboxState = DBCheckboxDefaultState & ChangeEventState & FocusEventState & FormState & - FormCheckState; + InitializedState; diff --git a/packages/components/src/components/header/header.scss b/packages/components/src/components/header/header.scss index 32031ee7d169..7523779f48ae 100644 --- a/packages/components/src/components/header/header.scss +++ b/packages/components/src/components/header/header.scss @@ -13,8 +13,8 @@ /* Mobile header should always be 56px */ min-height: to-rem(56); - padding-left: $db-spacing-fixed-md; - padding-right: $db-spacing-fixed-md; + gap: $db-spacing-fixed-md; + padding-inline: $db-spacing-fixed-md; .db-link { display: inline-block; @@ -39,10 +39,6 @@ .desktop-navigation { display: inherit; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); } } } diff --git a/packages/components/src/components/main-navigation/docs/Angular.md b/packages/components/src/components/main-navigation/docs/Angular.md new file mode 100644 index 000000000000..4031505c609b --- /dev/null +++ b/packages/components/src/components/main-navigation/docs/Angular.md @@ -0,0 +1,24 @@ +## Angular + +For general installation and configuration look at the [ngx-components](https://www.npmjs.com/package/@db-ui/ngx-components) package. + +### Load component + +```ts app.module.ts +//app.module.ts +import { DBMainNavigationModule } from '@db-ui/ngx-components'; + +@NgModule({ + ... + imports: [..., DBMainNavigationModule], + ... +}) + +``` + +### Use component + +```html app.component.html + +MainNavigation +``` diff --git a/packages/components/src/components/main-navigation/docs/HTML.md b/packages/components/src/components/main-navigation/docs/HTML.md new file mode 100644 index 000000000000..24534d97c9be --- /dev/null +++ b/packages/components/src/components/main-navigation/docs/HTML.md @@ -0,0 +1,13 @@ +## HTML + +For general installation and configuration look at the [components](https://www.npmjs.com/package/@db-ui/components) package. + +### Use component + +```html index.html + +... + +
MainNavigation
+ +``` diff --git a/packages/components/src/components/main-navigation/docs/React.md b/packages/components/src/components/main-navigation/docs/React.md new file mode 100644 index 000000000000..0787f099f05c --- /dev/null +++ b/packages/components/src/components/main-navigation/docs/React.md @@ -0,0 +1,14 @@ +## React + +For general installation and configuration look at the [react-components](https://www.npmjs.com/package/@db-ui/react-components) package. + +### Use component + +```tsx App.tsx +// App.tsx +import { DBMainNavigation } from "@db-ui/react-components"; + +const App = () => MainNavigation; + +export default App; +``` diff --git a/packages/components/src/components/main-navigation/docs/Vue.md b/packages/components/src/components/main-navigation/docs/Vue.md new file mode 100644 index 000000000000..cb6fb162bb07 --- /dev/null +++ b/packages/components/src/components/main-navigation/docs/Vue.md @@ -0,0 +1,16 @@ +## Vue + +For general installation and configuration look at the [v-components](https://www.npmjs.com/package/@db-ui/v-components) package. + +### Use component + +```vue App.vue + + + + +``` diff --git a/packages/components/src/components/main-navigation/index.ts b/packages/components/src/components/main-navigation/index.ts new file mode 100644 index 000000000000..846a714dbd69 --- /dev/null +++ b/packages/components/src/components/main-navigation/index.ts @@ -0,0 +1 @@ +export { default as DBMainNavigation } from './main-navigation'; diff --git a/packages/components/src/components/main-navigation/main-navigation-web-component.scss b/packages/components/src/components/main-navigation/main-navigation-web-component.scss new file mode 100644 index 000000000000..7aad751257f4 --- /dev/null +++ b/packages/components/src/components/main-navigation/main-navigation-web-component.scss @@ -0,0 +1 @@ +@forward "main-navigation"; diff --git a/packages/components/src/components/main-navigation/main-navigation.lite.tsx b/packages/components/src/components/main-navigation/main-navigation.lite.tsx new file mode 100644 index 000000000000..a8e0109ee9de --- /dev/null +++ b/packages/components/src/components/main-navigation/main-navigation.lite.tsx @@ -0,0 +1,62 @@ +import { + onMount, + onUpdate, + Show, + useMetadata, + useStore +} from '@builder.io/mitosis'; +import { DBMainNavigationState, DBMainNavigationProps } from './model'; +import classNames from 'classnames'; +import { setMainMenuToFirstListElement, uuid } from '../../utils'; + +useMetadata({ + isAttachedToShadowDom: true, + component: { + // MS Power Apps + includeIcon: false, + properties: [] + } +}); + +export default function DBMainNavigation(props: DBMainNavigationProps) { + // This is used as forwardRef + let component: any; + // jscpd:ignore-start + const state = useStore({ + initialized: false, + mainNavigationId: 'main-navigation-' + uuid(), + getClassNames: (...args: classNames.ArgumentArray) => { + return classNames(args); + } + }); + + onMount(() => { + state.initialized = true; + if (props.stylePath) { + state.stylePath = props.stylePath; + } + }); + + onUpdate(() => { + if (state.initialized && document && state.mainNavigationId) { + const menuElement = document?.getElementById( + state.mainNavigationId + ) as HTMLMenuElement; + if (menuElement) { + setMainMenuToFirstListElement(menuElement); + } + } + }, [state.initialized]); + // jscpd:ignore-end + + return ( + + ); +} diff --git a/packages/components/src/components/main-navigation/main-navigation.scss b/packages/components/src/components/main-navigation/main-navigation.scss new file mode 100644 index 000000000000..c80d2f79e9bb --- /dev/null +++ b/packages/components/src/components/main-navigation/main-navigation.scss @@ -0,0 +1,11 @@ +@use "@db-ui/foundations/build/scss/variables" as *; +@use "@db-ui/foundations/build/scss/variables.global" as *; + +.db-main-navigation { + & > menu { + display: flex; + padding: 0; + margin: 0; + gap: $db-spacing-fixed-sm; + } +} diff --git a/packages/components/src/components/main-navigation/main-navigation.spec.tsx b/packages/components/src/components/main-navigation/main-navigation.spec.tsx new file mode 100644 index 000000000000..c92f8dfe6de7 --- /dev/null +++ b/packages/components/src/components/main-navigation/main-navigation.spec.tsx @@ -0,0 +1,41 @@ +import { test, expect } from '@playwright/experimental-ct-react'; +import AxeBuilder from '@axe-core/playwright'; + +import { DBMainNavigation } from './index'; +// @ts-ignore - vue can only find it with .ts as file ending +import { DEFAULT_VIEWPORT } from '../../shared/constants.ts'; +import { DBNavigationItem } from '../navigation-item'; + +const comp = ( + + Test1 + Test2 + Test3 + +); + +const testComponent = () => { + test('should match screenshot', async ({ mount }) => { + const component = await mount(comp); + await expect(component).toHaveScreenshot(); + }); +}; + +test.describe('DBMainNavigation', () => { + test.use({ viewport: DEFAULT_VIEWPORT }); + testComponent(); +}); + +test.describe('DBMainNavigation component A11y', () => { + test('DBMainNavigation should not have any automatically detectable accessibility issues', async ({ + page, + mount + }) => { + await mount(comp); + const accessibilityScanResults = await new AxeBuilder({ page }) + .include('.db-main-navigation') + .analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); + }); +}); diff --git a/packages/components/src/components/main-navigation/model.ts b/packages/components/src/components/main-navigation/model.ts new file mode 100644 index 000000000000..f653576bf3ac --- /dev/null +++ b/packages/components/src/components/main-navigation/model.ts @@ -0,0 +1,13 @@ +import { GlobalProps, GlobalState, InitializedState } from '../../shared/model'; + +export interface DBMainNavigationDefaultProps {} + +export type DBMainNavigationProps = DBMainNavigationDefaultProps & GlobalProps; + +export interface DBMainNavigationDefaultState { + mainNavigationId: string; +} + +export type DBMainNavigationState = DBMainNavigationDefaultState & + GlobalState & + InitializedState; diff --git a/packages/components/src/components/navigation-item/model.ts b/packages/components/src/components/navigation-item/model.ts index cbf7071a2018..5b8d21a10104 100644 --- a/packages/components/src/components/navigation-item/model.ts +++ b/packages/components/src/components/navigation-item/model.ts @@ -6,6 +6,7 @@ import { IconAfterProps, IconProps, IconState, + InitializedState, WidthProps } from '../../shared/model'; @@ -28,6 +29,10 @@ export interface DBNavigationItemDefaultProps { * Use an icon button here for additional actions. */ action?: DBNavigationItemActionProps & IconProps & ClickEventProps; + + slotSubNavigation?: any; + + isMainMenuItem?: boolean; } export type DBNavigationItemProps = DBNavigationItemDefaultProps & @@ -39,9 +44,12 @@ export type DBNavigationItemProps = DBNavigationItemDefaultProps & export interface DBNavigationItemDefaultState { handleActionClick: (event: any) => void; + hasAreaPopup: boolean; + subNavigationId: string; } export type DBNavigationItemState = DBNavigationItemDefaultState & ClickEventState & GlobalState & - IconState; + IconState & + InitializedState; diff --git a/packages/components/src/components/navigation-item/navigation-item.lite.tsx b/packages/components/src/components/navigation-item/navigation-item.lite.tsx index 61f40bbdfb97..4f2ccd4793cb 100644 --- a/packages/components/src/components/navigation-item/navigation-item.lite.tsx +++ b/packages/components/src/components/navigation-item/navigation-item.lite.tsx @@ -1,7 +1,17 @@ -import { onMount, Show, useMetadata, useStore } from '@builder.io/mitosis'; +import { + onMount, + onUpdate, + Show, + Slot, + useMetadata, + useRef, + useStore +} from '@builder.io/mitosis'; import { DBNavigationItemState, DBNavigationItemProps } from './model'; import classNames from 'classnames'; import { DBButton } from '../button'; +import { uuid } from '../../utils'; +import { DEFAULT_ID } from '../../shared/constants'; useMetadata({ isAttachedToShadowDom: true, @@ -15,8 +25,12 @@ useMetadata({ export default function DBNavigationItem(props: DBNavigationItemProps) { // This is used as forwardRef let component: any; + // jscpd:ignore-start const state = useStore({ + initialized: false, + hasAreaPopup: false, + subNavigationId: 'sub-navigation-' + uuid(), handleClick: (event: any) => { if (props.onClick) { props.onClick(event); @@ -38,36 +52,48 @@ export default function DBNavigationItem(props: DBNavigationItemProps) { }); onMount(() => { + state.initialized = true; if (props.stylePath) { state.stylePath = props.stylePath; } }); + + onUpdate(() => { + if (state.initialized && document && state.subNavigationId) { + const subNavigationSlot = document?.getElementById( + state.subNavigationId + ) as HTMLMenuElement; + if (subNavigationSlot) { + const children = subNavigationSlot.children; + if (children?.length > 0) { + state.hasAreaPopup = true; + } + } + } + }, [state.initialized]); + // jscpd:ignore-end return ( -
- + data-width={props.width} + tabIndex={props.tabIndex || -1} + data-main-menu={props.isMainMenuItem} + data-icon={state.iconVisible(props.icon) ? props.icon : undefined} + data-icon-after={ + state.iconVisible(props.iconAfter) ? props.iconAfter : undefined + } + aria-current={props.active ? 'page' : undefined} + data-disabled={props.disabled} + onClick={(event) => state.handleClick(event)}> + + + + {props.children} + -
+ + + + + +
+ ); } diff --git a/packages/components/src/components/navigation-item/navigation-item.scss b/packages/components/src/components/navigation-item/navigation-item.scss index 90c6e6141952..b0f07040df86 100644 --- a/packages/components/src/components/navigation-item/navigation-item.scss +++ b/packages/components/src/components/navigation-item/navigation-item.scss @@ -2,29 +2,87 @@ @use "@db-ui/foundations/build/scss/variables.global" as *; @use "@db-ui/foundations/build/scss/helpers/component" as *; @use "@db-ui/foundations/build/scss/color/color-variants" as *; +@use "@db-ui/foundations/build/scss/icon/icons.helpers" as *; .db-navigation-item { - display: inline-flex; - - &[data-width="full"] { - width: 100%; - } -} - -.db-navigation-item-button { @extend %default-interactive-component; @extend %default-background-transition; - @extend %bg-transparent-interactive; @extend %transparent-border; + @include get-variant-bg-color(0); + display: inline-flex; + cursor: pointer; + + position: relative; + + font-weight: normal; border-radius: $default-card-border-radius; padding: $db-spacing-fixed-xs $db-spacing-fixed-sm; white-space: nowrap; // we don't want to break - display: inline-flex; - justify-content: center; text-align: center; align-items: center; // Centering the content vertically and horizontally + & > a { + text-decoration: none; + } + + .active-indicator { + position: absolute; + width: 0; + transition: width 0.15s $db-transition-emotional-timing; + left: 50%; + transform: translate(-50%, 0); + } + + &[data-main-menu="true"] { + &[aria-current="page"] { + & > .active-indicator { + border-bottom: $default-border-radius solid + $db-colors-primary-enabled; + width: 100%; + border-radius: $default-border-radius; + bottom: calc(-1 * $db-spacing-fixed-xs); + } + } + } + + &[aria-haspopup="true"] { + &[data-main-menu="true"] { + --icon-margin-before: auto; + @include icon(glyph(expand-more), 24, "outline", "after"); + + &:focus, + &:focus-within { + & > .db-sub-navigation { + visibility: visible; + top: 100%; + left: 0; + } + } + } + + &:not([data-main-menu="true"]) { + @include icon(glyph(chevron-right), 24, "outline", "after"); + + &:hover { + & > .db-sub-navigation { + visibility: visible; + top: calc(-100% + $db-spacing-fixed-sm); + left: 100%; + } + } + } + } + + &:hover { + @include get-variant-bg-color(0.16); + } + + &:active, + &:focus { + @include get-variant-bg-color(0.24); + } + &::before { margin-inline-end: $db-spacing-fixed-sm; } @@ -33,7 +91,7 @@ margin-inline-start: $db-spacing-fixed-sm; } - &[data-active="true"] { + &[aria-current="page"] { font-weight: 700; } @@ -45,7 +103,41 @@ } } - &:disabled { + &[data-disabled="true"] { opacity: $default-opacity; } } + +@mixin desktop-card-style { + border-radius: $default-card-border-radius; + box-shadow: $db-elevation-4; + + background-color: var( + --db-current-background-color, + $db-colors-neutral-bg-0-enabled + ); + padding: $db-spacing-fixed-sm; +} + +.db-sub-navigation { + display: flex; + flex-direction: column; + + .db-navigation-item { + width: 100%; + + &::after { + margin-inline-start: auto; + } + } + + @media screen and (min-width: $db-screens-m) { + @include desktop-card-style; + + min-width: 328px; // We should get this value from UX + + position: absolute; + visibility: hidden; + z-index: 70; + } +} diff --git a/packages/components/src/components/radio/model.ts b/packages/components/src/components/radio/model.ts index 3f61b888ce72..d3c7760c8c79 100644 --- a/packages/components/src/components/radio/model.ts +++ b/packages/components/src/components/radio/model.ts @@ -10,7 +10,7 @@ import { FormProps, FormState, FormCheckProps, - FormCheckState + InitializedState } from '../../shared/model'; export interface DBRadioDefaultProps { @@ -34,4 +34,4 @@ export type DBRadioState = DBRadioDefaultState & ChangeEventState & FocusEventState & FormState & - FormCheckState; + InitializedState; diff --git a/packages/components/src/components/tag/model.ts b/packages/components/src/components/tag/model.ts index ba9e1993d34a..55200f2ca930 100644 --- a/packages/components/src/components/tag/model.ts +++ b/packages/components/src/components/tag/model.ts @@ -10,7 +10,7 @@ import { IconProps, IconState, FormCheckProps, - FormCheckState + InitializedState } from '../../shared/model'; export interface DBTagDefaultProps { @@ -68,5 +68,5 @@ export type DBTagState = DBTagDefaultState & GlobalState & ChangeEventState & FormState & - FormCheckState & + InitializedState & IconState; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 96f70bfd1706..6906d4d980d1 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -19,3 +19,4 @@ export * from './components/code-docs'; export * from './components/select'; export * from './components/tag'; export * from './components/navigation-item'; +export * from './components/main-navigation'; diff --git a/packages/components/src/shared/model.ts b/packages/components/src/shared/model.ts index db18113a0fc4..78fdbe14ce80 100644 --- a/packages/components/src/shared/model.ts +++ b/packages/components/src/shared/model.ts @@ -140,7 +140,7 @@ export type FormState = { _value?: any; }; -export type FormCheckState = { +export type InitializedState = { initialized: boolean; }; diff --git a/packages/components/src/styles/db-ui-components.scss b/packages/components/src/styles/db-ui-components.scss index 8c38b5c4375a..97f20f9bb5c9 100644 --- a/packages/components/src/styles/db-ui-components.scss +++ b/packages/components/src/styles/db-ui-components.scss @@ -22,7 +22,12 @@ @use "../components/select/select" as *; @use "../components/navigation-item/navigation-item" as *; +@use "../components/main-navigation/main-navigation" as *; + // angular-workaround + +dbmain-navigation, +db-main-navigation, dbnavigation-item, db-navigation-item, dbtag, diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index 572275c6e38f..11174d7ca075 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -5,3 +5,25 @@ export const uuid = () => { return Math.random().toString(); }; + +export const setMainMenuToFirstListElement = (element: Element) => { + if (element.children?.length > 0) { + const children = Array.from(element.children); + const foundListElements = children.filter( + (child) => child.nodeName === 'LI' + ); + if (foundListElements.length > 0) { + foundListElements.forEach((listElement) => + listElement.setAttribute('data-main-menu', 'true') + ); + const restElements = children.filter( + (child) => child.nodeName !== 'LI' + ); + restElements.forEach((child) => + setMainMenuToFirstListElement(child) + ); + } else { + children.forEach((child) => setMainMenuToFirstListElement(child)); + } + } +}; diff --git a/showcases/angular-showcase/src/app/app.component.html b/showcases/angular-showcase/src/app/app.component.html index 998fbfbbcb2b..6f76cad9a17b 100644 --- a/showcases/angular-showcase/src/app/app.component.html +++ b/showcases/angular-showcase/src/app/app.component.html @@ -11,17 +11,21 @@ > Showcase - + + + + + + + +