You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

AppMenu.vue 7.1KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. <!--
  2. - @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net>
  3. -
  4. - @author Julius Härtl <jus@bitgrid.net>
  5. -
  6. - @license GNU AGPL version 3 or any later version
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -->
  21. <template>
  22. <nav class="app-menu"
  23. :aria-label="t('core', 'Applications menu')">
  24. <ul class="app-menu-main">
  25. <li v-for="app in mainAppList"
  26. :key="app.id"
  27. :data-app-id="app.id"
  28. class="app-menu-entry"
  29. :class="{ 'app-menu-entry__active': app.active }">
  30. <a :href="app.href"
  31. :class="{ 'has-unread': app.unread > 0 }"
  32. :aria-label="appLabel(app)"
  33. :title="app.name"
  34. :aria-current="app.active ? 'page' : false"
  35. :target="app.target ? '_blank' : undefined"
  36. :rel="app.target ? 'noopener noreferrer' : undefined">
  37. <img :src="app.icon" alt="">
  38. <div class="app-menu-entry--label">
  39. {{ app.name }}
  40. <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
  41. </div>
  42. </a>
  43. </li>
  44. </ul>
  45. <NcActions class="app-menu-more" :aria-label="t('core', 'More apps')">
  46. <NcActionLink v-for="app in popoverAppList"
  47. :key="app.id"
  48. :aria-label="appLabel(app)"
  49. :aria-current="app.active ? 'page' : false"
  50. :href="app.href"
  51. class="app-menu-popover-entry">
  52. <template #icon>
  53. <div class="app-icon" :class="{ 'has-unread': app.unread > 0 }">
  54. <img :src="app.icon" alt="">
  55. </div>
  56. </template>
  57. {{ app.name }}
  58. <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
  59. </NcActionLink>
  60. </NcActions>
  61. </nav>
  62. </template>
  63. <script>
  64. import { loadState } from '@nextcloud/initial-state'
  65. import { subscribe, unsubscribe } from '@nextcloud/event-bus'
  66. import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
  67. import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
  68. export default {
  69. name: 'AppMenu',
  70. components: {
  71. NcActions, NcActionLink,
  72. },
  73. data() {
  74. return {
  75. apps: loadState('core', 'apps', {}),
  76. appLimit: 0,
  77. observer: null,
  78. }
  79. },
  80. computed: {
  81. appList() {
  82. return Object.values(this.apps)
  83. },
  84. mainAppList() {
  85. return this.appList.slice(0, this.appLimit)
  86. },
  87. popoverAppList() {
  88. return this.appList.slice(this.appLimit)
  89. },
  90. appLabel() {
  91. return (app) => app.name
  92. + (app.active ? ' (' + t('core', 'Currently open') + ')' : '')
  93. + (app.unread > 0 ? ' (' + n('core', '{count} notification', '{count} notifications', app.unread, { count: app.unread }) + ')' : '')
  94. },
  95. },
  96. mounted() {
  97. this.observer = new ResizeObserver(this.resize)
  98. this.observer.observe(this.$el)
  99. this.resize()
  100. subscribe('nextcloud:app-menu.refresh', this.setApps)
  101. },
  102. beforeDestroy() {
  103. this.observer.disconnect()
  104. unsubscribe('nextcloud:app-menu.refresh', this.setApps)
  105. },
  106. methods: {
  107. setNavigationCounter(id, counter) {
  108. this.$set(this.apps[id], 'unread', counter)
  109. },
  110. setApps({ apps }) {
  111. this.apps = apps
  112. },
  113. resize() {
  114. const availableWidth = this.$el.offsetWidth
  115. let appCount = Math.floor(availableWidth / 50) - 1
  116. const popoverAppCount = this.appList.length - appCount
  117. if (popoverAppCount === 1) {
  118. appCount--
  119. }
  120. if (appCount < 1) {
  121. appCount = 0
  122. }
  123. this.appLimit = appCount
  124. },
  125. },
  126. }
  127. </script>
  128. <style lang="scss" scoped>
  129. $header-icon-size: 20px;
  130. .app-menu {
  131. width: 100%;
  132. display: flex;
  133. flex-shrink: 1;
  134. flex-wrap: wrap;
  135. }
  136. .app-menu-main {
  137. display: flex;
  138. flex-wrap: nowrap;
  139. .app-menu-entry {
  140. width: 50px;
  141. height: 50px;
  142. position: relative;
  143. display: flex;
  144. &.app-menu-entry__active {
  145. opacity: 1;
  146. &::before {
  147. content: " ";
  148. position: absolute;
  149. pointer-events: none;
  150. border-bottom-color: var(--color-main-background);
  151. transform: translateX(-50%);
  152. width: 12px;
  153. height: 5px;
  154. border-radius: 3px;
  155. background-color: var(--color-primary-text);
  156. left: 50%;
  157. bottom: 6px;
  158. display: block;
  159. transition: all 0.1s ease-in-out;
  160. opacity: 1;
  161. }
  162. .app-menu-entry--label {
  163. font-weight: bold;
  164. }
  165. }
  166. a {
  167. width: calc(100% - 4px);
  168. height: calc(100% - 4px);
  169. margin: 2px;
  170. // this is shown directly on the background which has `color-primary`, so we need `color-primary-text`
  171. color: var(--color-primary-text);
  172. position: relative;
  173. }
  174. img {
  175. transition: margin 0.1s ease-in-out;
  176. width: $header-icon-size;
  177. height: $header-icon-size;
  178. padding: calc((100% - $header-icon-size) / 2);
  179. box-sizing: content-box;
  180. filter: var(--background-image-invert-if-bright);
  181. }
  182. .app-menu-entry--label {
  183. opacity: 0;
  184. position: absolute;
  185. font-size: 12px;
  186. // this is shown directly on the background which has `color-primary`, so we need `color-primary-text`
  187. color: var(--color-primary-text);
  188. text-align: center;
  189. left: 50%;
  190. top: 45%;
  191. display: block;
  192. min-width: 100%;
  193. transform: translateX(-50%);
  194. transition: all 0.1s ease-in-out;
  195. width: 100%;
  196. text-overflow: ellipsis;
  197. overflow: hidden;
  198. letter-spacing: -0.5px;
  199. }
  200. &:hover,
  201. &:focus-within {
  202. opacity: 1;
  203. .app-menu-entry--label {
  204. opacity: 1;
  205. font-weight: bolder;
  206. bottom: 0;
  207. width: 100%;
  208. text-overflow: ellipsis;
  209. overflow: hidden;
  210. }
  211. }
  212. }
  213. // Show labels
  214. &:hover,
  215. &:focus-within,
  216. .app-menu-entry:hover,
  217. .app-menu-entry:focus {
  218. opacity: 1;
  219. img {
  220. margin-top: -8px;
  221. }
  222. .app-menu-entry--label {
  223. opacity: 1;
  224. bottom: 0;
  225. }
  226. &::before, .app-menu-entry::before {
  227. opacity: 0;
  228. }
  229. }
  230. }
  231. ::v-deep .app-menu-more .button-vue--vue-tertiary {
  232. opacity: .7;
  233. margin: 3px;
  234. filter: var(--background-image-invert-if-bright);
  235. /* Remove all background and align text color if not expanded */
  236. &:not([aria-expanded="true"]) {
  237. color: var(--color-primary-element-text);
  238. &:hover {
  239. opacity: 1;
  240. background-color: transparent !important;
  241. }
  242. }
  243. &:focus-visible {
  244. opacity: 1;
  245. outline: none !important;
  246. }
  247. }
  248. .app-menu-popover-entry {
  249. .app-icon {
  250. position: relative;
  251. height: 44px;
  252. width: 48px;
  253. display: flex;
  254. align-items: center;
  255. justify-content: center;
  256. /* Icons are bright so invert them if bright color theme == bright background is used */
  257. filter: var(--background-invert-if-bright);
  258. &.has-unread::after {
  259. background-color: var(--color-main-text);
  260. }
  261. img {
  262. width: $header-icon-size;
  263. height: $header-icon-size;
  264. }
  265. }
  266. }
  267. .has-unread::after {
  268. content: "";
  269. width: 8px;
  270. height: 8px;
  271. background-color: var(--color-primary-element-text);
  272. border-radius: 50%;
  273. position: absolute;
  274. display: block;
  275. top: 10px;
  276. right: 10px;
  277. }
  278. .unread-counter {
  279. display: none;
  280. }
  281. </style>