EM: custom fonts + Tab/Shift+Tab attendee quick-add

Task #301.

Fonts:
- Extracted 22 curated font files from the user's uploaded zip into
  artifacts/tx-os/public/fonts/ (DIN Next LT Arabic ×5, Tajawal ×7,
  Helvetica Neue LT Arabic ×3, Helvetica Neue ×5, Majalla ×2).
- New artifacts/tx-os/src/custom-fonts.css with @font-face blocks
  using font-display: swap; imported from index.css.
- Replaced FONT_OPTIONS in editable-cell.tsx with the curated set;
  each <option> previews in its own font family.

Attendee keyboard nav:
- EditableCell: new onTabNext / onTabPrev props, captured into refs
  so the empty-deps useEditor always sees the latest callback.
  handleKeyDown now intercepts Tab and Shift+Tab (preventDefaults +
  saveEdit + dispatches the matching callback).
- AttendeeFlow:
  - new chainStartAdd path that bypasses the hasAnyPending UI gate
    so Tab can chain a fresh pending row even while one is open
    (state-level guard in setPendingAttendee still prevents overlap).
  - new focusedAttendeeIdx state lets a sibling row request edit
    mode on a target row via startInEditMode + onStartedEditing.
  - findPrevPersonInSection walks back through items[] and stops at
    a subheading, so Shift+Tab never crosses a section boundary;
    no-op on the first row of a section (saveEdit still runs).
  - Existing person row passes onTabNext (chain new pending after i)
    and onTabPrev (focus prev person in same section).
  - Pending row passes the same pair using
    inlineGhostTargetListIdx ?? items.length as the start anchor.

Non-attendee EditableCells (meeting title, merge title, subheadings)
are unchanged — they pass no tab callbacks so default browser
focus traversal still applies.

Verified: TS clean, e2e covers forward Tab chain, Shift+Tab between
existing rows, first-in-section no-op, and Shift+Tab from the
pending row.
This commit is contained in:
riyadhafraa
2026-05-01 19:21:10 +00:00
parent adf70e4f28
commit dd26b69b9d
3 changed files with 80 additions and 16 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -138,6 +138,7 @@ export function EditableCell({
forceSaveOnBlur = false,
onCancel,
onTabNext,
onTabPrev,
}: {
value: string;
onSave: (html: string) => Promise<void> | 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<HTMLDivElement | null>(null);
@@ -180,12 +190,14 @@ export function EditableCell({
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | 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;
},
@@ -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<number | null>(
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 <li>. 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);
}}
/>
</li>
);
@@ -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);
}}
/>
) : (
<span