feat(ux): Sticky Save/Cancel buttons on forms (#38)

- Add sticky positioning to form action buttons (always visible at bottom)
- Add Ctrl+S / Cmd+S keyboard shortcut to save forms
- Create reusable useFormShortcuts hook
- Apply to all create forms: spool, filament, vendor

Fixes: https://github.com/Donkie/Spoolman/issues/274

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 00:09:06 -06:00
parent 88747307e5
commit 94612a6dd3
5 changed files with 62 additions and 3 deletions

View File

@@ -5,7 +5,7 @@ import { PlusOutlined } from "@ant-design/icons";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { FilamentImportModal } from "../../components/filamentImportModal";
import { VendorCreateModal } from "../../components/vendorCreateModal";
@@ -20,6 +20,7 @@ import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from ".
import { ExternalFilament } from "../../utils/queryExternalDB";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { useFormShortcuts } from "../../utils/useFormShortcuts";
import { getOrCreateVendorFromExternal } from "../vendors/functions";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";
@@ -83,6 +84,12 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
redirect(redirectTo);
};
// Keyboard shortcut: Ctrl+S / Cmd+S to save
const handleSaveShortcut = useCallback(() => {
handleSubmit("list");
}, []);
useFormShortcuts(handleSaveShortcut);
const { selectProps: vendorSelect } = useSelect<IVendor>({
resource: "vendor",
optionLabel: "name",

View File

@@ -5,7 +5,7 @@ import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Se
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { FilamentCreateModal } from "../../components/filamentCreateModal";
import { useSpoolmanLocations } from "../../components/otherModels";
@@ -14,6 +14,7 @@ import "../../utils/overrides.css";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { useFormShortcuts } from "../../utils/useFormShortcuts";
import { createFilamentFromExternal } from "../filaments/functions";
import { IFilament } from "../filaments/model";
import { useGetFilamentSelectOptions } from "./functions";
@@ -140,6 +141,12 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
redirect(redirectTo);
};
// Keyboard shortcut: Ctrl+S / Cmd+S to save
const handleSaveShortcut = useCallback(() => {
handleSubmit("list");
}, []);
useFormShortcuts(handleSaveShortcut);
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
useEffect(() => {

View File

@@ -4,9 +4,10 @@ import { Button, Form, Input, InputNumber, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useFormShortcuts } from "../../utils/useFormShortcuts";
import { IVendor, IVendorParsedExtras } from "./model";
dayjs.extend(utc);
@@ -41,6 +42,12 @@ export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProp
redirect(redirectTo, (values as IVendor).id);
};
// Keyboard shortcut: Ctrl+S / Cmd+S to save
const handleSaveShortcut = useCallback(() => {
handleSubmit("list");
}, []);
useFormShortcuts(handleSaveShortcut);
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
useEffect(() => {

View File

@@ -6,3 +6,15 @@
.ant-form {
max-width: 800px;
}
/* Sticky Save/Cancel buttons on forms (#38) */
.ant-card-actions {
position: sticky;
bottom: 0;
z-index: 100;
background: var(--ant-color-bg-container, #fff);
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
margin: 0 -24px -24px -24px;
padding: 12px 24px;
border-radius: 0 0 8px 8px;
}

View File

@@ -0,0 +1,26 @@
import { useEffect } from "react";
/**
* Hook to add keyboard shortcuts to forms.
* - Ctrl+S / Cmd+S: Save (calls onSave callback)
* - Escape: Cancel (calls onCancel callback if provided)
*/
export function useFormShortcuts(onSave: () => void, onCancel?: () => void) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+S or Cmd+S to save
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
onSave();
}
// Escape to cancel (if handler provided)
if (e.key === "Escape" && onCancel) {
e.preventDefault();
onCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onSave, onCancel]);
}