mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2026-01-04 20:14:54 +01:00
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
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:
@@ -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");
|
||||
|
||||
@@ -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" />
|
||||
|
||||
108
frontend/components/global/QuickMenu/Input.vue
Normal file
108
frontend/components/global/QuickMenu/Input.vue
Normal 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>
|
||||
58
frontend/components/global/QuickMenu/Modal.vue
Normal file
58
frontend/components/global/QuickMenu/Modal.vue
Normal 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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user