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:
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
|
||||
|
||||
Reference in New Issue
Block a user