-
Notifications
You must be signed in to change notification settings - Fork 17
Mobile Friendly 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.
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.
| 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 |
This is the round's main lesson, and it happened twice on one module.
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.
.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: 0on a real heading is the signature of a crushed pane. Overflow gets reported; crushing does not.
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. Somobile.jscopies each<th>'s text onto its cells asdata-mobile-label, and CSS prints it withattr():
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-arrowspan;textContentwould 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
MutationObserveron 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.
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.
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.
β¦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
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-childare 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.
π΄ 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.
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.
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 |
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.
Ed went through it on his phone. Seven fixes, and two of them could not have been found any other way.
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 β andscrollTopcan still be set from script on anoverflow: hiddenbox, so a test that assignsscrollTopand 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 > 0while the computedoverflow-yishidden, orelementFromPointchanging 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 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.
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.
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 insidemobile.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.
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 = 1pass inincludes/services/forms.phpbefore 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.
-
A top-level
letis not a window property.frame.contentWindow.fieldsisundefined, so a test that reads it that way concludes there is nothing to test and passes. Use a directevalin 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.
-
forms/index.phpcarries a second<style>block inside its<body>(the catalogueβapproval modal). That block is aftermobile.cssin document order, so Β§9's loadβorder rule cannot help and those few rules are the only!importantin 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.
-
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β thelabelCardFeed()IIFE, its own topβlevel block for the reason Β§14 gives. - Module documentation: Forms.
- MobileβFriendly Β· Techniques & Tricks
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)