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.


Round 2 β€” the device pass (#1290)

Ed went through it on his phone. Seven fixes, and two of them could not have been found any other way.

πŸ”΄ You could not scroll the page for filling a form in

316px of the form was unreachable. .fill-container is also .main-container, so LAYER 2 had already given it overflow: hidden for the tickets pane stack β€” a pane stack this page does not have. The identical fault, on the identical class, was found and fixed on .servers-container in the Assets round six modules earlier.

⚠️ The measurement that finds it, because none of the usual ones do. The page contains perfectly, the document never spills β€” and scrollTop can still be set from script on an overflow: hidden box, so a test that assigns scrollTop and re-measures reports a happy PASS over a screen a finger cannot move. My first run did exactly that and printed a tick.

The tells that are real: scrollHeight - clientHeight > 0 while the computed overflow-y is hidden, or elementFromPoint changing after a scroll that only touches genuine scrollers.

.subs-container is the same class and the same shape. It did not show the fault only because that page happened to be shorter than the screen β€” it was fixed at the same time rather than waiting for the second report.

πŸ”΄ The action bar sat underneath Safari's toolbar

The editor's new bottom bar was hidden behind the browser's own back button and address bar. .forms-edit-page was sized by calc(100vh - 48px), and on iOS 100vh is the large viewport β€” the height the page would have if Safari's toolbars were hidden. A pane sized by it is taller than what you can see, so anything pinned to its bottom edge is below the fold.

⚠️ No headless browser has that chrome. The pane measured exactly right at 360px in Chrome and always would. Ed's device was the only instrument.

The fix is to let LAYER 2's flex body own the height, not to add a margin β€” a margin would only move the bar up by a guess.

πŸ”΄ And then it was too high. The first fix also added env(safe-area-inset-bottom) to clear the home indicator, and Ed's verdict was "that's better but they are a bit high". The two allowances stacked. Clearing the same edge twice is a gap, not safety β€” once the pane stops where the visible viewport stops, the browser chrome is already accounted for.

⭐ Two controls one rule sizes, rendering at different widths

The From / To date filters. Their labels sat beside the fields, and "From" is 30px wide where "To" is 13px β€” so the two date inputs started at different x positions and came out different lengths, from a single shared rule.

⭐ Same family as §20's tell: a mismatch between two controls that one rule sizes is a layout fault, and it shows AT REST. Nothing overflows, so no containment check will ever mention it. It is found by looking.

Labels above, the pair at exactly half each, Clear and Export aligned beneath.

The editor's four tools became one bottom bar

AI Assist / Versions / Save as new version / Properties were four full-width rows β€” about 150px before a single field. They now join Cancel and Save in one row of icons at the bottom, with a β‹― overflow, reusing LAYER 5's .mobile-more-panel.

Three things worth carrying forward:

  • ⚠️ Sharing a CSS class does not share the JS that usually comes with it. LAYER 5's tap-outside-to-close handler is inside mobile.js's main IIFE, which returns early without an .email-list-container. On this page it never runs, and the failure would have been silent β€” the panel opens correctly and simply never closes. The forms IIFE carries its own copy.
  • ⚠️ Before hiding a label, check every button has something left to show. Cancel is a plain <button>Cancel</button> with no <svg>, so hiding its text left an empty grey rectangle. Anything icon-less gets a glyph injected.
  • ⚠️ insertBefore(el, footer.firstChild) in a loop reverses your list. The bar came out Properties-first. Anchor on the first original child instead.

Deleting now asks β€” and says the thing nobody could see

Nothing is written until Save, which is why there was no confirmation. But a mis-tap still loses a question, its options and its rule, and on a phone the delete icon sits a few pixels from the Required tick.

The dialog names the field and states the two facts that make it easy to answer β€” answers already given are kept (save_form retires a removed field rather than deleting it) and nothing changes until you save β€” plus how many visibility rules on other fields depend on it and will be dropped with it. That last part was happening silently before.

πŸ”‘ Verify the promise before you print it. A confirmation that says answers are kept is worse than no confirmation at all if they are not. The claim was checked against the UPDATE form_fields SET is_deleted = 1 pass in includes/services/forms.php before the string was written.

Removing a dropdown option asks too β€” unless the option is still blank. "+ Add option" then "Γ—" is a normal thing to do a second after doing it by accident, and a dialog people learn to dismiss without reading stops protecting the case that matters.

⚠️ Two traps while testing this round

  • A top-level let is not a window property. frame.contentWindow.fields is undefined, so a test that reads it that way concludes there is nothing to test and passes. Use a direct eval in the frame's own scope.
  • A dialog left over from the previous assertion answered the next one. Checking "a blank option raises no dialog" saw the overlay still open from the test before and reported a failure that was not there. Re-run the case in isolation, with a negative control on the same page proving the dialog does appear when it should.

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