Przeglądaj źródła

Save with Ctrl+S on settings pages, dialogs, inspections and the designers (#437)

Bernt Christian Egeland 17 godzin temu
rodzic
commit
88ac18ed0c
56 zmienionych plików z 391 dodań i 54 usunięć
  1. 165 0
      src/__tests__/components/save-shortcut-listener.test.tsx
  2. 1 1
      src/app/(authenticated)/customers/[id]/customer-detail-client.tsx
  3. 2 0
      src/app/(authenticated)/layout.tsx
  4. 1 1
      src/app/(authenticated)/settings/account/account-settings.tsx
  5. 3 3
      src/app/(authenticated)/settings/alerts/alerts-settings.tsx
  6. 1 1
      src/app/(authenticated)/settings/company/company-settings.tsx
  7. 1 1
      src/app/(authenticated)/settings/inspection-reminders/inspection-reminder-settings.tsx
  8. 5 1
      src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx
  9. 1 1
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  10. 1 1
      src/app/(authenticated)/settings/localization/localization-settings.tsx
  11. 1 1
      src/app/(authenticated)/settings/maintenance/maintenance-settings.tsx
  12. 1 1
      src/app/(authenticated)/settings/payment/payment-settings.tsx
  13. 1 0
      src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx
  14. 1 1
      src/app/(authenticated)/settings/tax/tax-settings.tsx
  15. 6 1
      src/app/(authenticated)/settings/team/team-settings.tsx
  16. 2 2
      src/app/(authenticated)/settings/templates/template-settings.tsx
  17. 1 1
      src/app/(authenticated)/settings/tire-hotel/tire-hotel-settings.tsx
  18. 1 1
      src/app/(authenticated)/settings/warranty/warranty-settings.tsx
  19. 1 1
      src/app/(authenticated)/settings/webhooks/webhooks-settings.tsx
  20. 1 1
      src/app/(authenticated)/settings/workshop/workshop-settings.tsx
  21. 1 0
      src/app/(authenticated)/tire-hotel/storage/storage-client.tsx
  22. 2 0
      src/app/(designer)/layout.tsx
  23. 90 0
      src/components/save-shortcut-listener.tsx
  24. 1 1
      src/features/admin/Components/admin-settings.tsx
  25. 1 1
      src/features/billing/Components/RecurringInvoicesClient.tsx
  26. 1 0
      src/features/condition-map/Components/MarkTypeSettings.tsx
  27. 5 1
      src/features/custom-fields/Components/CustomFieldsManager.tsx
  28. 1 1
      src/features/customers/Components/CustomerForm.tsx
  29. 5 1
      src/features/dashboard/Components/CustomCardDialog.tsx
  30. 9 1
      src/features/email/Components/EmailDesigner.tsx
  31. 9 0
      src/features/inspections/Components/InspectionPageClient.tsx
  32. 5 1
      src/features/inspections/Components/NewInspectionDialog.tsx
  33. 1 1
      src/features/inspections/Components/TemplateForm.tsx
  34. 1 1
      src/features/inventory/Components/InventoryPartForm.tsx
  35. 9 1
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  36. 1 1
      src/features/labor-presets/Components/LaborPresetForm.tsx
  37. 7 1
      src/features/portal/Components/CustomerPortalSettings.tsx
  38. 1 1
      src/features/quotes/Components/NewQuoteDialog.tsx
  39. 1 1
      src/features/signatures/Components/SignatureCard.tsx
  40. 6 1
      src/features/time-tracking/Components/TimeEntryDialog.tsx
  41. 1 1
      src/features/tire-hotel/Components/CheckInDialog.tsx
  42. 1 1
      src/features/tire-hotel/Components/EditTireSetDialog.tsx
  43. 15 3
      src/features/tire-hotel/Components/LocationFormDialog.tsx
  44. 5 1
      src/features/tire-hotel/Components/NewTireJobDialog.tsx
  45. 1 1
      src/features/tire-hotel/Components/RelocateDialog.tsx
  46. 1 1
      src/features/tire-hotel/Components/TreatmentCard.tsx
  47. 1 1
      src/features/vehicles/Components/FindingForm.tsx
  48. 1 1
      src/features/vehicles/Components/NoteForm.tsx
  49. 1 1
      src/features/vehicles/Components/ReminderForm.tsx
  50. 1 1
      src/features/vehicles/Components/ReminderFormDialog.tsx
  51. 1 1
      src/features/vehicles/Components/ServiceForm.tsx
  52. 1 1
      src/features/vehicles/Components/VehicleForm.tsx
  53. 1 1
      src/features/work-order-statuses/Components/WorkOrderStatusSettings.tsx
  54. 1 1
      src/features/workboard/Components/TechnicianDialog.tsx
  55. 1 1
      src/features/workboard/Components/WorkBayDialog.tsx
  56. 4 2
      src/hooks/use-save-shortcut.ts

+ 165 - 0
src/__tests__/components/save-shortcut-listener.test.tsx

@@ -0,0 +1,165 @@
+import { render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SaveShortcutListener } from '@/components/save-shortcut-listener'
+import { useSaveShortcut } from '@/hooks/use-save-shortcut'
+
+// jsdom lays nothing out, so every element reports no rects; the listener
+// reads that as hidden.
+const visible = () => [{}] as unknown as DOMRectList
+
+function pressSave() {
+  const event = new KeyboardEvent('keydown', {
+    key: 's',
+    ctrlKey: true,
+    bubbles: true,
+    cancelable: true,
+  })
+  ;(document.activeElement ?? document.body).dispatchEvent(event)
+  vi.runAllTimers()
+  return event
+}
+
+function Editor({ onSave }: { onSave: () => void }) {
+  useSaveShortcut(onSave)
+  return null
+}
+
+describe('SaveShortcutListener', () => {
+  beforeEach(() => {
+    vi.useFakeTimers()
+    vi.spyOn(HTMLElement.prototype, 'getClientRects').mockImplementation(visible)
+  })
+  afterEach(() => {
+    vi.useRealTimers()
+    vi.restoreAllMocks()
+  })
+
+  it('presses the marked save button on the page', () => {
+    const save = vi.fn()
+    render(
+      <>
+        <SaveShortcutListener />
+        <button type="button" data-save-shortcut onClick={save}>
+          Save
+        </button>
+      </>
+    )
+    const event = pressSave()
+    expect(save).toHaveBeenCalledTimes(1)
+    expect(event.defaultPrevented).toBe(true)
+  })
+
+  it('leaves the key alone when nothing on the page saves', () => {
+    const send = vi.fn()
+    render(
+      <>
+        <SaveShortcutListener />
+        <button type="button" onClick={send}>
+          Send
+        </button>
+      </>
+    )
+    const event = pressSave()
+    expect(send).not.toHaveBeenCalled()
+    expect(event.defaultPrevented).toBe(false)
+  })
+
+  it('saves the card being typed in, and gives the field its focus back', () => {
+    const first = vi.fn()
+    const second = vi.fn()
+    const { getByTestId } = render(
+      <>
+        <SaveShortcutListener />
+        <section>
+          <button type="button" data-save-shortcut onClick={first}>
+            Save
+          </button>
+        </section>
+        <section>
+          <input data-testid="field" />
+          <button type="button" data-save-shortcut onClick={second}>
+            Save
+          </button>
+        </section>
+      </>
+    )
+    const field = getByTestId('field')
+    field.focus()
+    pressSave()
+    expect(first).not.toHaveBeenCalled()
+    expect(second).toHaveBeenCalledTimes(1)
+    expect(document.activeElement).toBe(field)
+  })
+
+  it('skips a disabled save button', () => {
+    const save = vi.fn()
+    render(
+      <>
+        <SaveShortcutListener />
+        <button type="button" data-save-shortcut disabled onClick={save}>
+          Save
+        </button>
+      </>
+    )
+    expect(pressSave().defaultPrevented).toBe(false)
+    expect(save).not.toHaveBeenCalled()
+  })
+
+  it('saves the open dialog rather than the editor or the page under it', () => {
+    const page = vi.fn()
+    const editor = vi.fn()
+    const dialog = vi.fn()
+    render(
+      <>
+        <SaveShortcutListener />
+        <Editor onSave={editor} />
+        <button type="button" data-save-shortcut onClick={page}>
+          Save
+        </button>
+        <div role="dialog" data-state="open" data-slot="dialog-content">
+          <button type="button" data-save-shortcut onClick={dialog}>
+            Save
+          </button>
+        </div>
+      </>
+    )
+    pressSave()
+    expect(dialog).toHaveBeenCalledTimes(1)
+    expect(editor).not.toHaveBeenCalled()
+    expect(page).not.toHaveBeenCalled()
+  })
+
+  it('lets the editor save first and does not press a page button as well', () => {
+    const page = vi.fn()
+    const editor = vi.fn()
+    render(
+      <>
+        <SaveShortcutListener />
+        <Editor onSave={editor} />
+        <button type="button" data-save-shortcut onClick={page}>
+          Save
+        </button>
+      </>
+    )
+    pressSave()
+    expect(editor).toHaveBeenCalledTimes(1)
+    expect(page).not.toHaveBeenCalled()
+  })
+
+  it('does not press a page button behind a dialog that has nothing to save', () => {
+    const page = vi.fn()
+    render(
+      <>
+        <SaveShortcutListener />
+        <button type="button" data-save-shortcut onClick={page}>
+          Save
+        </button>
+        <div role="dialog" data-state="open" data-slot="dialog-content">
+          <button type="button">Send</button>
+        </div>
+      </>
+    )
+    pressSave()
+    expect(page).not.toHaveBeenCalled()
+  })
+})

+ 1 - 1
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -1214,7 +1214,7 @@ function ServiceRequestCard({
                 onChange={(e) => setNotes(e.target.value)}
                 rows={2}
               />
-              <Button size="sm" onClick={handleSaveNotes} disabled={isPending}>
+              <Button data-save-shortcut size="sm" onClick={handleSaveNotes} disabled={isPending}>
                 {isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
                 {t('saveNotes')}
               </Button>

+ 2 - 0
src/app/(authenticated)/layout.tsx

@@ -46,6 +46,7 @@ import { addZonedDays, safeTimeZone, startOfZonedDay } from '@/lib/timezone'
 import { technicianIdsForUser } from '@/features/time-tracking/Lib/timeEntries'
 import { TimeClockProvider } from '@/features/time-tracking/Components/TimeClockProvider'
 import { RealtimeProvider } from '@/features/realtime/RealtimeProvider'
+import { SaveShortcutListener } from '@/components/save-shortcut-listener'
 
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
@@ -328,6 +329,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
                           channels the remaining pages still read. */}
                         <RealtimeProvider>
                           <TimeClockProvider technicianIds={technicianIds}>
+                            <SaveShortcutListener />
                             <AppSidebar
                               companyLogo={data.companyLogo}
                               organizations={data.organizations}

+ 1 - 1
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -350,7 +350,7 @@ export function AccountSettings({
         </div>
         <Separator />
         <div className="flex items-center gap-3">
-          <Button onClick={handleUpdateProfile} disabled={savingProfile}>
+          <Button data-save-shortcut onClick={handleUpdateProfile} disabled={savingProfile}>
             {savingProfile ? (
               <Loader2 className="mr-2 h-4 w-4 animate-spin" />
             ) : (

+ 3 - 3
src/app/(authenticated)/settings/alerts/alerts-settings.tsx

@@ -84,7 +84,7 @@ function TechnicianStatusAlerts({ settings }: { settings: Record<string, string>
       {/* Hides the button entirely for a member who may not edit settings,
           the same as every other card here. */}
       <SaveButton>
-        <Button onClick={handleSave} disabled={saving}>
+        <Button data-save-shortcut onClick={handleSave} disabled={saving}>
           {saving ? (
             <Loader2 className="mr-2 h-4 w-4 animate-spin" />
           ) : (
@@ -173,7 +173,7 @@ function ServiceRequestAlertCard({ settings }: { settings: Record<string, string
       )}
 
       <SaveButton>
-        <Button onClick={handleSave} disabled={saving}>
+        <Button data-save-shortcut onClick={handleSave} disabled={saving}>
           {saving ? (
             <Loader2 className="mr-2 h-4 w-4 animate-spin" />
           ) : (
@@ -337,7 +337,7 @@ function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
 
       <SaveButton>
         <div className="flex flex-wrap gap-2">
-          <Button onClick={handleSave} disabled={saving}>
+          <Button data-save-shortcut onClick={handleSave} disabled={saving}>
             {saving ? (
               <Loader2 className="mr-2 h-4 w-4 animate-spin" />
             ) : (

+ 1 - 1
src/app/(authenticated)/settings/company/company-settings.tsx

@@ -260,7 +260,7 @@ export function CompanySettings({
         </AppCard>
         <SaveButton>
           <div className="flex items-center justify-between">
-            <Button size="sm" onClick={handleSave} disabled={saving}>
+            <Button data-save-shortcut size="sm" onClick={handleSave} disabled={saving}>
               {saving ? (
                 <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
               ) : (

+ 1 - 1
src/app/(authenticated)/settings/inspection-reminders/inspection-reminder-settings.tsx

@@ -167,7 +167,7 @@ export function InspectionReminderSettings({
           </div>
 
           <div className="flex justify-end">
-            <Button onClick={handleSave} disabled={saving}>
+            <Button data-save-shortcut onClick={handleSave} disabled={saving}>
               {saving ? (
                 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
               ) : (

+ 5 - 1
src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx

@@ -777,7 +777,11 @@ function ConnectForm({
       <div className="flex flex-wrap gap-2">
         {manifest.auth.type === 'oauth2' ? (
           needsTenantApp ? (
-            <Button onClick={saveAndConnect} disabled={busy !== null || !tenantComplete}>
+            <Button
+              data-save-shortcut
+              onClick={saveAndConnect}
+              disabled={busy !== null || !tenantComplete}
+            >
               {busy === 'credentials' ? (
                 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
               ) : (

+ 1 - 1
src/app/(authenticated)/settings/invoice/invoice-settings.tsx

@@ -734,7 +734,7 @@ export function InvoiceSettings({
 
             <SaveButton>
               <div className="flex items-center gap-3">
-                <Button onClick={handleSaveGeneral} disabled={saving}>
+                <Button data-save-shortcut onClick={handleSaveGeneral} disabled={saving}>
                   {saving ? (
                     <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                   ) : (

+ 1 - 1
src/app/(authenticated)/settings/localization/localization-settings.tsx

@@ -561,7 +561,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       {/* Save */}
       <SaveButton>
         <div className="flex items-center gap-3">
-          <Button onClick={handleSave} disabled={saving}>
+          <Button data-save-shortcut onClick={handleSave} disabled={saving}>
             {saving ? (
               <Loader2 className="mr-2 h-4 w-4 animate-spin" />
             ) : (

+ 1 - 1
src/app/(authenticated)/settings/maintenance/maintenance-settings.tsx

@@ -102,7 +102,7 @@ export function MaintenanceSettings({ settings }: { settings: Record<string, str
           <SaveButton>
             <Separator />
             <div className="flex items-center gap-3">
-              <Button onClick={handleSave} disabled={saving}>
+              <Button data-save-shortcut onClick={handleSave} disabled={saving}>
                 {saving ? (
                   <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                 ) : (

+ 1 - 1
src/app/(authenticated)/settings/payment/payment-settings.tsx

@@ -213,7 +213,7 @@ export function PaymentSettings({
           <SaveButton>
             <Separator />
             <div className="flex items-center gap-3">
-              <Button onClick={handleSave} disabled={saving}>
+              <Button data-save-shortcut onClick={handleSave} disabled={saving}>
                 {saving ? (
                   <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                 ) : (

+ 1 - 0
src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx

@@ -352,6 +352,7 @@ function ScheduleForm({
           {t('cancel')}
         </Button>
         <Button
+          data-save-shortcut
           onClick={handleSave}
           disabled={isPending || sections.length === 0 || recipients.length === 0}
         >

+ 1 - 1
src/app/(authenticated)/settings/tax/tax-settings.tsx

@@ -453,7 +453,7 @@ export function TaxSettings({
         <SaveButton>
           <Separator />
           <div className="flex items-center gap-3">
-            <Button onClick={handleSave} disabled={saving}>
+            <Button data-save-shortcut onClick={handleSave} disabled={saving}>
               {saving ? (
                 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
               ) : (

+ 6 - 1
src/app/(authenticated)/settings/team/team-settings.tsx

@@ -953,7 +953,12 @@ export function TeamSettings({
                 </div>
               )}
               <div className="flex gap-2">
-                <Button onClick={handleSaveRole} disabled={loading || !roleName.trim()} size="sm">
+                <Button
+                  data-save-shortcut
+                  onClick={handleSaveRole}
+                  disabled={loading || !roleName.trim()}
+                  size="sm"
+                >
                   {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                   {editingRole ? t('team.updateRole') : t('team.createRole')}
                 </Button>

+ 2 - 2
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -780,7 +780,7 @@ export function TemplateSettings({
           </ReadOnlyWrapper>
           <SaveButton>
             <div className="flex justify-end">
-              <Button onClick={handleSave} disabled={saving}>
+              <Button data-save-shortcut onClick={handleSave} disabled={saving}>
                 {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {t('templates.saveSmsTemplates')}
               </Button>
@@ -817,7 +817,7 @@ export function TemplateSettings({
 
           <SaveButton>
             <div className="flex justify-end">
-              <Button onClick={handleSave} disabled={saving}>
+              <Button data-save-shortcut onClick={handleSave} disabled={saving}>
                 {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {t(
                   tab === 'invoice'

+ 1 - 1
src/app/(authenticated)/settings/tire-hotel/tire-hotel-settings.tsx

@@ -259,7 +259,7 @@ export function TireHotelSettings({ settings }: { settings: Record<string, strin
           )}
 
           <SaveButton>
-            <Button onClick={handleSave} disabled={saving}>
+            <Button data-save-shortcut onClick={handleSave} disabled={saving}>
               {saving ? (
                 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
               ) : (

+ 1 - 1
src/app/(authenticated)/settings/warranty/warranty-settings.tsx

@@ -199,7 +199,7 @@ export function WarrantySettings({ settings }: { settings: Record<string, string
           <SaveButton>
             <Separator />
             <div className="flex items-center gap-3">
-              <Button onClick={handleSave} disabled={saving}>
+              <Button data-save-shortcut onClick={handleSave} disabled={saving}>
                 {saving ? (
                   <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                 ) : (

+ 1 - 1
src/app/(authenticated)/settings/webhooks/webhooks-settings.tsx

@@ -714,7 +714,7 @@ function WebhookFormDialog({
           <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
             {t('cancel')}
           </Button>
-          <Button onClick={submit} disabled={isPending}>
+          <Button data-save-shortcut onClick={submit} disabled={isPending}>
             {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
             {editing ? t('save') : t('create')}
           </Button>

+ 1 - 1
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -568,7 +568,7 @@ export function WorkshopSettings({
           <SaveButton>
             <Separator />
             <div className="flex items-center gap-3">
-              <Button onClick={handleSave} disabled={saving}>
+              <Button data-save-shortcut onClick={handleSave} disabled={saving}>
                 {saving ? (
                   <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                 ) : (

+ 1 - 0
src/app/(authenticated)/tire-hotel/storage/storage-client.tsx

@@ -360,6 +360,7 @@ export function StorageClient({
               {t('common.cancel')}
             </Button>
             <Button
+              data-save-shortcut
               onClick={handleCreateWarehouse}
               disabled={savingWarehouse || !warehouseName.trim()}
             >

+ 2 - 0
src/app/(designer)/layout.tsx

@@ -1,3 +1,4 @@
+import { SaveShortcutListener } from '@/components/save-shortcut-listener'
 import { redirect } from 'next/navigation'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getCachedMembership } from '@/lib/cached-session'
@@ -43,6 +44,7 @@ export default async function DesignerLayout({ children }: { children: React.Rea
     <ServiceTypeProvider serviceType={data.serviceType ?? 'automotive'}>
       <TypeKeyProvider enabled={data.typeKeyEnabled}>
         <ConfirmProvider>
+          <SaveShortcutListener />
           <div className="h-screen overflow-hidden bg-[#eceef1] text-[#1a1d21]">{children}</div>
         </ConfirmProvider>
       </TypeKeyProvider>

+ 90 - 0
src/components/save-shortcut-listener.tsx

@@ -0,0 +1,90 @@
+'use client'
+
+import { useEffect } from 'react'
+
+const SAVE_BUTTON = '[data-save-shortcut]'
+const OPEN_DIALOG = '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'
+
+function isSaveShortcut(e: KeyboardEvent) {
+  return (e.ctrlKey || e.metaKey) && !e.altKey && (e.key === 's' || e.key === 'S')
+}
+
+function usable(button: HTMLElement) {
+  return !(button as HTMLButtonElement).disabled && button.getClientRects().length > 0
+}
+
+/**
+ * The save button the shortcut stands for: the one closest to where the
+ * person is typing, so a page with a Save per card saves the card in use,
+ * and the first one on the page when nothing has focus.
+ */
+function findSaveButton(scope: Element, insideDialog: boolean): HTMLElement | null {
+  const candidates = Array.from(scope.querySelectorAll<HTMLElement>(SAVE_BUTTON)).filter(
+    (button) => usable(button) && (insideDialog || !button.closest(OPEN_DIALOG))
+  )
+  if (candidates.length === 0) return null
+  let node: Element | null = document.activeElement
+  while (node && node !== scope && scope.contains(node)) {
+    const near = candidates.find((button) => node?.contains(button))
+    if (near) return near
+    node = node.parentElement
+  }
+  return candidates[0]
+}
+
+/**
+ * Pressed with the mouse, a button first takes focus off the field, and
+ * fields that commit on blur rely on that. The shortcut does the same, then
+ * hands the focus back so typing carries on.
+ */
+function press(button: HTMLElement) {
+  const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null
+  focused?.blur()
+  setTimeout(() => {
+    button.click()
+    if (focused?.isConnected) focused.focus()
+  }, 0)
+}
+
+/**
+ * Ctrl+S / Cmd+S for everything that saves through a button marked
+ * `data-save-shortcut`: settings pages and dialogs. The document editors
+ * register their own save with `useSaveShortcut`; an open dialog with a save
+ * button goes first, then the editor, then a save button on the page.
+ */
+export function SaveShortcutListener() {
+  useEffect(() => {
+    const topDialog = () => Array.from(document.querySelectorAll(OPEN_DIALOG)).at(-1) ?? null
+
+    const dialogFirst = (e: KeyboardEvent) => {
+      if (!isSaveShortcut(e)) return
+      const dialog = topDialog()
+      const button = dialog && findSaveButton(dialog, true)
+      if (!button) return
+      e.preventDefault()
+      e.stopPropagation()
+      press(button)
+    }
+
+    const pageLast = (e: KeyboardEvent) => {
+      if (!isSaveShortcut(e) || e.defaultPrevented) return
+      // A save button on the page is not what someone looking at a dialog
+      // means to press.
+      if (document.querySelector('[data-slot$="-content"][role$="dialog"][data-state="open"]'))
+        return
+      const button = findSaveButton(document.body, false)
+      if (!button) return
+      e.preventDefault()
+      press(button)
+    }
+
+    window.addEventListener('keydown', dialogFirst, true)
+    window.addEventListener('keydown', pageLast)
+    return () => {
+      window.removeEventListener('keydown', dialogFirst, true)
+      window.removeEventListener('keydown', pageLast)
+    }
+  }, [])
+
+  return null
+}

+ 1 - 1
src/features/admin/Components/admin-settings.tsx

@@ -677,7 +677,7 @@ export function AdminSettings({
 
       {/* Save Button */}
       <div className="flex justify-end">
-        <Button onClick={handleSave} disabled={isPending}>
+        <Button data-save-shortcut onClick={handleSave} disabled={isPending}>
           {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
           {t('adminSettings.saveSettings')}
         </Button>

+ 1 - 1
src/features/billing/Components/RecurringInvoicesClient.tsx

@@ -801,7 +801,7 @@ export default function RecurringInvoicesClient({
               >
                 {t('recurring.cancel')}
               </Button>
-              <Button onClick={handleCreate} disabled={isPending}>
+              <Button data-save-shortcut onClick={handleCreate} disabled={isPending}>
                 {isPending && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
                 {t('recurring.create')}
               </Button>

+ 1 - 0
src/features/condition-map/Components/MarkTypeSettings.tsx

@@ -376,6 +376,7 @@ export function MarkTypeSettings({ types }: { types: MarkType[] }) {
               {t('cancel')}
             </Button>
             <Button
+              data-save-shortcut
               type="button"
               onClick={() => void save()}
               disabled={saving || !draft?.name.trim()}

+ 5 - 1
src/features/custom-fields/Components/CustomFieldsManager.tsx

@@ -583,7 +583,11 @@ export function CustomFieldsManager({
               >
                 {t('customFields.cancel')}
               </Button>
-              <Button onClick={handleSave} disabled={loading || !formData.name || !formData.label}>
+              <Button
+                data-save-shortcut
+                onClick={handleSave}
+                disabled={loading || !formData.name || !formData.label}
+              >
                 {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {editing ? t('customFields.update') : t('customFields.create')}
               </Button>

+ 1 - 1
src/features/customers/Components/CustomerForm.tsx

@@ -371,7 +371,7 @@ export function CustomerForm({
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {tc('buttons.cancel')}
             </Button>
-            <Button type="submit" disabled={loading}>
+            <Button data-save-shortcut type="submit" disabled={loading}>
               {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {customer ? tc('buttons.saveChanges') : t('addTitle')}
             </Button>

+ 5 - 1
src/features/dashboard/Components/CustomCardDialog.tsx

@@ -307,7 +307,11 @@ export function CustomCardDialog({
             <Button variant="outline" onClick={() => onOpenChange(false)}>
               {t('cancel')}
             </Button>
-            <Button onClick={handleSave} disabled={saving || !name.trim() || columns.length === 0}>
+            <Button
+              data-save-shortcut
+              onClick={handleSave}
+              disabled={saving || !name.trim() || columns.length === 0}
+            >
               {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {t('save')}
             </Button>

+ 9 - 1
src/features/email/Components/EmailDesigner.tsx

@@ -18,6 +18,7 @@ import { useTranslations } from 'next-intl'
 import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
 import { toast } from 'sonner'
 import { useConfirm } from '@/components/confirm-dialog'
+import { useSaveShortcut } from '@/hooks/use-save-shortcut'
 import { useGlassModal } from '@/components/glass-modal'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
@@ -442,6 +443,9 @@ export function EmailDesigner({
     : null
   const nameTaken = names.some((n) => n.name.trim().toLowerCase() === name.trim().toLowerCase())
   const canSave = dirty && problems.length === 0 && !saving
+  useSaveShortcut(() => {
+    if (canSave) return save(saved ? 'update' : 'first')
+  }, naming === null)
   const inUse = saved ? activeId === saved.id : activeId === null
 
   return (
@@ -646,7 +650,11 @@ export function EmailDesigner({
               >
                 {t('cancel')}
               </Button>
-              <Button type="submit" disabled={!name.trim() || nameTaken || saving}>
+              <Button
+                data-save-shortcut
+                type="submit"
+                disabled={!name.trim() || nameTaken || saving}
+              >
                 {saving ? t('saving') : t('nameDialog.save')}
               </Button>
             </DialogFooter>

+ 9 - 0
src/features/inspections/Components/InspectionPageClient.tsx

@@ -64,6 +64,7 @@ import type { MarkType } from '@/features/condition-map/Lib/markTypes'
 import { InspectionItemRow, type InspectionItemData } from './InspectionItemRow'
 import { MediaLightbox, type LightboxImage } from './MediaLightbox'
 import { useServiceType } from '@/components/service-type-context'
+import { useSaveShortcut } from '@/hooks/use-save-shortcut'
 import {
   CONDITION_TOKENS,
   TEST_RESULT_TOKENS,
@@ -403,6 +404,14 @@ export function InspectionPageClient({
     }
   }
 
+  // The shortcut is pressed mid-sentence, so the field gets its focus back
+  // once the blur has sent what was typed.
+  useSaveShortcut(() => {
+    const focused = document.activeElement as HTMLElement | null
+    handleSaveNow()
+    focused?.focus()
+  }, !isCompleted)
+
   const openImage = (url: string) => {
     const index = images.findIndex((img) => img.url === url)
     setLightboxIndex(index >= 0 ? index : null)

+ 5 - 1
src/features/inspections/Components/NewInspectionDialog.tsx

@@ -255,7 +255,11 @@ export function NewInspectionDialog({
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {t('cancel')}
             </Button>
-            <Button type="submit" disabled={isPending || !vehicleId || !templateId}>
+            <Button
+              data-save-shortcut
+              type="submit"
+              disabled={isPending || !vehicleId || !templateId}
+            >
               {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               Start Inspection
             </Button>

+ 1 - 1
src/features/inspections/Components/TemplateForm.tsx

@@ -963,7 +963,7 @@ export function TemplateForm({
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {t('cancel')}
             </Button>
-            <Button type="submit" disabled={isPending}>
+            <Button data-save-shortcut type="submit" disabled={isPending}>
               {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
               {isEdit ? t('saveChanges') : t('create')}
             </Button>

+ 1 - 1
src/features/inventory/Components/InventoryPartForm.tsx

@@ -863,7 +863,7 @@ export function InventoryPartForm({
             <Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
               {t('form.cancel')}
             </Button>
-            <Button type="submit" disabled={loading}>
+            <Button data-save-shortcut type="submit" disabled={loading}>
               {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {part ? t('form.saveChanges') : t('form.addPart')}
             </Button>

+ 9 - 1
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -7,6 +7,7 @@ import { withOrgNumberLabel } from '../Lib/labelOverrides'
 import { useRouter } from 'next/navigation'
 import { toast } from 'sonner'
 import { useConfirm } from '@/components/confirm-dialog'
+import { useSaveShortcut } from '@/hooks/use-save-shortcut'
 import { DocsLink } from '@/components/docs-link'
 import { Button } from '@/components/ui/button'
 import {
@@ -751,6 +752,13 @@ export function InvoiceDesigner({
     setSaving(false)
   }
 
+  useSaveShortcut(
+    () => {
+      if (dirty[docType] && !saving) return save()
+    },
+    view !== 'gallery' && !namingDesign
+  )
+
   if (view === 'gallery') {
     // A bounded height, not a minimum: the tool sits in a fixed, non-scrolling
     // frame, so a gallery that grows past the viewport has to scroll inside
@@ -1181,7 +1189,7 @@ export function InvoiceDesigner({
               <Button type="button" variant="outline" onClick={() => setNamingDesign(false)}>
                 {t('cancel')}
               </Button>
-              <Button type="submit" disabled={!designName.trim()}>
+              <Button data-save-shortcut type="submit" disabled={!designName.trim()}>
                 {savedDesigns.some(
                   (d) => d.name.trim().toLowerCase() === designName.trim().toLowerCase()
                 )

+ 1 - 1
src/features/labor-presets/Components/LaborPresetForm.tsx

@@ -589,7 +589,7 @@ export function LaborPresetForm({
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {t('form.cancel')}
             </Button>
-            <Button type="submit" disabled={loading}>
+            <Button data-save-shortcut type="submit" disabled={loading}>
               {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {preset ? t('form.saveChanges') : t('form.create')}
             </Button>

+ 7 - 1
src/features/portal/Components/CustomerPortalSettings.tsx

@@ -197,7 +197,13 @@ export function CustomerPortalSettings({
           footer={
             enabled ? (
               <div className="flex justify-end">
-                <Button type="button" size="sm" onClick={handleSave} disabled={saving || !dirty}>
+                <Button
+                  data-save-shortcut
+                  type="button"
+                  size="sm"
+                  onClick={handleSave}
+                  disabled={saving || !dirty}
+                >
                   {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
                   {t('portal.save')}
                 </Button>

+ 1 - 1
src/features/quotes/Components/NewQuoteDialog.tsx

@@ -155,7 +155,7 @@ export function NewQuoteDialog({
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {t('form.cancel')}
             </Button>
-            <Button type="submit" disabled={creating || !title.trim()}>
+            <Button data-save-shortcut type="submit" disabled={creating || !title.trim()}>
               {creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {t('form.createQuote')}
             </Button>

+ 1 - 1
src/features/signatures/Components/SignatureCard.tsx

@@ -296,7 +296,7 @@ export function SignatureCard({ initial }: { initial: string | null }) {
             {mode === 'draw' ? t('signatureDrawTip') : t('signatureUploadTip')}
           </p>
           <div className="flex flex-wrap gap-2">
-            <Button onClick={onSave} disabled={busy || !hasInk}>
+            <Button data-save-shortcut onClick={onSave} disabled={busy || !hasInk}>
               {busy ? (
                 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
               ) : (

+ 6 - 1
src/features/time-tracking/Components/TimeEntryDialog.tsx

@@ -262,7 +262,12 @@ export function TimeEntryDialog({
           <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
             {t('cancel')}
           </Button>
-          <Button type="button" onClick={() => void submit()} disabled={!canSave}>
+          <Button
+            data-save-shortcut
+            type="button"
+            onClick={() => void submit()}
+            disabled={!canSave}
+          >
             {saving && <Loader2 className="size-4 animate-spin" />}
             {editing ? t('save') : t('add')}
           </Button>

+ 1 - 1
src/features/tire-hotel/Components/CheckInDialog.tsx

@@ -876,7 +876,7 @@ export function CheckInDialog({
               </Button>
             )}
             {last ? (
-              <Button onClick={handleSubmit} disabled={saving || !locationId}>
+              <Button data-save-shortcut onClick={handleSubmit} disabled={saving || !locationId}>
                 {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {t(returning ? 'checkIn.submitReturn' : 'checkIn.submit')}
               </Button>

+ 1 - 1
src/features/tire-hotel/Components/EditTireSetDialog.tsx

@@ -348,7 +348,7 @@ export function EditTireSetDialog({
           <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
             {t('common.cancel')}
           </Button>
-          <Button onClick={handleSubmit} disabled={saving}>
+          <Button data-save-shortcut onClick={handleSubmit} disabled={saving}>
             {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
             {t('common.save')}
           </Button>

+ 15 - 3
src/features/tire-hotel/Components/LocationFormDialog.tsx

@@ -276,7 +276,11 @@ export function LocationFormDialog({
                 <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
                   {t('common.cancel')}
                 </Button>
-                <Button onClick={handleSaveBulk} disabled={saving || !bulkPreview}>
+                <Button
+                  data-save-shortcut
+                  onClick={handleSaveBulk}
+                  disabled={saving || !bulkPreview}
+                >
                   {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                   {t('storage.createShelves')}
                 </Button>
@@ -308,7 +312,11 @@ export function LocationFormDialog({
                 <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
                   {t('common.cancel')}
                 </Button>
-                <Button onClick={handleSaveSingle} disabled={saving || !effectiveCode}>
+                <Button
+                  data-save-shortcut
+                  onClick={handleSaveSingle}
+                  disabled={saving || !effectiveCode}
+                >
                   {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                   {t('storage.createLocation')}
                 </Button>
@@ -322,7 +330,11 @@ export function LocationFormDialog({
             <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
               {t('common.cancel')}
             </Button>
-            <Button onClick={handleSaveSingle} disabled={saving || !effectiveCode}>
+            <Button
+              data-save-shortcut
+              onClick={handleSaveSingle}
+              disabled={saving || !effectiveCode}
+            >
               {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {t('common.save')}
             </Button>

+ 5 - 1
src/features/tire-hotel/Components/NewTireJobDialog.tsx

@@ -640,7 +640,11 @@ export function NewTireJobDialog({
           <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
             {t('common.cancel')}
           </Button>
-          <Button onClick={handleSubmit} disabled={saving || loading || nothingPicked}>
+          <Button
+            data-save-shortcut
+            onClick={handleSubmit}
+            disabled={saving || loading || nothingPicked}
+          >
             {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
             {mode === 'quote'
               ? t('job.createQuote')

+ 1 - 1
src/features/tire-hotel/Components/RelocateDialog.tsx

@@ -105,7 +105,7 @@ export function RelocateDialog({
           <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
             {t('common.cancel')}
           </Button>
-          <Button onClick={handleSubmit} disabled={saving || !locationId}>
+          <Button data-save-shortcut onClick={handleSubmit} disabled={saving || !locationId}>
             {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
             {t('relocate.submit')}
           </Button>

+ 1 - 1
src/features/tire-hotel/Components/TreatmentCard.tsx

@@ -137,7 +137,7 @@ export function TreatmentCard({
             <Button variant="outline" size="sm" onClick={() => setEditing(false)} disabled={saving}>
               {t('common.cancel')}
             </Button>
-            <Button size="sm" onClick={handleSaveList} disabled={saving}>
+            <Button data-save-shortcut size="sm" onClick={handleSaveList} disabled={saving}>
               {saving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
               {t('common.save')}
             </Button>

+ 1 - 1
src/features/vehicles/Components/FindingForm.tsx

@@ -184,7 +184,7 @@ export function FindingForm({
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {tc('cancel')}
             </Button>
-            <Button type="submit" disabled={loading}>
+            <Button data-save-shortcut type="submit" disabled={loading}>
               {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {isEdit ? tc('saveChanges') : t('addTitle')}
             </Button>

+ 1 - 1
src/features/vehicles/Components/NoteForm.tsx

@@ -99,7 +99,7 @@ export function NoteForm({ vehicleId, open, onOpenChange, note }: NoteFormProps)
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {tc('cancel')}
             </Button>
-            <Button type="submit" disabled={loading}>
+            <Button data-save-shortcut type="submit" disabled={loading}>
               {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {isEditing ? tc('saveChanges') : t('addTitle')}
             </Button>

+ 1 - 1
src/features/vehicles/Components/ReminderForm.tsx

@@ -197,7 +197,7 @@ export function ReminderForm({ vehicleId, open, onOpenChange, reminder }: Remind
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {tc('cancel')}
             </Button>
-            <Button type="submit" disabled={loading}>
+            <Button data-save-shortcut type="submit" disabled={loading}>
               {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {isEdit ? tc('saveChanges') : t('addTitle')}
             </Button>

+ 1 - 1
src/features/vehicles/Components/ReminderFormDialog.tsx

@@ -404,7 +404,7 @@ export function ReminderFormDialog({
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {tc('cancel')}
             </Button>
-            <Button type="submit" disabled={formLoading}>
+            <Button data-save-shortcut type="submit" disabled={formLoading}>
               {formLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {isEdit ? tc('saveChanges') : tv('addTitle')}
             </Button>

+ 1 - 1
src/features/vehicles/Components/ServiceForm.tsx

@@ -146,7 +146,7 @@ export function ServiceForm({ vehicleId, open, onOpenChange }: ServiceFormProps)
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               Cancel
             </Button>
-            <Button type="submit" disabled={loading}>
+            <Button data-save-shortcut type="submit" disabled={loading}>
               {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               Add Record
             </Button>

+ 1 - 1
src/features/vehicles/Components/VehicleForm.tsx

@@ -840,7 +840,7 @@ export function VehicleForm({
               <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
                 {tc('cancel')}
               </Button>
-              <Button type="submit" disabled={loading}>
+              <Button data-save-shortcut type="submit" disabled={loading}>
                 {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {vehicle ? tc('saveChanges') : t('addTitle')}
               </Button>

+ 1 - 1
src/features/work-order-statuses/Components/WorkOrderStatusSettings.tsx

@@ -519,7 +519,7 @@ export function WorkOrderStatusSettings({ statuses }: { statuses: WorkOrderStatu
             <Button variant="outline" onClick={() => setDraft(null)} disabled={saving}>
               {t('cancel')}
             </Button>
-            <Button onClick={save} disabled={saving || !draft?.name.trim()}>
+            <Button data-save-shortcut onClick={save} disabled={saving || !draft?.name.trim()}>
               {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {t('save')}
             </Button>

+ 1 - 1
src/features/workboard/Components/TechnicianDialog.tsx

@@ -225,7 +225,7 @@ export function TechnicianDialog({
               <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
                 {tc('cancel')}
               </Button>
-              <Button type="submit" disabled={loading || !name.trim()}>
+              <Button data-save-shortcut type="submit" disabled={loading || !name.trim()}>
                 {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {technician ? tc('save') : t('addTechnician')}
               </Button>

+ 1 - 1
src/features/workboard/Components/WorkBayDialog.tsx

@@ -181,7 +181,7 @@ export function WorkBayDialog({
               <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
                 {tc('cancel')}
               </Button>
-              <Button type="submit" disabled={saving || !name.trim()}>
+              <Button data-save-shortcut type="submit" disabled={saving || !name.trim()}>
                 {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {workBay ? tc('save') : t('addBay')}
               </Button>

+ 4 - 2
src/hooks/use-save-shortcut.ts

@@ -11,7 +11,9 @@ export function useSaveShortcut(onSave: () => void | Promise<void>, enabled = tr
         void onSave()
       }
     }
-    window.addEventListener('keydown', handler)
-    return () => window.removeEventListener('keydown', handler)
+    // On the document so it runs before SaveShortcutListener's page fallback,
+    // which sits on the window and steps aside once this has handled the key.
+    document.addEventListener('keydown', handler)
+    return () => document.removeEventListener('keydown', handler)
   }, [onSave, enabled])
 }