Skip to content

Mobile Friendly Forms

Ed Mozley edited this page Aug 29, 2026 · 2 revisions

Mobile: Forms

Forms is the thirteenth module made mobile‑friendly, after Tickets, Assets, Calendar, Knowledge, Service Status, Watchtower, Problem Management, Change Management, Software, Tasks and Morning Checks. Same hard rule: one @media (max-width: 768px) block, desktop byte‑identical.

🧰 Code‑level catalogue of the techniques: Mobile: Techniques & Tricks.

Shipped as #1289. LAYER 27, plus the first new mobile.js idea in several rounds β€” harvested column labels.


The starting point

viewport = 360   docScrollW = 904   ← on ALL SEVEN pages, identically

The thirteenth module and the thirteenth time every page in it reported the same number at once: nav.header-nav and .header-right in the shared header, on a module that had never opted in. Three tags per page (four for settings) took six of the seven straight to docScrollW === 360 and the list page to 460.


Scope β€” seven pages

Page State
forms/ β€” the list 27b, 27c β€” toolbar stacked, eight‑column table β†’ card feed
forms/fill.php β€” filling one in 27d β€” the page that matters most on a phone: density, 16px fields, the native date fix
forms/submissions.php 27e β€” 1,400px wide and reporting itself contained; table β†’ card feed, detail overlay β†’ full‑screen sheet
forms/approvals.php 27f β€” a 260px sidebar left the content 100px wide; sidebar β†’ chip row
forms/settings/ Done by opting in + data-mobile-page="settings" β€” 0 shrink floors on both tabs
forms/help.php Done by opting in β€” inherits the contents strip
forms/edit/ β€” the builder 27g β€” 24 shrink floors and six sub‑16px font sizes

πŸ”΄ Two of the four real faults were invisible to every containment check

This is the round's main lesson, and it happened twice on one module.

Submissions measured CONTAINED while being 1,400px wide

docScrW 360   innerW 360   =>  CONTAINED
.subs-content   1400px wide

.subs-container sets overflow-y: auto. CSS computes the other axis to auto when one axis is not visible β€” so the container scrolled horizontally, the document never spilled, and docScrollW === innerWidth stayed true over a page that was four screens wide.

πŸ”‘ A page whose container scrolls cannot report its own overflow. Assert the box, not just the document β€” the same lesson Β§13 teaches from the other direction, and the reason Β§14 exists.

Approvals was crushed to nothing, and nothing overflowed at all

.ca-container is a flex row: a 260px fixed sidebar beside a flex: 1 main pane. At 360px the main pane got 100px, and 40px of that once its 30px side padding came off.

h2#caTitle   scrollW=84   clientW=40
h3           scrollW=62   clientW=0     ← the empty-state heading

Nothing was uncontained. Nothing overflowed. The page was simply gone, and the only reading that said so was a heading whose client width was zero. Same shape as Problem Management's 110px list and the same fix as 22 and 23: the sidebar stops being a sidebar and becomes a scrolling row of filter chips above the content.

⭐ clientWidth: 0 on a real heading is the signature of a crushed pane. Overflow gets reported; crushing does not.


The general win: a card feed can have its headings back

Every card feed in this rollout has had to drop its column labels, because the only pure‑CSS way to put one back is:

td:nth-child(5)::before { content: "Submissions"; }   /* ❌ hardcoded English */

…and FreeITSM ships in 24 languages. So the rule became "reading order carries the meaning instead" (Β§11), which works when the values are self‑describing β€” a name, a date, a status pill β€” and fails completely when they are not.

The forms list is where it fails. Two of its eight columns are bare counts, and a card reading

New Starter Request
v1  Β·  Active  Β·  In catalogue
7   10

says nothing whatever about what 7 and 10 are.

⭐ The labels are already on the page, already translated, in the <thead> the feed hides. So mobile.js copies each <th>'s text onto its cells as data-mobile-label, and CSS prints it with attr():

