Sekrab Garage

Taming Angular forms

Validation style final tweaks

AngularDesign January 17, 25
Subscribe to Sekrab Parts newsletter.
Series:

Angular forms

Creating a form has always been a terrible task I keep till the end. And I usually just start with copying an existing form file. Today, I decided to do something about it. Here is a series to dig into Angular forms, in their simplest shape, and create reusable component to control validation.
  1. 14 days ago
    Using CSS to validate Angular reactive form
  2. 9 days ago
    Angular form validation directive
  3. 3 days ago
    Angular form field types
  4. one day ago
    Angular validation common functions
  5. one day ago
    Validation style final tweaks

To add the missing styles to make it as functional as possible, need to keep reminding ourselves of a golden rule:

Do not assume.

This means we can add relative paddings and margins, borders and colors, using CSS variables, but we cannot dictate how the checkbox should look like. That’s a project style, not an element style. (There are schools like material design that stuff their noses into every element, making it impossible to use single components individually.)

anchorCheckbox

Starting with the checkbox, all we need to do is swap locations of checkbox and label. Then just let the outer project design its own checkbox instead. There are two solutions, a smart one, and a too smart one. The too smart one looks like this:

.cr-field {
   /* target previous silbing */
  .cr-label:has(~ [type="checkbox"]) {
    /* important to remove transform in all cases */
    transform: none!important;
    inset-block-start: 0;
    inset-inline-start: 0;
    padding-inline-start: 1.8rem;
    position: relative;
    display: inline-block;
    background: none;
    cursor: pointer;
  }

  .cr-input[type="checkbox"] {
    position: absolute;
    inset-inline-start: 0;
  }
}

The simpler way is to introduce a new type property for the cr-field directly, assigned explicitly. Implicit assignment is also "too smart."

// input.partial
template: `
    <div class="{{ cssPrefix }}-field {{ typeCss }}" [class.cr-invalid-form]="invalidForm">
    // ...
`
@Input() type!: string;

get typeCss(): string {
    return this.type ? `${this.cssPrefix}-${this.type}` : '';
}

Then we write a not too-smart CSS:

.cr-field.cr-checkbox {
  .cr-label {
   /* same as above */
  }
  .cr-input {
   /* same as above */
  }
}

The selector is simpler and we have a bit more room to style other things, like the required asterisk, the help text, and the feedback text. See, sometimes it pays off to be dumber.

Image on Sekrab Garage

The CSS for this solution:

.cr-field.cr-checkbox {
  .cr-label {
    /* important to remove transform in all cases */
    transform: none!important;
    inset-block-start: 0;
    inset-inline-start: 0;
    padding-inline-start: 1.8rem;
    position: relative;
    display: inline-block;
    background: none;
    cursor: pointer;
  }
  .cr-input {
    position: absolute;
    inset-inline-start: 0;
  }
  .cr-feedback {
    margin-block-start: 0;
    float: none;
  }
  .cr-required {
    position: static;
  }
}

anchorTesting the edges

One of the scenarios we went through is where the required asterisk was out of view, we already designed it to be positioned on the extreme right. With what we have so far, can we make it look good without touching the library component and shared css?

Here is the example of Expiry date, the solution to place the asterisk within context is simply add enough style to the container.

/* fix the container width to be c-5 and display block */
<cr-input placeholder="Expiration" error="Add a date in the future" class="c-5 dblock">
  <input type="hidden" crinput id="mmyy" [required]="true" pattern="[0-9]{4}" formControlName="mmyy" />
  /* also fix the width of inner fields to c-6 */
  <cr-product-expiry (onValue)="expirationValue($event)"></cr-product-expiry>
</cr-input>

The result is like this:

Image on Sekrab Garage

Three things I’ve done:

  • Changed the container width to be at the percentage width I desired, and changed its display to block (Angular components display by default as contents )
  • I changed the inner component width to be 50% each (makes more sense)
  • Then changed the message to: Add a date in the future. This covers both rules: expired date and required value.

So our component held. No changes needed. That is a victory.

You may need to test extra scenarios and update the input css to be as simple as possible, so that it won’t break in the future.

anchorTesting another edge case: Nice looking checkbox

Here is another edge case. Every project has its own style for checkboxes. Given the CSS we have developed thus far, let's see if we can push our single checkbox from the outside world without breaking it. Let's use MDN example.

/* adding the same css with a proper random selector to our project */
.gr-something .cr-field.cr-checkbox {

  .cr-input {
    /* remove default appearance **/
    appearance: none;
    width: 44px;
    height: 24px;
    border-radius: 12px;
    transition: all 0.4s;
  }
  .cr-input::before {
    width: 16px;
    height: 16px;
    border-radius: 9px;
    background-color: var(--sh-black, #000);
    content: '';
    position: absolute;
    inset-block-start: 3px;
    inset-inline-start: 4px;
    transition: all 0.4s;
  }
  .cr-input:checked {
    background-color: var(--sh-yellow, #ffaa00);
    transition: all 0.4s;
  }
  .cr-input:checked::before {
    inset-inline-start: 22px;
    transition: all 0.4s;
  }
  .cr-label {
    /* adjust padding of label */
    padding-inline-start: 4.2rem;
  }
}

The above is the MDN example with few adjustments. We just had to pay attention to the selector so that we don't resolve to !important issues. Applying it is as easy as applying class to selector:

<cr-input  type="checkbox" class="gr-something" ...>
 <input type="checkbox" crinput  ...>
</cr-input>

This looks like the following:

Image on Sekrab Garage

Another victory! See, if we were too smart about our selectors, this would have turned into spaghetti.

anchorHidden fields

Hidden inputs simplify validation, that would otherwise be challenging. If the validation is within context of the cr-field it’s straight forward, like our previous example of the Expiration date.

<cr-input placeholder="Expiration" error="This is expired">
  <input type="hidden" crinput id="mmyy" pattern="[0-9]{4}" formControlName="mmyy" />
  // some component with MM and YY fields
  <cr-product-expiry (onValue)="expirationValue($event)"></cr-product-expiry>
</cr-input>

If the hidden input is not within context of a field, and it carries out a cross-form update and validation, then the cr-field has nothing but the feedback. The solution is to introduce the type hidden.

Here is a quick example, let’s say if the operating system is MAC, the minimum version is 5.

// example form components
<div class="spaced">
  <cr-input placeholder="Operating system">
    <select crinput id="os" formControlName="os" [required]="true" (change)="updatePlug()">
      <option value="">Select</option>
      <option value="1">Windows</option>
      <option value="2">Mac</option>
      <option value="3">Linux</option>
      <option value="4">Android</option>
    </select>
  </cr-input>
</div>

<cr-input placeholder="Version">
  <input crinput type="number" id="version" formControlName="version" (change)="updatePlug()" />
  <ng-container helptext>OS version</ng-container>
</cr-input>

Updating the fields updates a hidden field plugs with either a value or null

template: `  
// ...
<cr-input placeholder="" error="Not allowed to have Mac version less than 5">
  <input type="hidden" crinput id="plugs" formControlName="plugs" [required]="true" >
</cr-input>`

// in code
updatePlug() {
  // on change of form input, update hidden field
  const os = this.fg.get('os').value;
  const version = this.fg.get('version').value;
  if (os === '2' && version < 5) {
    this.fg.get('plugs').setValue(null);
  } else {
    this.fg.get('plugs').setValue('plug'+ os + version);
  }
}

So the only thing I want to take care of is hiding the required asterisk, and making the feedback more of a none-floating element. Any other styling, need to happen outside the component.

// let's pass type:hidden
<cr-input type="hidden" error="Not allowed to have Mac version less than 5">
  <input type="hidden" crinput id="plugs" formControlName="plugs" [required]="true" >
</cr-input>

And we cater for the cr-hidden style:

/* input.css */
.cr-field.cr-hidden {
  .cr-label {
    display: none;
  }
  .cr-input[required] ~ .cr-required {
    display: none;
  }
  .cr-feedback {
    float: none;
    margin-block-start: 0;
    margin-inline-start: 0;
  }
}

This will look like this

Image on Sekrab Garage

Of course, my validation is a bit dumbed down for demonstration purposes. The hidden field can have any validation (a pattern for example), you can use setErrors instead of setValue, and you can customize the error message accordingly. That would not affect the CSS though.

anchorAuto-filled fields

The last problem to fix is auto-filled fields, like username and password, where you don’t want the placeholder label to appear on top of the field. Ever. To fix that we introduce a new type static to prevent the floating animation.

Image on Sekrab Garage

Although this isn't stubborn but it would be nice to have another style for none-floating labels in general.

<cr-input placeholder="Username" type="static">
  <input crinput type="text" autocomplete="username" id="username"
   formControlName="username" [required]="true" />
</cr-input>
<cr-input placeholder="Password" type="static">
  <input crinput type="password" autocomplete="current-password" id="pwd" 
  formControlName="pwd" [required]="true" />
</cr-input>

The CSS then defines the new type:

/* input.css */
.cr-field.cr-static {
  /* force floating label even if empty */
  .cr-label:has(~ .cr-input:placeholder-shown) {
    transform: translateY(-100%) scale(0.8);
  }
}

And this is as good as it gets. Notice that if emptied, that label will not float inside the field. Meh, cheap price.

Image on Sekrab Garage

That's it. This is a wrap.

Bonus: standalone imports

Another thing we can do to make this easier to use, is place them in an exported const to import together.

export const InputComponent = [InputDirective, CrInputPartial];

Then we can import then together

imports: [...InputComponent],

anchorConclusion

To recap, the initial target was the following:

  • Use native HTML input elements.
    We did, with only the introduction a formControlName and a directive
  • Validation rules should be kept to minimum
    We only manipulated the error message, and contained basic patterns and common functions
  • Keep the Angular form loose (do not reinvent the wheel)
    The input is content-projected, it belongs to the original form
  • Use attributes instead of Form builder (unobtrusive)
    A directive that reads different attributes was accomplished
  • Keep form submission loose to allow as much flexibility
    Since the form is not part of our directive, nothing is imposed on it
  • Minimum styling allowing full replacement.
    We tried. I think we managed.

That's it. Let Gaza Live.

  1. 14 days ago
    Using CSS to validate Angular reactive form
  2. 9 days ago
    Angular form validation directive
  3. 3 days ago
    Angular form field types
  4. one day ago
    Angular validation common functions
  5. one day ago
    Validation style final tweaks