Blog-Archiv

Dienstag, 1. Januar 2019

HTML Form Layout CSS

The HTML form element does nothing to arrange its subordinates in an appropriate layout. We must do it ourselves.
Such layout expectations could be specified as:

  • Fields and Sets:

    • A form can contain a mix of fields and field-sets, where a field-set is a titled vertically arranged group of fields.

  • Horizontal Layout:

    • Portrait-format (mobile): one-column table, label on top, input field below, both in same table-cell.
      • Exception checkbox: label right, box left.

    • Landscape-format (desktop): two columns, label in left column, input field in right.
      • Label is right aligned, so that it is near to the field it describes.
      • Exception checkbox: label right, box left.
      • Newspaper layout: to exploit width, distribute fields across several horizontally ordered boxes.

  • Vertical Layout:

    • A left-side label should be on same height as its right-side input field, vertically aligned "middle".
    • In case the field or field-set is much higher than its left-side label, the label should be aligned to the upper edge of the field or field-set.

  • Nested Layouts:

    • A field can be organized as several fields with its own layout, examples are "Date of Birth" with "Age" output, or "Year", "Month" and "Day" input fields in a row.

In this article I will introduce CSS implementing this. I won't yet cover the checkbox case, and also I won't think about "newspaper layout" (distributing label/field pairs across several columns when having a restricted height). You can visit the current state of this project on my homepage.

Form Sample

Here is a HTML form example that contains all fields available from HTML-5 browsers, so that we can examine whether the layout works for all types of fields. This is also to try out the field-features your browser provides.

E.g. Firefox doesn't support datetime-local, week, month fields by now.
Chrome supports all, but paints box-shadow for radio-buttons badly, and the default shape of fields is ugly.

Use the button to toggle the layout from desktop ("left-label") to mobile ("top-label").
Use the button to stretch the form to full width.

HTML Form Layout Example
 

Notice that required field labels are also bold for field-sets, in case the set contains at least one required field. Example is the "More Text" group. On the other hand there is no required field in the "Date / Time" group, thus its label is not bold.

Core Form Layout CSS

I will present this in small doses. You can find the entire source code of the example on bottom of the article.

Most important to know is that I used line-height to get rid of vertical alignment problems (still open problems!), and box-sizing: border-box; to stop fields hanging out at right side.

About CSS coding:
I recommend to write comments on any CSS line, at least one per rule-set. Only this keeps CSS code maintainable! Remember that a rule like position: relative; can serve several purposes, so we need to make explicit why we apply it.

Basic Label Styling

      /* Smaller sans-serif font. */
      form label
      {
        font-size: 76%;
        font-family: sans-serif;
      }

      /* Add colon after any label. */
      form label::after
      {
        content: ":";
      }
      /* But not on label after radio. */
      form input[type = 'radio'] + label::after
      {
        content: "";
      }

The label should have a smaller font than the field. This also will uncover vertical alignment problems in the layout.
Further this CSS sets a colon after the label, and avoids it for radio buttons where the label is right of the button.

Labels of Required Fields

The JavaScript I introduced in my recent Blog copies the attribute required from field to label, the CSS below relies on that.

      /* Set asterisk before required fields. */
      form label[required]::before
      {
        content: "* ";
      }
      
      /* Make labels for required fields better visible. */
      form label[required]
      {
        font-weight: bold;
      }

This is for setting an asterisk (*) before any label of a required field, and displaying it in a bold font.

DIV Table Layout

      form .table
      {
        display: table;
        width: 100%;  /* if parent is 100%, go with it */
      }
      
      form .table > div
      {
        display: table-row;
        line-height: 2em;  /* needed for vertical line-alignment */
      }

This defines the table-layout inside the form, not including the "Submit" and "Reset" buttons. It will always fill its parent's width fully, so you should wrap it into either a display: inline-block; (natural width) or a display: block; (full width) DIV container.

The table-row receives its line-height here, important to avoid vertical alignment problems. The table-cell is not yet defined, it will be set specifically later for either mobile or desktop.

Layout of Fields Inside Tables

      /* Stretch fields to full cell width, except checkboxes and radios. */
      form .table > div > input:not([type = 'radio']):not([type = 'checkbox']),
      form .table > div > textarea,
      form .table > div > select
      {
        width: 100%;
      }

      /* Avoid text fields hanging out at right due to content-box sizing. */
      form .table input,
      form .table textarea,
      form .table select
      {
        box-sizing: border-box;
      }
      
      /* Let the user resize big fields vertically. */
      form .table select[multiple],
      form .table textarea
      {
        resize: vertical;
      }

Fields should fill their cell's full width, not keep their "natural" width which is browser-specific.
Force the browser to calculate their border-box, not content-box, else they will hang out at right side.
Optionally we should allow the user to resize choosers that may contain lots of rows, like <textarea> and <select multiple>.

Left Label for Desktop

      form .left-label > div > label,
      form .left-label > div > input,
      form .left-label > div > select,
      form .left-label > div > textarea,
      form .left-label > div > div,
      form .left-label > div > fieldset
      {
        display: table-cell;  /* display elements horizontally */
        vertical-align: middle;  /* avoid vertical alignment problems */
        
        margin-top: 0.2em;  /* vertical line distance */
        margin-bottom: 0.2em;
      }
    
      form .table.left-label > div label
      {
        text-align: right;
        padding-right: 0.2em;  /* distance to right field */
        
        vertical-align: top;   /* necessary for labels of nested fieldsets */
      }

So here is a list of element names that can be inside a table-cell. For a desktop-suited table with two columns, all these elements should behave as table-cell. We allow beside input, select and textarea also fieldset and div elements.
Their vertical alignment should be "middle" by default.
To create a little space between layout rows there is margin at bottom.

Labels should be aligned right in left-label variant, to be as near as possible to the field, but nevertheless keep a little padding-distance.
For labels we overwrite the default vertical alignment with "top" here, because in case a whole fieldset (high) is on right side, we want the label to stay in a visible range.


Mind that there are no further rules for the top-label layout variant. As any input field has been stretched to 100%, its preceding label will automatically go to top. As a side-effect top-label will be the default, unless you put a CSS class="left-label" onto the DIV table root-element.


Optional Additions

Following introduces useful code to style initial validation, button bars and nested right-side containers (horizontal bars).

Visible Initial Validation

      form :not(output):not(fieldset):invalid /* fieldset would also be red */
      {
        box-shadow: 1px 1px 1px red;
      }

If you want to make the validation markers already visible on document-load, add this rule-set. By default HTML forms would be validated by the browser only on a submit-event, and only then you would see the browser-made validation markers and tips.

Buttons and Toolbar

      form input[type = 'submit'],
      form input[type = 'reset']
      {
        margin-top: 0.4em;  /* space to form above */
        font-size: 140%;  /* using Unicode character as icon */
        width: 33%;  /* take a third of available space */
      }
      
      form input[type = 'submit']
      {
        color: green;
      }
      form input[type = 'reset']
      {
        color: red;
      }
      
      /* Variant label-top: put "Ok" to very right, "Cancel" will be at very left. */
      form :not(.left-label) + .button-container input[type = 'submit']
      {
        float: right;
      }
      
      /* Variant label-left: align "Cancel" and "Ok" to middle. */
      form .left-label + .button-container
      {
        text-align: center;
      }

Try out of you like this appearance of buttons and their bar. It is different for top-label and left-label variants. Such things depend on ergonomics, current fashion, company-rules, and last not least customer wishes. The "Submit" button right now establishes slowly, but still buttons top or bottom is undecided.

Horizontal Layout

      .horizontal-bar  /* flex-direction row */
      {
        display: flex !important;  /* else overwritten by table-cell! */
        align-items: center;  /* do not stretch vertically */
      }
    
      .horizontal-bar-natural  /* natural size */
      {
        flex: initial;
      }
      .horizontal-bar-stretch  /* takes all available space */
      {
        flex: auto;
      }

This is a general purpose layout done through flexbox. Look at the "Date" - "Years Since" fields above for an example. It is a frequent case that you have several fields in a line, and one of them should take the most available space. Put a CSS class="horizontal-bar" onto the container DIV, and one of class="horizontal-bar-natural" (just once!) or class="horizontal-bar-stretch" onto the elements in row.

General Purpose CSS

      /* Enable a fluid responsive display of forms and fieldsets. */
      div.responsive
      {
        display: inline-block; /* take just the space needed */
        vertical-align: top; /* align to top if any peer DIV is present */
      }
      
      /* Keep label and radio-button always on same layout row. */
      .sameline
      {
        white-space: nowrap;
      }

      /** Round border for fieldset. */
      fieldset
      {
        border-radius: 0.5em;
      }

If you want DIV containers to arrange themselves responsively side-by-side, you can set a CSS class="responsive" into it.

The CSS class="sameline" is necessary to tie radio buttons to their label in a way that never the label is in a different layout row than the button. This could be quite misleading! Look at this example:

The same when <span class="sameline"> was wrapped around label and input:


Entire Source Code

Press the arrow-button to see HTML and CSS source code of this example.

Click to see example source.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1"/>

    <title>HTML Form Test</title>
    
    <!-- Core form layout -->
      
    <style> /** Style labels of fields. */
    
      /* Smaller sans-serif font. */
      form label
      {
        font-size: 76%;
        font-family: sans-serif;
      }
      
      /* Add colon after any label. */
      form label::after
      {
        content: ":";
      }
      /* But not on label after radio. */
      form input[type = 'radio'] + label::after
      {
        content: "";
      }
    </style>
    
    <style> /** Style labels of required fields. */
    
      /* Set asterisk before required fields. */
      form label[required]::before
      {
        content: "* ";
      }
      
      /* Make labels for required fields better visible. */
      form label[required]
      {
        font-weight: bold;
      }
    </style>
    
    <style> /** DIV table layout. */
    
      form .table
      {
        display: table;
        width: 100%;  /* if parent is 100%, go with it */
      }
      
      form .table > div
      {
        display: table-row;
        line-height: 2em;  /* needed for vertical line-alignment */
      }
    </style>
    
    <style> /** Layout fields inside tables. */
    
      /* Stretch fields to full cell width, except checkboxes and radios. */
      form .table > div > input:not([type = 'radio']):not([type = 'checkbox']),
      form .table > div > textarea,
      form .table > div > select
      {
        width: 100%;
      }

      /* Avoid text fields hanging out at right due to content-box sizing. */
      form .table input,
      form .table textarea,
      form .table select
      {
        box-sizing: border-box;
      }
      
      /* Let the user resize big fields vertically. */
      form .table select[multiple],
      form .table textarea
      {
        resize: vertical;
      }
    </style>
    
    <style> /** Layout for left-label variant */
      
      form .left-label > div > label,
      form .left-label > div > input,
      form .left-label > div > select,
      form .left-label > div > textarea,
      form .left-label > div > div,
      form .left-label > div > fieldset
      {
        display: table-cell;  /* display elements horizontally */
        vertical-align: middle;  /* avoid vertical alignment problems */
        
        margin-top: 0.2em; margin-bottom: 0.2em; /* vertical line distance */
      }
    
      form .table.left-label > div label
      {
        text-align: right;
        padding-right: 0.2em;  /* distance to right field */
        
        vertical-align: top;   /* necessary for labels of nested fieldsets */
      }
    </style>

    <style> /* Layout for label-above variant. */
      /*
       * Because any input field has been stretched to 100%, the
       * preceding label will automatically go to top. Nothing to do!
       */
    </style>
    
    
    <!-- Optional additions -->

    <style> /** Make validation visible initially. */
    
      form :not(output):not(fieldset):invalid /* fieldset would also be red */
      {
        box-shadow: 1px 1px 1px red;
      }
    </style>
    
    <style> /* Button and toolbar styles. */
    
      form input[type = 'submit'],
      form input[type = 'reset']
      {
        margin-top: 0.4em;  /* space to form above */
        font-size: 140%;  /* using Unicode character as icon */
        width: 33%;  /* take a third of available space */
      }
      
      form input[type = 'submit']
      {
        color: green;
      }
      form input[type = 'reset']
      {
        color: red;
      }
      
      /* Variant label-top: put "Ok" to very right, "Cancel" will be at very left. */
      form :not(.left-label) + .button-container input[type = 'submit']
      {
        float: right;
      }
      
      /* Variant label-left: align "Cancel" and "Ok" to middle. */
      form .left-label + .button-container
      {
        text-align: center;
      }
    </style>
    
    <style> /** General purpose horizontal bar. */
    
      .horizontal-bar  /* flex-direction row */
      {
        display: flex !important;  /* else overwritten by table-cell! */
        align-items: center;  /* do not stretch vertically */
      }
    
      .horizontal-bar-natural  /* natural size */
      {
        flex: initial;
      }
      .horizontal-bar-stretch  /* takes all available space */
      {
        flex: auto;
      }
    </style>
    
    <style> /** General purpose layout styles. */
    
      /* Enable a fluid responsive display of forms and fieldsets. */
      div.responsive
      {
        display: inline-block; /* take just the space needed */
        vertical-align: top; /* align to top if any peer DIV is present */
      }
      
      /* Keep label and radio-button always on same layout row. */
      .sameline
      {
        white-space: nowrap;
      }
      
      /** Round border for fieldset. */
      fieldset
      {
        border-radius: 0.5em;
      }
    </style>
    

    <!-- Script to copy required and ids -->
    
    <script>
      /**
       * Avoid duplication of "required" and "id" attributes in HTML forms.
       * Any LABEL before an INPUT must get the INPUT's "id" value into its "for" attribute. 
       * Any LABEL before an INPUT must copy the INPUT's "required" if it exists. 
       * Sometimes the "required" attribute is not in the next element but in its sub-DOM.
       */
      function manageIdAndRequired()
      {
        var count = 1;  /* sequence number for unique ids */
        
        /** Searches for "required" in given element and all sub-elements. */
        function containsRequired(element) {
          if (element.getAttribute("required") !== null)
            return true; 
          
          var children = element.children;
          for (var i = 0; i < children.length; i++)
            if (containsRequired(children[i]))
              return true;
          
          return false;
        }
        /* end containsRequired() */
        
        /** Copies the optional "required" attribute to LABEL, chains it with INPUT's id. */
        function chainLabelToIdAndCopyRequired(label) {
          var nextSibling = label.nextElementSibling;
          var nextTagName = nextSibling ? nextSibling.tagName : undefined;
          
          /* previous is just for radio-buttons */
          var previousSibling = label.previousElementSibling;
          var previousTagName = previousSibling ? previousSibling.tagName : undefined;
          var isRadioLabel = (previousTagName === "INPUT");
          
          if (isRadioLabel ||
                nextTagName === "INPUT" ||
                nextTagName === "SELECT" ||
                nextTagName === "TEXTAREA" ||
                nextTagName === "DIV" ||
                nextTagName === "FIELDSET")
          {
            if ( ! isRadioLabel && containsRequired(nextSibling))
              label.setAttribute("required", "");  /* copy attribute to non-radios */
            
            if (label.getAttribute("for") === null) {  /* chain id */
              var sibling = isRadioLabel ? previousSibling : nextSibling;
              var id = sibling.getAttribute("id");
              
              if ( ! id && sibling.getAttribute("name")) {  /* generate id from name */
                var identity = "_"+count;
                count++;
                id = sibling.getAttribute("name")+identity;
                sibling.setAttribute("id", id);
              }
              
              if (id)
                label.setAttribute("for", id);
            }
          }
        }
        /* end chainLabelToIdAndCopyRequired() */
        
        /* Iterate all forms on page */
        var forms = document.getElementsByTagName("form");
        for (var f = 0; f < forms.length; f++) {
          var elementsInForm = forms[f].getElementsByTagName("*");
          for (var i = 0; i < elementsInForm.length; i++) {
            var element = elementsInForm[i];
            if (element.tagName === "LABEL")
              chainLabelToIdAndCopyRequired(element);
          }
        }
      }
      
      /* Execute function on page-load */
      window.addEventListener("load", manageIdAndRequired);
      
    </script>
    
  </head>
  
  
  <body>
  
    <form>
      <div class="responsive">
        <fieldset>
          <legend>HTML Form Layout Example</legend>
    
          <div class="table">
          <!-- <div class="table left-label"> -->
          
            <div>
              <label>Single Line</label>
              <input name="single-line-text" type="text" required value="Default value in text-field">
            </div>
          
            <div>
              <label>Password</label>
              <input name="hidden-password" type="password" required>
            </div>
          
            <div>
              <label>Number</label>
              <input name="numeric" type="number" required>
            </div>
          
            <div>
              <label>More Text</label>
              
              <fieldset>
                <div class="table left-label">
              
                 <div>
                    <label>E-Mail</label>
                    <input name="e-mail" type="email" required value="a@b.c">
                  </div>
                
                  <div>
                    <label>Telephone</label>
                    <input name="phone-number" type="tel" />
                  </div>
                
                  <div>
                    <label>URL</label>
                    <input name="url-address" type="url" value="protocol://host">
                  </div>
                
                  <div>
                    <label>Auto-Suggest</label>
                    <input list="browsers" name="auto-suggest"/>
                    <datalist id="browsers">
                      <option value="Firefox">
                      <option value="Chrome">
                      <option value="Opera">
                      <option value="Safari">
                      <option value="Edge">
                    </datalist>
                  </div>
                  
                  <div>
                    <label>Search</label>
                    <input name="search-pattern" type="search">
                  </div>
                
                  <div>
                    <label>Multiple Lines</label>
                    <textarea name="multiline-text" required>Default
