Browse Source

Show the work order's save state on the Save button, so the buttons stay in place (#516)

* Keep the work order's buttons in place when Unsaved changes or Saved shows

* Show the work order's save state on the Save button, so the row never changes width
Bernt Christian Egeland 1 day ago
parent
commit
11ebde926c

+ 45 - 0
src/__tests__/features/vehicles/service-header-save-state.test.tsx

@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { ServiceHeaderActions } from '@/features/vehicles/Components/service-page/ServiceHeaderActions'
+
+/**
+ * The work order's save state sits on the Save button, not in a text label
+ * beside it, so the row of buttons is the same width whatever the state: the
+ * label used to push the buttons onto a second line on every edit. The words
+ * still reach a screen reader.
+ */
+
+const noop = vi.fn()
+const header = (state: { hasUnsavedChanges?: boolean; showSaved?: boolean }) => (
+  <TooltipProvider>
+    <ServiceHeaderActions
+      showSave
+      downloading={false}
+      saving={false}
+      onDownloadPDF={noop}
+      onPreviewPDF={noop}
+      onDelete={noop}
+      onShowEmail={noop}
+      onShowShare={noop}
+      {...state}
+    />
+  </TooltipProvider>
+)
+
+describe('the work order save state', () => {
+  it('says "Unsaved changes" without a label that widens the row', () => {
+    render(header({ hasUnsavedChanges: true }))
+    expect(screen.getByRole('status')).toHaveTextContent('Unsaved changes')
+    expect(screen.getByRole('status')).toHaveClass('sr-only')
+    // The button keeps its name, so everything that finds it by name still does.
+    expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
+  })
+
+  it('says "Saved" after a save, and nothing once it settles', () => {
+    const { rerender } = render(header({ showSaved: true }))
+    expect(screen.getByRole('status')).toHaveTextContent('Saved')
+    rerender(header({}))
+    expect(screen.getByRole('status')).toBeEmptyDOMElement()
+  })
+})

+ 4 - 1
src/components/icon-action-button.tsx

@@ -17,6 +17,8 @@ export interface IconActionButtonProps extends Omit<ButtonProps, 'children' | 's
   size?: 'icon-xs' | 'icon-sm' | 'icon' | 'icon-lg'
   size?: 'icon-xs' | 'icon-sm' | 'icon' | 'icon-lg'
   /** Small overlay on the icon, e.g. a count of open observations. */
   /** Small overlay on the icon, e.g. a count of open observations. */
   badge?: React.ReactNode
   badge?: React.ReactNode
+  /** Tooltip text when it should say more than the label, e.g. a state. Defaults to the label. */
+  tooltip?: React.ReactNode
 }
 }
 
 
 /**
 /**
@@ -37,6 +39,7 @@ export function IconActionButton({
   className,
   className,
   disabled,
   disabled,
   badge,
   badge,
+  tooltip,
   ...props
   ...props
 }: IconActionButtonProps) {
 }: IconActionButtonProps) {
   return (
   return (
@@ -55,7 +58,7 @@ export function IconActionButton({
           {badge}
           {badge}
         </Button>
         </Button>
       </TooltipTrigger>
       </TooltipTrigger>
-      <TooltipContent>{label}</TooltipContent>
+      <TooltipContent>{tooltip ?? label}</TooltipContent>
     </Tooltip>
     </Tooltip>
   )
   )
 }
 }

+ 31 - 10
src/features/vehicles/Components/service-page/ServiceHeaderActions.tsx

@@ -13,6 +13,7 @@ import {
 } from '@/components/ui/dropdown-menu'
 } from '@/components/ui/dropdown-menu'
 import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 import {
 import {
+  Check,
   Download,
   Download,
   Eye,
   Eye,
   Globe,
   Globe,
@@ -76,24 +77,44 @@ export function ServiceHeaderActions({
     <div className="flex shrink-0 flex-wrap items-center gap-2">
     <div className="flex shrink-0 flex-wrap items-center gap-2">
       {showSave && (
       {showSave && (
         <>
         <>
-          {hasUnsavedChanges && (
-            <span className="text-xs font-medium text-amber-600 dark:text-amber-400">
-              {t('unsavedChanges')}
-            </span>
-          )}
-          {showSaved && !hasUnsavedChanges && (
-            <span className="text-xs font-medium text-green-600 dark:text-green-400">
-              {t('saved')}
-            </span>
-          )}
+          {/* The save state is shown on the Save button itself, as a dot while
+              there are unsaved changes and a check once saved, with the words
+              in its tooltip. A text label beside it changed the row's width,
+              which pushed the buttons onto a second line on every edit. Screen
+              readers still hear the words, from this status line. */}
+          <span role="status" className="sr-only">
+            {hasUnsavedChanges ? t('unsavedChanges') : showSaved ? t('saved') : ''}
+          </span>
           <IconActionButton
           <IconActionButton
             type="submit"
             type="submit"
             form="service-record-form"
             form="service-record-form"
             label={t('save')}
             label={t('save')}
+            tooltip={
+              hasUnsavedChanges
+                ? `${t('save')} · ${t('unsavedChanges')}`
+                : showSaved
+                  ? t('saved')
+                  : t('save')
+            }
             icon={Save}
             icon={Save}
             loading={saving}
             loading={saving}
             variant={hasUnsavedChanges ? 'default' : 'outline'}
             variant={hasUnsavedChanges ? 'default' : 'outline'}
             className={hasUnsavedChanges ? 'animate-pulse' : ''}
             className={hasUnsavedChanges ? 'animate-pulse' : ''}
+            badge={
+              hasUnsavedChanges ? (
+                <span
+                  aria-hidden="true"
+                  className="-right-1 -top-1 absolute size-2.5 rounded-full bg-amber-500 ring-2 ring-background"
+                />
+              ) : showSaved ? (
+                <span
+                  aria-hidden="true"
+                  className="-right-1.5 -top-1.5 absolute flex size-3.5 items-center justify-center rounded-full bg-green-600 text-white ring-2 ring-background"
+                >
+                  <Check className="size-2.5" />
+                </span>
+              ) : undefined
+            }
           />
           />
         </>
         </>
       )}
       )}