Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add NcHeaderMenu #3489

Merged
merged 4 commits into from
Dec 6, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 347 additions & 0 deletions src/components/NcHeaderMenu/NcHeaderMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->

<docs>
This component is made to be used in the Nextcloud top header.

```
<template>
<div id="nextcloud-header">
<NcHeaderMenu id="search"
aria-label="Search">
<template #trigger>
<Magnify />
</template>
<div>
<input placeholder="Search for files, comments, contacts..." type="search" style="width: 99%;" />
<NcEmptyContent
title="Search"
description="Start typing to search">
<template #icon>
<Magnify />
</template>
</NcEmptyContent>
</div>
</NcHeaderMenu>
</div>
</template>
<script>
import Magnify from 'vue-material-design-icons/Magnify'

export default {
components: {
Magnify,
},
}
</script>
<style>
#nextcloud-header {
display: flex;
justify-content: right;
background-color: var(--color-primary);
}
</style>
```
</docs>

<template>
<div :id="id"
v-click-outside="clickOutsideConfig"
:class="{ 'header-menu--opened': opened }"
class="header-menu">
<!-- Open trigger icon -->
<a class="header-menu__trigger"
href="#"
:aria-label="ariaLabel"
:aria-controls="`header-menu-${id}`"
:aria-expanded="opened.toString()"
@click.prevent="toggleMenu">
<!-- @slot Icon trigger slot. Make sure the svg path
is at least 16px. Usually mdi icon works at 20px -->
<slot name="trigger" />
</a>

<!-- Visual triangle -->
<div v-show="opened" class="header-menu__carret" />

<!-- Menu opened content -->
<div v-show="opened"
:id="`header-menu-${id}`"
class="header-menu__wrapper"
role="menu">
<div ref="content" class="header-menu__content">
<!-- @slot Main content -->
<slot />
</div>
</div>
</div>
</template>

<script>
import { directive as ClickOutside } from 'v-click-outside'
import { createFocusTrap } from 'focus-trap'

import excludeClickOutsideClasses from '../../mixins/excludeClickOutsideClasses/index.js'
import { getTrapStack } from '../../utils/focusTrap.js'

export default {
name: 'NcHeaderMenu',

directives: {
ClickOutside,
},

mixins: [
excludeClickOutsideClasses,
],

props: {
/**
* Unique id for this menu
*/
id: {
type: String,
required: true,
},

/**
* aria-label attribute of the menu open button
*/
ariaLabel: {
type: String,
default: '',
},

/**
* Current menu open state
*/
open: {
type: Boolean,
default: false,
},
},

emits: [
'close',
'open',
'update:open',
'cancel',
],

data() {
return {
focusTrap: null,
opened: this.open,
shortcutsDisabled: window.OCP?.Accessibility?.disableKeyboardShortcuts?.(),

clickOutsideConfig: {
handler: this.closeMenu,
middleware: this.clickOutsideMiddleware,
},
}
},

watch: {
open(open) {
if (open) {
this.openMenu()
} else {
this.closeMenu()
}
},
},

mounted() {
document.addEventListener('keydown', this.onKeyDown)
},
beforeDestroy() {
document.removeEventListener('keydown', this.onKeyDown)
},

methods: {
/**
* Toggle the current menu open state
*/
toggleMenu() {
// Toggling current state
if (!this.opened) {
this.openMenu()
} else {
this.closeMenu()
}
},

/**
* Close the current menu
*
* @param {boolean} cancelled emit a cancel event instead of close
*/
closeMenu(cancelled = false) {
// Close the menu
this.opened = false
this.$emit(cancelled ? 'cancel' : 'close')
this.$emit('update:open', false)

// Kill focus trap
this.clearFocusTrap()

// Wait for component to finish rendering
this.$nextTick(() => {
this.$emit('closed')
})
},

/**
* Open the current menu
*/
openMenu() {
// Open the menu
this.opened = true
this.$emit('open')
this.$emit('update:open', true)

// Wait for component to finish rendering
this.$nextTick(() => {
skjnldsv marked this conversation as resolved.
Show resolved Hide resolved
this.useFocusTrap()
skjnldsv marked this conversation as resolved.
Show resolved Hide resolved
this.$emit('opened')
})
},

onKeyDown(event) {
if (this.shortcutsDisabled || !this.opened) {
return
}

// If escape have been pressed, we close
if (event.key === 'Escape') {
event.preventDefault()

/** User cancelled the menu by pressing escape */
this.closeMenu(true)
}
},

/**
* Add focus trap for accessibility.
*/
async useFocusTrap() {
// wait until all children are mounted and available
// in the DOM before focusTrap can be added
await this.$nextTick()

if (this.focusTrap) {
return
}
// Init focus trap
const contentContainer = this.$refs.content
this.focusTrap = createFocusTrap(contentContainer, {
allowOutsideClick: true,
trapStack: getTrapStack(),
})
this.focusTrap.activate()
},
clearFocusTrap() {
this.focusTrap?.deactivate()
this.focusTrap = null
},
},
}
</script>

<style lang="scss" scoped>
// content inner and outer margin
// Also used for menu top-right positioning
$externalMargin: 8px;

.header-menu {
position: relative;
width: var(--header-height);
height: var(--header-height);

&__trigger {
display: flex;
align-items: center;
justify-content: center;
width: var(--header-height);
height: var(--header-height);
margin: 0;
padding: 0;
cursor: pointer;
opacity: .85;

// header is filled with primary or image background
filter: var(--background-image-invert-if-bright);
color: #fff !important;
}

&--opened &__trigger,
&__trigger:hover,
&__trigger:focus,
&__trigger:active {
opacity: 1;
}

&__trigger:focus-visible {
outline: none;
}

&__wrapper {
position: fixed;
z-index: 2000;
top: 50px;
right: 0;
box-sizing: border-box;
margin: 0 $externalMargin;
padding: 8px;
border-radius: 0 0 var(--border-radius) var(--border-radius);
border-radius: var(--border-radius-large);
background-color: var(--color-main-background);

filter: drop-shadow(0 1px 5px var(--color-box-shadow));
}

&__carret {
position: absolute;
z-index: 2001; // Because __wrapper is 2000.
bottom: 0;
left: calc(50% - 10px);
width: 0;
height: 0;
content: ' ';
pointer-events: none;
border: 10px solid transparent;
border-bottom-color: var(--color-main-background);
}

&__content {
overflow: auto;
width: 350px;
max-width: calc(100vw - 2 * $externalMargin);
min-height: calc(44px * 1.5);
max-height: calc(100vh - 50px * 2);
:deep(.empty-content) {
margin: 12vh 10px;
}
}
}

</style>
23 changes: 23 additions & 0 deletions src/components/NcHeaderMenu/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

export { default } from './NcHeaderMenu.vue'
1 change: 1 addition & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export { default as NcDateTimePickerNative } from './NcDateTimePickerNative/inde
export { default as NcEmojiPicker } from './NcEmojiPicker/index.js'
export { default as NcEmptyContent } from './NcEmptyContent/index.js'
export { default as NcGuestContent } from './NcGuestContent/index.js'
export { default as NcHeaderMenu } from './NcHeaderMenu/index.js'
// Not exported on purpose
// export { default as NcInputField } from './NcInputField/index.js'
export { default as NcListItem } from './NcListItem/index.js'
Expand Down