Schedule attendees: force number+name onto the same visual line

Task #175. Follow-up to #173. The first fix added `whitespace-nowrap`
on each attendee `<li>`, but users still saw the index span (`1-`,
`2-`) stacked above the name — even with very short names like
"رياض" / "محمد" that obviously fit on one line. Two screenshots
(before and after the merge) showed the same stacked layout, ruling
out narrow-column wrapping.

Root cause: attendee names are saved as tiptap HTML such as
`<p>محمد</p>`. Inside the inline-block EditableCell shell (and even
inside the plain view-mode `<span>`), the default block-level `<p>`
with its 1em top/bottom margins forced the name onto its own visual
row beneath the index span. `whitespace-nowrap` cannot pull a block
child back onto the parent line.

Fix (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Each attendee `<li>` is now `inline-flex items-baseline
  whitespace-nowrap` so the index span and the name wrapper become
  flex children that structurally cannot break apart.
- The view-mode `<span>` (plain dangerouslySetInnerHTML) gets
  `[&_p]:inline [&_p]:m-0` — tiptap `<p>` renders as inline with no
  margins.
- The editable EditableCell wrapper gets the more-scoped
  `[&>span_p]:inline [&>span_p]:m-0`, which matches only the
  view-mode shell `<div> > <span> > <p>` and deliberately does NOT
  match the editing shell `<div> > <div(border)> > EditorContent`,
  so pressing Enter inside the editor still creates a real new
  paragraph.

Tests (artifacts/tx-os/tests/executive-meetings-edit-toggle.spec.mjs):
- New regression spec asserts that the index span and the name
  wrapper share the same vertical center (within 8px) for the first
  attendee in BOTH view mode and edit mode. Without the fix the
  centers differ by a full line height (~20px+).
- All 3 specs in the file pass.

Out of scope (unchanged): grouping/sorting, index format, multi-
group Virtual/Internal/External rows, pending +Add ghost row,
other EditableCell call sites (title, time, notes, manage tab).
This commit is contained in:
riyadhafraa
2026-04-29 18:57:26 +00:00
parent 8c9e169e8c
commit c29632d5a6
3 changed files with 116 additions and 10 deletions
@@ -2936,15 +2936,25 @@ function AttendeeFlow({
{items.map(({ a, i }, displayIdx) => (
<li
key={a.id ?? i}
// `whitespace-nowrap` keeps the index prefix and the name on
// the same visual line so we never get a stacked "number on
// top, name below" layout — even for long names like
// "محمد علي (Webex)" or in edit mode where the name is wrapped
// in an inline-block EditableCell. Wrapping between attendees
// still happens at the parent <ul flex-wrap> level, which is
// the desired behavior when the cell is narrow.
// We use `inline-flex items-baseline` so the index prefix and
// the name are GUARANTEED to lay out side-by-side as flex
// children — they can never break onto separate lines within
// the same attendee. `whitespace-nowrap` is belt-and-braces
// for the rare browser that doesn't honor inline-flex baseline
// alignment for an inline-block child. Wrapping between
// *different* attendees still happens at the parent
// <ul flex-wrap> level, which is what we want when the cell is
// narrow.
//
// The `[&_p]:inline [&_p]:m-0` utilities on the inner name
// wrappers (below) are critical: attendee names are saved as
// tiptap HTML like `<p>محمد</p>`, and a default block-level
// `<p>` (with its 1em top/bottom margins) is what was forcing
// the name onto its own visual line under the index. Stripping
// its display+margins keeps the rendered name truly inline.
className={
"whitespace-nowrap" + (editable ? " min-w-[3rem]" : "")
"inline-flex items-baseline whitespace-nowrap" +
(editable ? " min-w-[3rem]" : "")
}
>
<span className="text-gray-500 me-1">{displayIdx + 1}-</span>
@@ -2961,11 +2971,23 @@ function AttendeeFlow({
// target so a near-miss tap on an iPad/touch device still
// lands on the editor. Hidden in edit mode (the editor draws
// its own border) and on print.
className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 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"
//
// `[&>span_p]:inline [&>span_p]:m-0` flattens the block
// `<p>` that tiptap stores names in, but ONLY for the
// view-mode shell (`<div> > <span> > <p>...</p>`). When
// the cell is actively being edited the structure becomes
// `<div> > <div(border)> > <EditorContent>`, which this
// selector deliberately does NOT match — so pressing
// Enter inside the editor still creates a real new
// paragraph.
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}`}
/>
) : (
<span dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }} />
<span
className="[&_p]:inline [&_p]:m-0"
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
/>
)}
</li>
))}
@@ -146,3 +146,87 @@ test("Schedule: turning edit mode OFF cancels any open inline editor and discard
expect(afterText).not.toContain(draft);
expect(afterText).toBe(originalText);
});
test("Schedule: attendee index prefix renders on the same visual line as the name", async ({
page,
}) => {
// Regression guard for the "number stacked above the name" bug
// (Tasks #173 / #175). Attendee names are saved as tiptap HTML
// (`<p>name</p>`); without a fix, the block-level <p> pushes the
// name onto its own visual row beneath the small index span. We
// assert that for at least one attendee, the index span and the
// name wrapper share roughly the same vertical center, in BOTH
// view mode and edit mode.
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* ignore */
}
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
// Reset edit-mode toggle so we start in view mode.
await page.evaluate(() => {
try {
const keys = Object.keys(window.localStorage);
for (const k of keys) {
if (k.startsWith("em-schedule-edit-mode-v1")) {
window.localStorage.removeItem(k);
}
}
} catch {
/* ignore */
}
});
await page.reload();
// Find the first attendee LI in the schedule that actually has
// an index prefix span and a name span. We bind the assertion to
// the LI's structure (index span + sibling name span) so it
// stays meaningful even if specific data changes.
const firstAttendeeLi = page
.locator(
'li.inline-flex:has(> span.text-gray-500):has(> span:not(.text-gray-500))',
)
.first();
async function assertInline(li) {
await expect(li).toBeVisible();
const indexBox = await li.locator("> span.text-gray-500").boundingBox();
const nameBox = await li
.locator("> span:not(.text-gray-500), > div")
.first()
.boundingBox();
expect(indexBox).not.toBeNull();
expect(nameBox).not.toBeNull();
const indexCenter = indexBox.y + indexBox.height / 2;
const nameCenter = nameBox.y + nameBox.height / 2;
// If the name had wrapped onto a row below the index, the
// vertical centers would differ by at least one full line
// height (~18-22px). We allow up to 8px of slack for normal
// baseline alignment between an inline span and an inline-block.
expect(Math.abs(indexCenter - nameCenter)).toBeLessThan(8);
}
// 1. View mode (toggle off): name renders as a plain <span>
// next to the index span.
await assertInline(firstAttendeeLi);
// 2. Edit mode (toggle on): name renders inside an EditableCell
// inline-block <div>; should still sit beside the index span.
const toggle = page.getByTestId("em-edit-mode-toggle");
if (await toggle.isVisible().catch(() => false)) {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
// Re-locate after re-render — the matching attendee LI now
// wraps an EditableCell <div> instead of a plain <span>.
const firstAttendeeLiEditable = page
.locator(
'li.inline-flex:has(> span.text-gray-500):has(> div[data-testid^="em-edit-attendee-"])',
)
.first();
await assertInline(firstAttendeeLiEditable);
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB