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+).
- The new spec is parameterised over `tx-lang` so it runs once for
  English (LTR) and once for Arabic (RTL) — the bug originally
  surfaced on the Arabic schedule, so RTL coverage matters.
- The new spec self-skips (rather than fails) if the schedule has
  no attendees, so an empty environment doesn't masquerade as a
  layout regression.
- All passing.

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 19:04:00 +00:00
parent ab1708cb36
commit 7877cb173f
3 changed files with 48 additions and 67 deletions
@@ -200,17 +200,14 @@ for (const lang of ["en", "ar"]) {
/* fall through to the skip check below */
});
// 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))',
)
// Locate attendees by stable testids on the row, the index span,
// and either the static name span or the editable cell. This keeps
// the regression guard decoupled from CSS class names.
const firstAttendeeRow = page
.locator('[data-testid^="em-attendee-row-"]')
.first();
if ((await firstAttendeeLi.count()) === 0) {
if ((await firstAttendeeRow.count()) === 0) {
test.skip(
true,
`No attendees present on the schedule for lang=${lang}; cannot assert layout. Seed an attendee to enable this guard.`,
@@ -218,46 +215,46 @@ for (const lang of ["en", "ar"]) {
return;
}
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()
async function assertSameLine(rowLocator, nameLocator) {
await expect(rowLocator).toBeVisible();
const indexBox = await rowLocator
.locator('[data-testid^="em-attendee-index-"]')
.boundingBox();
const nameBox = await nameLocator.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.
// If the name wrapped onto a row below the index, centers would
// differ by ~a full line height (~18-22px). 8px of slack covers
// normal inline-vs-inline-block baseline differences.
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);
// 1. View mode (toggle off): name renders as a plain <span>.
await assertSameLine(
firstAttendeeRow,
firstAttendeeRow.locator('[data-testid^="em-attendee-name-"]'),
);
// 2. Edit mode (toggle on): name renders inside an EditableCell
// inline-block <div>; should still sit beside the index span.
// 2. Edit mode (toggle on): name renders inside an EditableCell.
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-"])',
)
const firstAttendeeRowEditable = page
.locator('[data-testid^="em-attendee-row-"]')
.filter({
has: page.locator('[data-testid^="em-edit-attendee-"]'),
})
.first();
if ((await firstAttendeeLiEditable.count()) > 0) {
await assertInline(firstAttendeeLiEditable);
if ((await firstAttendeeRowEditable.count()) > 0) {
await assertSameLine(
firstAttendeeRowEditable,
firstAttendeeRowEditable.locator(
'[data-testid^="em-edit-attendee-"]',
),
);
}
}
});