feat: Improve new shopping list UI (#7600)

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Michael Genson
2026-05-10 13:15:20 -05:00
committed by GitHub
parent a242aea9f2
commit 9fd3fbca8b
6 changed files with 77 additions and 33 deletions

View File

@@ -11,18 +11,28 @@
>
<div class="d-flex flex-column ga-3">
<v-card-actions class="pa-0">
<InputLabelType
v-model="listItem.food"
v-model:item-id="listItem.foodId!"
:items="foods"
:label="rail ? $t('shopping-list.add-item') : $t('shopping-list.food')"
:icon="$globals.icons.foods"
:style="rail ? 'margin-inline: 3px;' : undefined"
:search="rail"
create
@create="createAssignFood"
@focus="rail = false"
/>
<div class="position-relative" style="flex: 1;">
<InputLabelType
ref="foodInputRef"
v-model="listItem.food"
v-model:item-id="listItem.foodId!"
:items="foods"
:label="rail ? $t('shopping-list.add-item') : $t('shopping-list.food')"
:icon="$globals.icons.foods"
:style="rail ? 'margin-inline: 3px;' : undefined"
:search="rail"
:menu-props="{ location: menuDirection }"
create
@create="createAssignFood"
/>
<!-- Intercept clicks when collapsed so the drawer expands before the autocomplete opens -->
<div
v-if="rail"
class="position-absolute"
style="inset: 0; cursor: text;"
@click="expandAndFocus"
/>
</div>
<BaseButtonGroup
v-if="!rail"
:buttons="[
@@ -84,6 +94,20 @@ defineEmits<{
const { createAssignFood } = useShoppingListItemEditor(listItem);
const { smAndDown } = useDisplay();
const menuDirection = computed(() => smAndDown.value ? "top" : "bottom");
const foodInputRef = ref<{ focus: () => void } | null>(null);
const rail = ref(true);
async function expandAndFocus() {
rail.value = false;
await nextTick();
setTimeout(() => {
foodInputRef.value?.focus();
}, 200);
}
watch(
() => listItem.value.quantity,
(newQty) => {
@@ -100,6 +124,4 @@ watch(
listItem.value.labelId = listItem.value.label?.id || null;
},
);
const rail = ref(true);
</script>

View File

@@ -11,11 +11,13 @@
>
<v-row
v-touch="{
move: ({ originalEvent: { touches: [{ screenX }] } }) => {
move: ({ originalEvent: { touches: [{ screenX, screenY }] } }) => {
swipeInfo.touchendX = screenX;
swipeInfo.touchendY = screenY;
},
start: ({ originalEvent: { touches: [{ screenX }] } }) => {
start: ({ originalEvent: { touches: [{ screenX, screenY }] } }) => {
swipeInfo.touchstartX = screenX;
swipeInfo.touchstartY = screenY;
},
end: () => {
if (swiping < SWIPE_THRESHOLD) {
@@ -212,6 +214,7 @@ const emit = defineEmits<{
}>();
const SWIPE_THRESHOLD = 50;
const SCROLL_THRESHOLD = 50;
const { isRtl } = useRtl();
const i18n = useI18n();
@@ -264,14 +267,22 @@ function save() {
edit.value = false;
}
const swipeInfo: Ref<{ touchstartX?: number; touchendX?: number }> = ref({ touchstartX: undefined, touchendX: undefined });
const swipeInfo: Ref<{ touchstartX?: number; touchendX?: number; touchstartY?: number; touchendY?: number }> = ref({});
const swiping = computed(() => {
const { touchstartX, touchendX } = swipeInfo.value ?? {};
const { touchstartX, touchendX, touchstartY, touchendY } = swipeInfo.value ?? {};
if (touchstartX === undefined || touchendX === undefined) {
return 0;
}
const delta = isRtl.value ? touchstartX - touchendX : touchendX - touchstartX;
return Math.min(Math.max(0, delta), 100);
const deltaX = isRtl.value ? touchstartX - touchendX : touchendX - touchstartX;
// If there's significant vertical movement, treat as a scroll gesture and ignore
if (touchstartY !== undefined && touchendY !== undefined) {
const deltaY = Math.abs(touchendY - touchstartY);
if (deltaY > SCROLL_THRESHOLD) {
return 0;
}
}
return Math.min(Math.max(0, deltaX), 100);
});
const recipeList = computed<RecipeSummary[]>(() => {

View File

@@ -16,6 +16,7 @@
:items="units"
:label="$t('recipe.unit')"
:icon="$globals.icons.units"
:menu-props="{ location: menuDirection }"
style="flex: 3"
create
@create="createAssignUnit"
@@ -35,6 +36,7 @@
v-model:item-id="listItem.labelId!"
:items="labels"
:label="$t('shopping-list.label')"
:menu-props="{ location: menuDirection }"
style="flex: 1 0 200px"
/>
<BaseButton
@@ -75,6 +77,9 @@ const emit = defineEmits<{ (e: "save"): void }>();
const { assignLabelToFood, createAssignUnit } = useShoppingListItemEditor(listItem);
const { smAndDown } = useDisplay();
const menuDirection = computed(() => smAndDown.value ? "top" : "bottom");
function handleNoteKeyPress(event: KeyboardEvent) {
// Save on Enter
if (!event.shiftKey && event.key === "Enter") {

View File

@@ -93,4 +93,8 @@ function emitCreate() {
emit("create", searchInput.value);
autocompleteRef.value?.blur();
}
defineExpose({
focus: () => autocompleteRef.value?.focus(),
});
</script>

View File

@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Are you sure you want to uncheck all items?",
"are-you-sure-you-want-to-delete-checked-items": "Are you sure you want to delete all checked items?",
"no-shopping-lists-found": "No Shopping Lists Found",
"item-checked-off": "{item} was checked off"
"item-checked-off": "Checked off {item}"
},
"sidebar": {
"all-recipes": "All Recipes",

View File

@@ -377,20 +377,22 @@ const { store: allUnits } = useUnitStore();
const { store: allFoods } = useFoodStore();
function itemCheckedToast(item: ShoppingListItemOut) {
alert.info(
i18n.t("shopping-list.item-checked-off", { item: item.food?.name || item.note || i18n.t("recipe.ingredient") }),
undefined,
{
timeout: 4000,
action: {
message: i18n.t("general.undo"),
onClick: () => {
item.checked = false;
shoppingListPage.saveListItem(item);
setTimeout(() => {
alert.info(
i18n.t("shopping-list.item-checked-off", { item: item.food?.name || item.note || i18n.t("recipe.ingredient") }),
undefined,
{
timeout: 4000,
action: {
message: i18n.t("general.undo"),
onClick: () => {
item.checked = false;
shoppingListPage.saveListItem(item);
},
},
},
},
);
);
}, 500);
}
const {