aboutsummaryrefslogtreecommitdiffstats
path: root/apps/theming/src/components/AppOrderSelector.vue
diff options
context:
space:
mode:
Diffstat (limited to 'apps/theming/src/components/AppOrderSelector.vue')
-rw-r--r--apps/theming/src/components/AppOrderSelector.vue199
1 files changed, 199 insertions, 0 deletions
diff --git a/apps/theming/src/components/AppOrderSelector.vue b/apps/theming/src/components/AppOrderSelector.vue
new file mode 100644
index 00000000000..9c065211bc1
--- /dev/null
+++ b/apps/theming/src/components/AppOrderSelector.vue
@@ -0,0 +1,199 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <Fragment>
+ <div :id="statusInfoId"
+ aria-live="polite"
+ class="hidden-visually"
+ role="status">
+ {{ statusInfo }}
+ </div>
+ <ol ref="listElement" data-cy-app-order class="order-selector">
+ <AppOrderSelectorElement v-for="app,index in appList"
+ :key="`${app.id}${renderCount}`"
+ ref="selectorElements"
+ :app="app"
+ :aria-details="ariaDetails"
+ :aria-describedby="statusInfoId"
+ :is-first="index === 0 || !!appList[index - 1].default"
+ :is-last="index === value.length - 1"
+ v-on="app.default ? {} : {
+ 'move:up': () => moveUp(index),
+ 'move:down': () => moveDown(index),
+ 'update:focus': () => updateStatusInfo(index),
+ }" />
+ </ol>
+ </Fragment>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+
+import { translate as t } from '@nextcloud/l10n'
+import { useSortable } from '@vueuse/integrations/useSortable'
+import { computed, defineComponent, onUpdated, ref } from 'vue'
+import { Fragment } from 'vue-frag'
+
+import AppOrderSelectorElement from './AppOrderSelectorElement.vue'
+
+export interface IApp {
+ id: string // app id
+ icon: string // path to the icon svg
+ label: string // display name
+ default?: boolean // force app as default app
+ app?: string
+}
+
+export default defineComponent({
+ name: 'AppOrderSelector',
+ components: {
+ AppOrderSelectorElement,
+ Fragment,
+ },
+ props: {
+ /**
+ * Details like status information that need to be forwarded to the interactive elements
+ */
+ ariaDetails: {
+ type: String,
+ default: null,
+ },
+ /**
+ * List of apps to reorder
+ */
+ value: {
+ type: Array as PropType<IApp[]>,
+ required: true,
+ },
+ },
+ emits: {
+ /**
+ * Update the apps list on reorder
+ * @param value The new value of the app list
+ */
+ 'update:value': (value: IApp[]) => Array.isArray(value),
+ },
+ setup(props, { emit }) {
+ /**
+ * The Element that contains the app list
+ */
+ const listElement = ref<HTMLElement | null>(null)
+
+ /**
+ * The app list with setter that will ement the `update:value` event
+ */
+ const appList = computed({
+ get: () => props.value,
+ // Ensure the sortable.js does not mess with the default attribute
+ set: (list) => {
+ const newValue = [...list].sort((a, b) => ((b.default ? 1 : 0) - (a.default ? 1 : 0)) || list.indexOf(a) - list.indexOf(b))
+ if (newValue.some(({ id }, index) => id !== props.value[index].id)) {
+ emit('update:value', newValue)
+ } else {
+ // forceUpdate as the DOM has changed because of a drag event, but the reactive state has not -> wrong state
+ renderCount.value += 1
+ }
+ },
+ })
+
+ /**
+ * Helper to force rerender the list in case of a invalid drag event
+ */
+ const renderCount = ref(0)
+
+ /**
+ * Handle drag & drop sorting
+ */
+ useSortable(listElement, appList, { filter: '.order-selector-element--disabled' })
+
+ /**
+ * Array of all AppOrderSelectorElement components used to for keeping the focus after button click
+ */
+ const selectorElements = ref<InstanceType<typeof AppOrderSelectorElement>[]>([])
+
+ /**
+ * We use the updated hook here to verify all selector elements keep the focus on the last pressed button
+ * This is needed to be done in this component to make sure Sortable.JS has finished sorting the elements before focussing an element
+ */
+ onUpdated(() => {
+ selectorElements.value.forEach(element => element.keepFocus())
+ })
+
+ /**
+ * Handle element is moved up
+ * @param index The index of the element that is moved
+ */
+ const moveUp = (index: number) => {
+ const before = index > 1 ? props.value.slice(0, index - 1) : []
+ // skip if not possible, because of default default app
+ if (props.value[index - 1]?.default) {
+ return
+ }
+
+ const after = [props.value[index - 1]]
+ if (index < props.value.length - 1) {
+ after.push(...props.value.slice(index + 1))
+ }
+ emit('update:value', [...before, props.value[index], ...after])
+ }
+
+ /**
+ * Handle element is moved down
+ * @param index The index of the element that is moved
+ */
+ const moveDown = (index: number) => {
+ const before = index > 0 ? props.value.slice(0, index) : []
+ before.push(props.value[index + 1])
+
+ const after = index < (props.value.length - 2) ? props.value.slice(index + 2) : []
+ emit('update:value', [...before, props.value[index], ...after])
+ }
+
+ /**
+ * Additional status information to show to screen reader users for accessibility
+ */
+ const statusInfo = ref('')
+
+ /**
+ * ID to be used on the status info element
+ */
+ const statusInfoId = `sorting-status-info-${(Math.random() + 1).toString(36).substring(7)}`
+
+ /**
+ * Update the status information for the currently selected app
+ * @param index Index of the app that is currently selected
+ */
+ const updateStatusInfo = (index: number) => {
+ statusInfo.value = t('theming', 'Current selected app: {app}, position {position} of {total}', {
+ app: props.value[index].label,
+ position: index + 1,
+ total: props.value.length,
+ })
+ }
+
+ return {
+ appList,
+ listElement,
+
+ moveDown,
+ moveUp,
+
+ statusInfoId,
+ statusInfo,
+ updateStatusInfo,
+
+ renderCount,
+ selectorElements,
+ }
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.order-selector {
+ width: max-content;
+ min-width: 260px; // align with NcSelect
+}
+</style>