cell.setAttribute('data-mobile-label', text);   // text taken from the table's own <th>
#formsTable tbody td[data-mobile-label]::before { content: attr(data-mobile-label) "\00a0"; }

giving "Fields 7 Β· Submissions 10" in whatever language the person is using. No invented string, no new key in 24 locales, no fallback. Any module's table can use it β€” add a line to the FEEDS array naming the table and which column indexes deserve a label.

Deliberately a list, not "all columns". A card whose every line is prefixed reads like a spreadsheet; only the columns that are meaningless bare want one.

Three details that matter if you reuse it:

  • It only fires below 768px (if (!mq.matches) return), so the attribute is not even written on a desktop render β€” proved by the desktop control finding no [data-mobile-label] cells at all.
  • Take the <th>'s text nodes only. These headers carry a .sort-arrow span; textContent would drag β–²β–Ό into the label.
  • Skip any row whose cell count differs from the header's. The empty state is a single <td colspan="8">, and a label stamped onto that would land on the wrong thing.
  • Re‑render safety is a MutationObserver on the table, not a wrap of the renderer. The list is rebuilt on load, on search and on every sort; observing costs one line and cannot get out of step with four call sites this file does not own.

27c β€” the forms list as a card feed

Eight columns, measured 1,294px inside a 300px card, cells crushed to 105 Γ— 113px β€” a two‑digit number set four lines deep. The 82 Γ— 134 signature again.

πŸ”΄ The first attempt still came out 448px wide

tr   scrollW=448   clientW=360

.col-title small β€” the form's description β€” is white-space: nowrap with an ellipsis, because on desktop it is one truncated line in a column. A flex item's automatic minimum size is its content width, so the title cell could not shrink below the 434px that sentence wanted, and every card was wider than the screen. The document stayed contained throughout, because the card scrolls.

Two lines fixed it, and both are worth copying:

#formsTable tbody td { min-width: 0; }                 /* the floor, not the width */
#formsTable tbody td.col-title small {
    white-space: normal; max-width: none; overflow: visible;
}

⚠️ In a card there is no column to protect. A one‑line summary of what a form is for is worth two lines of a phone screen β€” the same call 26 made for .notes-display.

⚠️ The empty state is a <td colspan="8">

…which makes it the first and last child of its row, so any rule keyed on either position swallows it β€” exactly how the Calendar feed lost its message. It is excluded explicitly, and then driven (search for something that matches nothing) rather than assumed:

POSITIVE CONTROL #formsTable tbody td[colspan]:  332x311  visible=true

27e β€” submissions: the table is an index, not the data

Thirteen columns, one of them holding prose ("Needs Visual Studio, Git, …") at white-space: nowrap. Β§11 is unambiguous about prose in a sideways scroller and equally unambiguous that unlabelled answers are meaningless β€” which looks like a deadlock until you notice what the page already does.

Every row carries onclick="showDetail(idx)" and opens an overlay listing every answer against its own label. So the card only has to be enough to pick a row, and the three columns that identify one are fixed whatever the form contains:

.subs-table tbody td:nth-child(n+4):not(:last-child) { display: none; }

πŸ”‘ nth-child(n+4) and :last-child are stable however many questions the form has, because the three leading columns (number, who, when) and the trailing delete button are written by the renderer, not by the form. Keying on a count would have broken on the next form somebody built.

Hiding the answers costs nothing, because they are one tap away complete and labelled β€” which is more than the crushed table ever gave. The overlay itself is .detail-box, a module's own modal class and therefore invisible to LAYER 3, so it needed promoting to a full‑screen sheet by hand.


27g β€” the builder, and the drag that does work on a phone

πŸ”΄ I nearly removed something that works, on a rule I had over‑stated.

Reordering fields in the builder is HTML5 drag‑and‑drop (draggable="true" + dragstart), and drag surfaces are normally out of scope on a phone. I put that to Ed as a scope question, saying dragstart does not fire on touch at all. He pointed at Morning Checks settings, where the identical mechanism is shipped and works on his iPhone.

The correction is worth stating precisely, because the two cases really are different:

Kind of drag On a phone
HTML5 drag‑and‑drop (draggable="true", dragstart/dragover/drop) Works. iOS Safari has driven this API from a long press since iOS 11. Needs touch-action: none on the handle so the page does not scroll instead β€” which .field-drag and .check-drag both already carry.
A hand‑rolled mousedown/mousemove drag (a resize divider, a canvas) Does not work. No touch handlers, no events. This is what 26c's chart divider and the mapper canvases actually are.

So the builder's reordering is left exactly as it is, and nothing in LAYER 27 touches .field-drag.

The rest of the builder is ordinary work: the field row was grip | label | type | controls needing 383px, so the label β€” the thing you came to edit β€” takes a full‑width line beneath the grip and the controls; a conditional‑logic row stacks rather than squeezing three controls onto one 360px line; the properties drawer fills the screen instead of being 324px of a 360px one with a 36px dead strip.

⚠️ The anti‑zoom rule missed the two fields at the top of the page

Scoped to .form-tab-content on the first pass β€” and the form's own title and description live in .form-settings-card, above the tabs. Two fields left at 14px reads as "the rule works" right up until you focus the first control on the page.

πŸ”‘ Same shape as Β§9's near‑miss: a fix that covers most of a page is harder to spot than one that covers none of it.


Verification

Everything below was driven against the real authenticated pages in a 360px iframe, not a reconstructed harness.

Check Result
7 pages at 360px docScrollW === 360, 0 uncontained, 0 shrink floors, 0 crushed cells, 0 sub‑16px fields
forms/settings/ both tabs driven contained, 0 floors on each
Builder: properties drawer, preview tab, AI modal each contained, each with a positive control proving it rendered
Submissions detail overlay 360x740 at (0,0) β€” full screen
Approvals with a real card 332x492, contained
The .ca-modal on the list page 360x740 β€” the !important set beating an in‑<body> <style>
Empty state driven 332x311, visible β€” not swallowed
Harvested labels 4 cells stamped, ::before renders "Fields\00a0", negative control (col-title) unstamped, still 4 after a re‑render
Desktop control @1200 and @1400 tables still display: table with table-header-group headers, sidebar still row/260px, .cond-row still nowrap, drawer still 360px, date appearance: auto, no [data-mobile-label] written at all
CSS + JS parse 1 top‑level rule (the @media block), 860 inside it; mobile.js 0 errors β€” with a negative control proving the harness can see one

πŸ”΄ A positive control caught a state I had not actually opened

Driving openPropertiesDrawer() and measuring reported the drawer at (360, 50) β€” parked off‑screen, visible=false. The class had been added; the CSS transition had not run, because headless Chrome under --virtual-time-budget does not advance them. Disabling the transition in the drive step showed the truth: 360x690 at (0, 50), correct.

⚠️ Without the positive control this would have been recorded as "drawer verified". Β§14 says driving the wrong entry point proves nothing; this is the same failure one step later β€” driving the right entry point and measuring before it took effect.


Known rough edges

  • forms/index.php carries a second <style> block inside its <body> (the catalogue‑approval modal). That block is after mobile.css in document order, so Β§9's load‑order rule cannot help and those few rules are the only !important in LAYER 27. Moving the block into <head> would remove the need; it was left alone as a module change outside this round.
  • No device pass yet. Everything here is measured, and Β§20 is the standing reminder that a headless browser draws native date controls differently from an iPhone.
  • The builder's drag is untested on a real phone by me β€” it is inferred from Morning Checks working, which is strong but is not the same as having tried it.

Reference

  • LAYER 27 in assets/css/mobile.css β€” 27a containment Β· 27b list toolbar Β· 27c list card feed Β· 27d filling a form in Β· 27e submissions Β· 27f approvals Β· 27g the builder.
  • mobile.js β€” the labelCardFeed() IIFE, its own top‑level block for the reason Β§14 gives.
  • Module documentation: Forms.
  • Mobile‑Friendly Β· Techniques & Tricks

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally