Added keyboard accessible shortcut menu for create modals (#457)
Some checks are pending
Docker publish rootless / build (linux/amd64) (push) Waiting to run
Docker publish rootless / build (linux/arm/v7) (push) Waiting to run
Docker publish rootless / build (linux/arm64) (push) Waiting to run
Docker publish rootless / merge (push) Blocked by required conditions
Docker publish / build (linux/amd64) (push) Waiting to run
Docker publish / build (linux/arm/v7) (push) Waiting to run
Docker publish / build (linux/arm64) (push) Waiting to run
Docker publish / merge (push) Blocked by required conditions
Update Currencies / update-currencies (push) Waiting to run

* Added quick action menu

* Ran ui:fix

* Updated quick action ui, added navigation options, translation keys

* Changed text color

* Added missing translation keys
This commit is contained in:
Corknut
2025-01-11 12:59:33 -05:00
committed by GitHub
parent 3ca10897bb
commit 55b907fac3
6 changed files with 265 additions and 8 deletions

View File

@@ -2,8 +2,15 @@
<div class="z-[999]">
<input :id="modalId" v-model="modal" type="checkbox" class="modal-toggle" />
<div class="modal modal-bottom overflow-visible sm:modal-middle">
<div class="modal-box relative overflow-auto">
<button :for="modalId" class="btn btn-circle btn-sm absolute right-2 top-2" @click="close"></button>
<div ref="modalBox" class="modal-box relative overflow-visible">
<button
v-if="props.showCloseButton"
:for="modalId"
class="btn btn-circle btn-sm absolute right-2 top-2"
@click="close"
>
</button>
<h3 class="text-lg font-bold">
<slot name="title"></slot>
@@ -30,14 +37,24 @@
type: Boolean,
default: false,
},
showCloseButton: {
type: Boolean,
default: true,
},
});
const modalBox = ref();
function escClose(e: KeyboardEvent) {
if (e.key === "Escape") {
close();
}
}
onClickOutside(modalBox, () => {
close();
});
function close() {
if (props.readonly) {
emit("cancel");

View File

@@ -20,7 +20,7 @@
<div
tabindex="0"
style="display: inline"
class="dropdown-content menu bg-base-100 z-[9999] mb-1 w-full rounded border border-gray-400 shadow"
class="dropdown-content menu z-[9999] mb-1 w-full rounded border border-gray-400 bg-base-100 shadow"
>
<div class="m-2">
<input v-model="search" placeholder="Search…" class="input input-bordered input-sm w-full" />

View File

@@ -0,0 +1,108 @@
<template>
<Combobox v-model="selectedAction">
<ComboboxInput
ref="inputBox"
class="input input-bordered mt-2 w-full"
@input="inputValue = $event.target.value"
></ComboboxInput>
<ComboboxOptions
class="card dropdown-content absolute max-h-48 w-full overflow-y-scroll rounded-lg border border-base-300 bg-base-100"
>
<ComboboxOption
v-for="(action, idx) in filteredActions"
:key="idx"
v-slot="{ active }"
:value="action"
as="template"
>
<button
class="flex w-full rounded-lg px-3 py-1.5 text-left transition-colors"
:class="{ 'bg-primary text-primary-content': active }"
>
{{ action.text }}
<kbd
v-if="action.shortcut"
class="kbd kbd-sm ml-auto"
:class="{ 'border-primary-content bg-primary': active }"
>
{{ action.shortcut }}
</kbd>
</button>
</ComboboxOption>
<div
v-if="filteredActions.length == 0"
class="w-full rounded-lg p-3 text-left transition-colors hover:bg-base-300"
>
No actions found.
</div>
</ComboboxOptions>
<ComboboxButton ref="inputBoxButton"></ComboboxButton>
</Combobox>
</template>
<script setup lang="ts">
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption, ComboboxButton } from "@headlessui/vue";
type ExposedProps = {
focused: boolean;
revealActions: () => void;
};
type QuickMenuAction = {
text: string;
action: () => void;
// A character that invokes this action instantly if pressed
shortcut?: string;
};
const props = defineProps({
modelValue: {
type: Object as PropType<QuickMenuAction>,
required: false,
default: undefined,
},
actions: {
type: Array as PropType<QuickMenuAction[]>,
required: true,
},
});
const selectedAction = useVModel(props, "modelValue");
const inputValue = ref("");
const inputBox = ref();
const inputBoxButton = ref();
const { focused: inputBoxFocused } = useFocus(inputBox);
const emit = defineEmits(["update:modelValue", "quickSelect"]);
const revealActions = () => {
unrefElement(inputBoxButton).click();
};
watch(inputBoxFocused, () => {
if (inputBoxFocused.value) revealActions();
else inputValue.value = "";
});
watch(inputValue, (val, oldVal) => {
if (!oldVal) {
const action = props.actions?.find(v => v.shortcut === val);
if (action) {
emit("quickSelect", action);
}
}
});
const filteredActions = computed(() => {
const searchTerm = inputValue.value.toLowerCase();
return (props.actions || []).filter(action => {
return action.text.toLowerCase().includes(searchTerm) || action.shortcut?.includes(searchTerm);
});
});
defineExpose({ focused: inputBoxFocused, revealActions });
export type { QuickMenuAction, ExposedProps };
</script>

View File

@@ -0,0 +1,58 @@
<template>
<BaseModal v-model="modal" :show-close-button="false">
<div class="relative">
<span class="text-neutral-400">{{ $t("components.quick_menu.shortcut_hint") }}</span>
<QuickMenuInput
ref="inputBox"
v-model="selectedAction"
:actions="props.actions || []"
@quick-select="invokeAction"
></QuickMenuInput>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import type { ExposedProps as QuickMenuInputData, QuickMenuAction } from "./Input.vue";
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
actions: {
type: Array as PropType<QuickMenuAction[]>,
required: false,
default: () => [],
},
});
const modal = useVModel(props, "modelValue");
const selectedAction = ref<QuickMenuAction>();
const inputBox = ref<QuickMenuInputData>({ focused: false, revealActions: () => {} });
const onModalOpen = useTimeoutFn(() => {
inputBox.value.focused = true;
}, 50).start;
const onModalClose = () => {
selectedAction.value = undefined;
inputBox.value.focused = false;
};
watch(modal, () => (modal.value ? onModalOpen : onModalClose)());
onStartTyping(() => {
inputBox.value.focused = true;
});
function invokeAction(action: QuickMenuAction) {
modal.value = false;
useTimeoutFn(action.action, 100).start();
}
watch(selectedAction, action => {
if (action) invokeAction(action);
});
</script>

View File

@@ -10,6 +10,7 @@
<ItemCreateModal v-model="modals.item" />
<LabelCreateModal v-model="modals.label" />
<LocationCreateModal v-model="modals.location" />
<QuickMenuModal v-model="modals.quickMenu" :actions="quickMenuActions" />
<AppToast />
<div class="drawer drawer-mobile">
<input id="my-drawer-2" v-model="drawerToggle" type="checkbox" class="drawer-toggle" />
@@ -55,7 +56,7 @@
</div>
<div class="flex flex-col bg-base-200">
<div class="mb-6">
<div class="dropdown visible w-full">
<div class="dropdown tooltip visible w-full" data-tip="Shortcut: Ctrl+`">
<label tabindex="0" class="text-no-transform btn btn-primary btn-block text-lg">
<span>
<MdiPlus class="-ml-1 mr-1" />
@@ -64,8 +65,12 @@
</label>
<ul tabindex="0" class="dropdown-content menu rounded-box w-full bg-base-100 p-2 shadow">
<li v-for="btn in dropdown" :key="btn.id">
<button @click="btn.action">
<button class="group" @click="btn.action">
{{ btn.name.value }}
<kbd v-if="btn.shortcut" class="ml-auto hidden text-neutral-400 group-hover:inline">{{
btn.shortcut
}}</kbd>
</button>
</li>
</ul>
@@ -91,9 +96,11 @@
</div>
<!-- Bottom -->
<button class="rounded-btn mx-2 mt-auto p-3 hover:bg-base-300" @click="logout">
{{ $t("global.sign_out") }}
</button>
<div class="mx-2 mt-auto flex flex-col">
<button class="rounded-btn p-3 transition-colors hover:bg-base-300" @click="logout">
{{ $t("global.sign_out") }}
</button>
</div>
</div>
</div>
</div>
@@ -136,6 +143,12 @@
const displayOutdatedWarning = computed(() => !isDev && !hasHiddenLatest.value && isOutdated.value);
const keys = useMagicKeys({
aliasMap: {
"⌃": "control_",
},
});
// Preload currency format
useFormatCurrency();
const modals = reactive({
@@ -144,6 +157,7 @@
label: false,
import: false,
outdated: displayOutdatedWarning.value,
quickMenu: false,
});
watch(displayOutdatedWarning, () => {
@@ -157,6 +171,7 @@
{
id: 0,
name: computed(() => t("menu.create_item")),
shortcut: "⌃1",
action: () => {
modals.item = true;
},
@@ -164,6 +179,7 @@
{
id: 1,
name: computed(() => t("menu.create_location")),
shortcut: "⌃2",
action: () => {
modals.location = true;
},
@@ -171,12 +187,21 @@
{
id: 2,
name: computed(() => t("menu.create_label")),
shortcut: "⌃3",
action: () => {
modals.label = true;
},
},
];
dropdown.forEach(option => {
if (option.shortcut) {
whenever(keys[option.shortcut], () => {
option.action();
});
}
});
const route = useRoute();
const drawerToggle = ref();
@@ -231,6 +256,51 @@
},
];
const quickMenuShortcut = keys.control_Backquote;
whenever(quickMenuShortcut, () => {
modals.quickMenu = true;
modals.item = false;
modals.location = false;
modals.label = false;
modals.import = false;
});
const quickMenuActions = ref(
[
{
text: computed(() => `${t("global.create")}: ${t("menu.create_item")}`),
action: () => {
modals.item = true;
},
shortcut: "1",
},
{
text: computed(() => `${t("global.create")}: ${t("menu.create_location")}`),
action: () => {
modals.location = true;
},
shortcut: "2",
},
{
text: computed(() => `${t("global.create")}: ${t("menu.create_label")}`),
action: () => {
modals.label = true;
},
shortcut: "3",
},
].concat(
nav.map(v => {
return {
text: computed(() => `${t("global.navigate")}: ${v.name.value}`),
action: () => {
navigateTo(v.to);
},
shortcut: "",
};
})
)
);
const labelStore = useLabelStore();
const locationStore = useLocationStore();

View File

@@ -92,6 +92,9 @@
"tree": {
"no_locations": "No locations available. Add new locations through the\n `<`span class=\"link-primary\"`>`Create`<`/span`>` button on the navigation bar."
}
},
"quick_menu": {
"shortcut_hint": "Use the number keys to quickly select an action."
}
},
"global": {
@@ -114,6 +117,7 @@
"locations": "Locations",
"maintenance": "Maintenance",
"name": "Name",
"navigate": "Navigate",
"password": "Password",
"read_docs": "Read the Docs",
"save": "Save",