value
in
text
area</textarea>
                  </div>
            
                </div> <!-- end table -->
              </fieldset> <!-- end cell -->
            </div> <!-- end row -->
          
            <div>
              <label>Date</label>
              <div class="horizontal-bar">
                <script>
                  function calculateYearsSince(date) {
                    var millis = Date.now() - new Date(date).getTime();
                    var years = millis / 1000 / 60 / 60 / 24 / 365;
                    var age = (years > 0) ? Math.floor(years) : Math.ceil(years);
                    document.getElementById("yearsSince").value = age;
                  }
                </script>
                <input class="horizontal-bar-stretch" name="day-only" type="date" required oninput="calculateYearsSince(this.value);">
                &nbsp;
                <label class="horizontal-bar-natural" for="yearsSince">Years Since</label>
                <output name="yearsSince" id="yearsSince"></output>
              </div>
            </div>
          
            <div>
              <label>Date / Time</label>
              
              <fieldset>
                <div class="table left-label">
                
                  <div>
                      <label>Time</label>
                      <input name="time-only" type="time">
                  </div>
                  
                  <div>
                      <label>Date / Time Local</label>
                      <input name="date-and-time-local" type="datetime-local">
                  </div>
                  
                  <div>
                      <label>Month</label>
                      <input name="month-only" type="month">
                  </div>
                  
                  <div>
                      <label>Week</label>
                      <input name="week-only" type="week">
                  </div>
                  
                </div> <!-- end table -->
              </fieldset> <!-- end cell -->
            </div> <!-- end row -->
          
            <div>
              <label>Other Inputs</label>
              
              <fieldset>
                <div class="table left-label">
                
                  <div>
                      <label>Radiobuttons</label>
                      <div> 
                        <!-- span.sameline: don't let the label and the button separate to different layout rows -->
                        <span class="sameline"><input name="gender" type="radio" value="male"   required><label>Male</label></span>
                        <span class="sameline"><input name="gender" type="radio" value="female" required><label>Female</label></span>
                        <span class="sameline"><input name="gender" type="radio" value="other"  required><label>Other</label></span>
                      </div>
                  </div>
                  
                  <div>
                      <label>More Radiobuttons</label>
                      <div>
                        <!-- div: display each radio-button on a new layout row -->
                        <div><input name="RGB" type="radio" value="red"  ><label>Red</label></div>
                        <div><input name="RGB" type="radio" value="green"><label>Green</label></div>
                        <div><input name="RGB" type="radio" value="blue" ><label>Blue</label></div>
                      </div>
                  </div>
      
                  <div>
                      <label>Single Select</label>
                      <select name="single-select" required>
                        <optgroup label="Compiled">
                          <option value="java">Java</option>
                          <option value="c++">C++</option>
                        </optgroup>
                        <optgroup label="Interpreted">
                          <option value="es6">EcmaScript</option>
                          <option value="python">Python</option>
                        </optgroup>
                      </select>
                  </div>
                  
                  <div>
                      <label>Multiple Select</label>
                      <select name="multi-select" multiple required>
                        <option value="one">Use</option>
                        <option value="two">Shift</option>
                        <option value="three">and</option>
                        <option value="four">Control</option>
                        <option value="five">Keys</option>
                        <option value="six">with</option>
                        <option value="seven">Mouse</option>
                      </select>
                  </div>
                  
                  <div>
                      <label>Range</label>
                      <input name="slider" type="range">
                  </div>
                  
                  <div>
                      <label>Color</label>
                      <input name="colour" type="color">
                  </div>
                  
                  <div>
                      <label>File</label>
                      <input name="file-upload" type="file">
                  </div>
                  
                </div> <!-- end table -->
              </fieldset> <!-- end cell -->
            </div> <!-- end row -->
          
            <div>
              <label>License Read</label>
              <input name="boolean" type="checkbox" required>
            </div>
          
          </div> <!-- end table -->
          
          
          <div class="button-container">
            <input title="Reset" type="reset" value="&#x2718;">
            <input title="Submit" type="submit" value="&#x2714;">
          </div>
        
        
        </fieldset> <!-- end top fieldset -->
      </div> <!-- end div.responsive -->
          
    </form>
    
  </body>
</html>



Keine Kommentare: