Unverified Commit 343b1842 authored by Björn Grüning's avatar Björn Grüning Committed by GitHub
Browse files

Merge pull request #21035 from itisAliRH/add-history-list-keyboard-navigation

[25.1] Add Keyboard Navigation to History Lists
parents eaf525a0 499595d9
Loading
Loading
Loading
Loading
+13 −1
Original line number Diff line number Diff line
@@ -176,6 +176,11 @@ interface Props {
     * @default "Last updated"
     */
    updateTimeTitle?: string;

    /** Whether this card is highlighted (for example, as a range selection anchor)
     * @default false
     */
    highlighted?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
@@ -207,6 +212,7 @@ const props = withDefaults(defineProps<Props>(), {
    updateTime: "",
    updateTimeIcon: () => faEdit,
    updateTimeTitle: "Last updated",
    highlighted: false,
});

/**
@@ -283,8 +289,10 @@ const allowedTitleLines = computed(() => props.titleNLines);

function onKeyDown(event: KeyboardEvent) {
    if ((props.clickable && event.key === "Enter") || event.key === " ") {
        event.stopPropagation();
        emit("click", event);
    } else if (props.clickable) {
        event.stopPropagation();
        emit("keydown", event);
    }
}
@@ -310,7 +318,7 @@ function onKeyDown(event: KeyboardEvent) {
        <div
            :id="`g-card-content-${props.id}`"
            class="g-card-content d-flex flex-column justify-content-between h-100 p-2"
            :class="contentClass">
            :class="[{ 'g-card-highlighted': props.highlighted }, contentClass]">
            <slot>
                <div class="d-flex flex-column flex-gapy-1">
                    <div
@@ -708,6 +716,10 @@ function onKeyDown(event: KeyboardEvent) {
        border-left: 0.25rem solid $brand-primary;
    }

    &.g-card-highlighted .g-card-content {
        box-shadow: 0 0 0 0.2rem transparentize($brand-primary, 0.75);
    }

    &.g-card-clickable {
        cursor: pointer;

+46 −1
Original line number Diff line number Diff line
@@ -93,6 +93,20 @@ interface Props {
     * @default false
     */
    sharedView?: boolean;

    /**
     * Whether the card is clickable for keyboard navigation
     * @type {boolean}
     * @default false
     */
    clickable?: boolean;

    /**
     * Whether this card is highlighted (for example, as a range selection anchor)
     * @type {boolean}
     * @default false
     */
    highlighted?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
@@ -102,6 +116,8 @@ const props = withDefaults(defineProps<Props>(), {
    selectable: false,
    selected: false,
    sharedView: false,
    clickable: false,
    highlighted: false,
});

const router = useRouter();
@@ -144,6 +160,18 @@ const emit = defineEmits<{
     * @event updateFilter
     */
    (e: "updateFilter", key: string, value: any): void;

    /**
     * Emitted when a keyboard event occurs on the history card
     * @event on-key-down
     */
    (e: "on-key-down", history: AnyHistoryEntry, event: KeyboardEvent): void;

    /**
     * Emitted when the history card is clicked
     * @event on-history-card-click
     */
    (e: "on-history-card-click", history: AnyHistoryEntry, event: Event): void;
}>();

/**
@@ -203,12 +231,25 @@ async function onTagsUpdate(historyId: string, tags: string[]) {
    await historyStore.updateHistory(historyId, { tags: tags });
    emit("refreshList", true, true);
}

function onClick(event: Event) {
    if (props.clickable) {
        emit("on-history-card-click", props.history, event);
    }
}

function onKeyDown(event: KeyboardEvent) {
    if (props.clickable) {
        emit("on-key-down", props.history, event);
    }
}
</script>

<template>
    <GCard
        :id="`history-${history.id}`"
        :key="history.id"
        class="history-card"
        :title="historyCardTitle"
        :title-badges="historyCardTitleBadges"
        :title-n-lines="2"
@@ -226,11 +267,15 @@ async function onTagsUpdate(historyId: string, tags: string[]) {
        :tags-editable="userOwnsHistory(currentUser, history)"
        :max-visible-tags="props.gridView ? 2 : 8"
        :update-time="history.update_time"
        :clickable="props.clickable"
        :highlighted="props.highlighted"
        @titleClick="onTitleClick"
        @rename="() => router.push(`/histories/rename?id=${history.id}`)"
        @select="isMyHistory(history) && emit('select', history)"
        @tagsUpdate="(tags) => onTagsUpdate(history.id, tags)"
        @tagClick="(tag) => emit('tagClick', tag)">
        @tagClick="(tag) => emit('tagClick', tag)"
        @click="onClick"
        @keydown="onKeyDown">
        <template v-if="props.archivedView && isArchivedHistory(history)" v-slot:titleActions>
            <ExportRecordDOILink :export-record-uri="history.export_record_data?.target_uri" />
        </template>
+48 −2
Original line number Diff line number Diff line
@@ -23,6 +23,8 @@
 *   @tagClick="onTagClick" />
 */

import type { Ref } from "vue";

import type { AnyHistoryEntry, MyHistory } from "@/api/histories";
import { isMyHistory } from "@/api/histories";

@@ -76,6 +78,26 @@ interface Props {
     * @default []
     */
    selectedHistoryIds?: { id: string }[];

    /**
     * Whether cards are clickable for navigation
     * @type {boolean}
     * @default false
     */
    clickable?: boolean;

    /**
     * Item refs for keyboard navigation
     * @type {Record<string, Ref<InstanceType<typeof HistoryCard> | null>>}
     * @default {}
     */
    itemRefs?: Record<string, Ref<InstanceType<typeof HistoryCard> | null>>;

    /**
     * Range select anchor for keyboard navigation
     * @type {AnyHistoryEntry | undefined}
     */
    rangeSelectAnchor?: AnyHistoryEntry;
}

const props = withDefaults(defineProps<Props>(), {
@@ -83,6 +105,9 @@ const props = withDefaults(defineProps<Props>(), {
    publishedView: false,
    selectable: false,
    selectedHistoryIds: () => [],
    clickable: false,
    itemRefs: () => ({}),
    rangeSelectAnchor: undefined,
});

/**
@@ -112,14 +137,28 @@ const emit = defineEmits<{
     * @event updateFilter
     */
    (e: "updateFilter", key: string, value: any): void;

    /**
     * Emitted when a keyboard event occurs on a history card
     * @event on-key-down
     */
    (e: "on-key-down", history: AnyHistoryEntry, event: KeyboardEvent): void;

    /**
     * Emitted when a history card is clicked
     * @event on-history-card-click
     */
    (e: "on-history-card-click", history: AnyHistoryEntry, event: Event): void;
}>();
</script>

<template>
    <div class="history-card-list d-flex flex-wrap overflow-auto">
    <div class="history-card-list d-flex flex-wrap overflow-auto pt-1">
        <HistoryCard
            v-for="history in props.histories"
            :ref="props.itemRefs[history.id]"
            :key="history.id"
            tabindex="0"
            :history="history"
            :grid-view="props.gridView"
            :shared-view="props.sharedView"
@@ -127,14 +166,21 @@ const emit = defineEmits<{
            :archived-view="props.archivedView"
            :selectable="props.selectable"
            :selected="props.selectedHistoryIds.some((selected) => selected.id === history.id)"
            :clickable="props.clickable"
            :highlighted="props.rangeSelectAnchor?.id === history.id"
            class="history-card-in-list"
            @select="isMyHistory(history) && emit('select', history)"
            @tagClick="(...args) => emit('tagClick', ...args)"
            @refreshList="(...args) => emit('refreshList', ...args)"
            @updateFilter="(...args) => emit('updateFilter', ...args)" />
            @updateFilter="(...args) => emit('updateFilter', ...args)"
            @on-key-down="(...args) => emit('on-key-down', ...args)"
            @on-history-card-click="(...args) => emit('on-history-card-click', ...args)" />
    </div>
</template>

<style lang="scss" scoped>
@import "theme/blue.scss";

.history-card-list {
    container: cards-list / inline-size;
}
+59 −26
Original line number Diff line number Diff line
@@ -32,7 +32,10 @@ import {
    getPublishedHistories,
    getSharedHistories,
} from "@/api/histories";
import type HistoryCard from "@/components/History/HistoryCard.vue";
import { useHistoryCardActions } from "@/components/History/useHistoryCardActions";
import { useConfirmDialog } from "@/composables/confirmDialog";
import { useSelectedItems } from "@/composables/selectedItems/selectedItems";
import { Toast } from "@/composables/toast";
import { useHistoryStore } from "@/stores/historyStore";
import { updateHistoryFields } from "@/stores/services/history.services";
@@ -98,7 +101,6 @@ const bulkTagsLoading = ref(false);
const bulkDeleteOrRestoreLoading = ref(false);
const bulkPurgeLoading = ref(false);
const historiesLoaded = ref<AnyHistoryEntry[]>([]);
const selectedHistories = ref<SelectedHistory[]>([]);

/** Computed property that determines if the current view is "My Histories" */
const myView = computed(() => props.activeList === "my");
@@ -150,6 +152,49 @@ const invalidFilters = computed(() => historyListFilters.value.getValidFilters(r
const isSurroundedByQuotes = computed(() => /^["'].*["']$/.test(filterText.value));
const hasInvalidFilters = computed(() => !isSurroundedByQuotes.value && Object.keys(invalidFilters.value).length > 0);

const selectedHistories = computed<SelectedHistory[]>(() => {
    const ids = Array.from(selectedItems.value.keys());
    const matchingHistories = historiesLoaded.value.filter((h) => ids.includes(h.id));
    return matchingHistories.map((h) => ({
        id: h.id,
        name: h.name,
        published: h.published,
        purged: h.purged,
    }));
});

const {
    selectedItems,
    selectAllInCurrentQuery,
    isSelected,
    setSelected,
    resetSelection,
    itemRefs,
    initSelectedItem,
    onClick,
    onKeyDown,
} = useSelectedItems<AnyHistoryEntry, typeof HistoryCard>({
    scopeKey: computed(() => `${props.activeList}-histories-${filterText.value}`),
    getItemKey: (item) => item.id,
    filterText: filterText,
    totalItemsInQuery: computed(() => totalHistories.value ?? 0),
    allItems: historiesLoaded,
    filterClass: historyListFilters.value,
    selectable: computed(() => myView.value),
    onDelete: async (item) => {
        const { onDeleteHistory } = useHistoryCardActions(
            computed(() => item),
            false,
            () => load(true),
        );
        await onDeleteHistory();
    },
    expectedKeyDownClass: "history-card",
    getAttributeForRangeSelection(item) {
        return `g-card-${item.id}`;
    },
});

/**
 * Updates a specific filter value in the current filter text
 * @param {string} filterKey - The key of the filter to update
@@ -252,16 +297,10 @@ function validatedFilterText(): string {

/**
 * Toggles selection of a specific history item
 * @param {SelectedHistory} h - The history object to toggle selection for
 * @param {AnyHistoryEntry} h - The history object to toggle selection for
 */
function onSelectHistory(h: SelectedHistory) {
    const index = selectedHistories.value.findIndex((selected) => selected.id === h.id);

    if (index === -1) {
        selectedHistories.value.push(h);
    } else {
        selectedHistories.value.splice(index, 1);
    }
function onSelectHistory(h: AnyHistoryEntry) {
    setSelected(h, !isSelected(h));
}

/**
@@ -270,14 +309,9 @@ function onSelectHistory(h: SelectedHistory) {
 */
function onSelectAllHistories() {
    if (selectedHistories.value.length === historiesLoaded.value.length) {
        selectedHistories.value = [];
        resetSelection();
    } else {
        selectedHistories.value = historiesLoaded.value.map((h) => ({
            id: h.id,
            name: h.name,
            published: h.published,
            purged: h.purged,
        }));
        selectAllInCurrentQuery();
    }
}

@@ -326,15 +360,13 @@ async function onBulkDeleteOrPurge(purge: boolean = false) {

            Toast.success(`${purge ? "Purged" : "Deleted"} ${totalSelected} histories.`);

            selectedHistories.value = [];
            resetSelection();
        } catch (e) {
            Toast.error(`Failed to ${purge ? "purge" : "delete"} some histories.`);
        } finally {
            bulkPurgeLoading.value = false;
            bulkDeleteOrRestoreLoading.value = false;

            selectedHistories.value = tmpSelected;

            await load(true);
        }
    }
@@ -374,14 +406,12 @@ async function onBulkRestore() {

            Toast.success(`Restored ${totalSelected} histories.`);

            selectedHistories.value = [];
            resetSelection();
        } catch (e) {
            Toast.error(`Failed to restore some histories.`);
        } finally {
            bulkDeleteOrRestoreLoading.value = false;

            selectedHistories.value = tmpSelected;

            await load(true);
        }
    }
@@ -423,8 +453,6 @@ async function onBulkTagsAdd(tags: string[]) {
    } finally {
        bulkTagsLoading.value = false;

        selectedHistories.value = tmpSelected;

        await load(true);
    }
}
@@ -436,7 +464,7 @@ async function onBulkTagsAdd(tags: string[]) {
watch([filterText, sortBy, sortDesc], async () => {
    offset.value = 0;

    selectedHistories.value = [];
    resetSelection();

    await load(true);
});
@@ -597,8 +625,13 @@ onMounted(async () => {
                :grid-view="currentListViewMode === 'grid'"
                :selectable="myView"
                :selected-history-ids="selectedHistories"
                :item-refs="itemRefs"
                :range-select-anchor="initSelectedItem"
                :clickable="true"
                @refreshList="load"
                @select="onSelectHistory"
                @on-key-down="onKeyDown"
                @on-history-card-click="onClick"
                @updateFilter="updateFilterValue"
                @tagClick="(tag) => updateFilterValue('tag', `'${tag}'`)" />
        </BOverlay>
+2 −1
Original line number Diff line number Diff line
@@ -47,6 +47,7 @@ export function useHistoryCardActions(
    historyCardExtraActions: CardAction[];
    historyCardSecondaryActions: CardAction[];
    historyCardPrimaryActions: ComputedRef<CardAction[]>;
    onDeleteHistory: (purge?: boolean) => Promise<void>;
} {
    const { confirm } = useConfirmDialog();

@@ -296,5 +297,5 @@ export function useHistoryCardActions(
        ];
    });

    return { historyCardExtraActions, historyCardSecondaryActions, historyCardPrimaryActions };
    return { historyCardExtraActions, historyCardSecondaryActions, historyCardPrimaryActions, onDeleteHistory };
}
Loading