diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 70c131e1..011a01ee 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/components/editable-cell.tsx b/artifacts/tx-os/src/components/editable-cell.tsx index a159a866..39153755 100644 --- a/artifacts/tx-os/src/components/editable-cell.tsx +++ b/artifacts/tx-os/src/components/editable-cell.tsx @@ -138,6 +138,7 @@ export function EditableCell({ forceSaveOnBlur = false, onCancel, onTabNext, + onTabPrev, }: { value: string; onSave: (html: string) => Promise | void; @@ -170,9 +171,18 @@ export function EditableCell({ * responsible for what should happen "next" — typically opening a * new pending attendee row immediately after this one. We swallow * the default Tab behaviour so the editor never inserts a tab - * character. `Shift+Tab` is left to the browser/TipTap default. + * character. */ onTabNext?: () => void; + /** + * If provided, `Shift+Tab` inside the editor commits the current + * value and then invokes this callback. Mirror of `onTabNext` — used + * by the attendee flow to focus the previous person row in the same + * section. May be invoked even when there is nothing to focus + * (callback may no-op); we still preventDefault so the editor never + * inserts a tab character. + */ + onTabPrev?: () => void; }) { const [editing, setEditing] = useState(false); const wrapperRef = useRef(null); @@ -180,12 +190,14 @@ export function EditableCell({ const blurTimerRef = useRef | null>(null); // `useEditor` below is created once (deps `[]`), so its `handleKeyDown` - // closes over the first render's `onTabNext`. Stash the latest in a - // ref so Tab always invokes the current callback. + // closes over the first render's `onTabNext` / `onTabPrev`. Stash the + // latest in refs so Tab / Shift+Tab always invoke the current callback. const onTabNextRef = useRef(onTabNext); + const onTabPrevRef = useRef(onTabPrev); useEffect(() => { onTabNextRef.current = onTabNext; - }, [onTabNext]); + onTabPrevRef.current = onTabPrev; + }, [onTabNext, onTabPrev]); useEffect(() => { if (startInEditMode && !editing && !disabled) { @@ -239,19 +251,20 @@ export function EditableCell({ saveEdit(); return true; } - // Plain Tab → commit and run the "what's next" callback (e.g. - // open a fresh pending attendee row right after this one). - // Without an `onTabNext` handler we leave Tab to the browser + // Tab / Shift+Tab → commit and hand off to the parent's + // navigation callback (typically: open the next pending + // attendee row, or focus the previous attendee in the same + // section). Without a callback we leave Tab to the browser // so focus moves naturally to the next focusable control. - if ( - event.key === "Tab" && - !event.shiftKey && - onTabNextRef.current - ) { - event.preventDefault(); - const next = onTabNextRef.current; - void Promise.resolve(saveEdit()).then(() => next()); - return true; + if (event.key === "Tab") { + const cb = event.shiftKey + ? onTabPrevRef.current + : onTabNextRef.current; + if (cb) { + event.preventDefault(); + void Promise.resolve(saveEdit()).then(() => cb()); + return true; + } } return false; }, diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 0d83c7ce..ecac993c 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -3954,6 +3954,17 @@ function AttendeeFlow({ }: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) { const editable = canMutate && !!onSaveAttendeeName; const showAdd = canMutate && !!onStartAdd && !isPending; + // When Shift+Tab fires inside a person row we ask the previous + // person row to enter edit mode by setting `focusedAttendeeIdx` to + // its meeting-attendees index `i`. The target row's EditableCell is + // re-rendered with `startInEditMode={focusedAttendeeIdx === i}`, + // which trips the existing startInEditMode useEffect and opens it. + // We clear the value back to null via onStartedEditing so click + // interactions on other rows remain free of any stale "force edit" + // pull. null means "no row is being force-focused right now". + const [focusedAttendeeIdx, setFocusedAttendeeIdx] = useState( + null, + ); // True when the in-flight ghost is targeted at a specific slot — set // by clicking an after-section "+ عنوان فرعي" chip. The same flag // drives inline rendering for the person ghost (still wired through @@ -4025,6 +4036,19 @@ function AttendeeFlow({ } } + // Walk backward from `startListIdx - 1` until we find a person row; + // stop (return null) if we hit a subheading first or run off the top + // of the list. Used by Shift+Tab to decide which row should enter + // edit mode, while staying inside the current section. + const findPrevPersonInSection = (startListIdx: number): number | null => { + for (let j = startListIdx - 1; j >= 0; j--) { + const kind = items[j].a.kind ?? "person"; + if (kind === "subheading") return null; + return items[j].i; + } + return null; + }; + // The pending subheading ghost
  • . Used for inline rendering when // the user clicks an after-section "+ عنوان فرعي" chip — the ghost // materializes at the requested slot rather than the trailing @@ -4134,6 +4158,19 @@ function AttendeeFlow({ ) : undefined } + // Shift+Tab → commit the pending row (onCancelAdd handles the + // empty-blur path; non-empty values commit via onSave) and + // focus the previous person in the same section. The pending + // ghost lives at listIdx = inlineGhostTargetListIdx (when + // inline) or after the very last item (when trailing). + onTabPrev={() => { + const startListIdx = + inlineGhostTargetListIdx !== null + ? inlineGhostTargetListIdx + : items.length; + const prevI = findPrevPersonInSection(startListIdx); + if (prevI !== null) setFocusedAttendeeIdx(prevI); + }} />
  • ); @@ -4259,6 +4296,13 @@ function AttendeeFlow({ // creates a real new paragraph inside the editor. className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0" testId={`em-edit-attendee-${i}`} + // When Shift+Tab on a sibling row asks us to take focus + // we flip startInEditMode true; the cell's existing + // useEffect enters edit mode, then onStartedEditing + // clears the flag so future clicks elsewhere are not + // hijacked back to this row. + startInEditMode={focusedAttendeeIdx === i} + onStartedEditing={() => setFocusedAttendeeIdx(null)} // Tab → commit current name then open a pending person // row immediately after this one in the same cell, so // the user can keep typing names without mousing. @@ -4269,6 +4313,13 @@ function AttendeeFlow({ ? () => chainStartAdd(addType, "person", i + 1) : undefined } + // Shift+Tab → commit then jump back to the previous + // person row in the same section (no-op on section's + // first row — saveEdit still runs). + onTabPrev={() => { + const prevI = findPrevPersonInSection(listIdx); + if (prevI !== null) setFocusedAttendeeIdx(prevI); + }} /> ) : (