Skip to main content Accessibility Feedback

Progressively enhancing forms with an HTML Web Component (part 1)

Yesterday, I gave an overview of how I approach adding dynamic content to a static, server-rendered website.

Today, we’re going to dig into one piece of that puzzle: ajaxy forms. Let’s dig in!

Forms FTW!

Forms are awesome! With an HTML form, you can support JavaScript-free user iterations.

For example, let’s say I want to let users add an item to a list. I can include a <form> element with a field to add a new item.

I’ll give the form an [action] that points to a PHP file (this is what processes the form request and does stuff with the data), and a [method] attribute of POST since I want to send data.

<form action="/path/to/add-item.php" method="POST">
	<label for="item">The New Item</label>
	<input type="text" name="item" id="item" required>
	<button>Add Item</button>
	<div role="status"></div>
</form>

By default, when the user submits the form, the page will redirect to the file specified in the [action] attribute. That file will automatically receive any field value that has a [name] attribute.

When its done, it can display a message or redirect the user back to the page they were on.

In my personal web apps, I use a Web Component to take over and send the data with JavaScript, without leaving the page. Users get a working experience always, and a better one once the JS loads.

The PHP to make this work

I love PHP! Like, unapologetically love it!

It’s not perfect. No language is. But it runs nearly everywhere, has great docs, a ton of community support, and just works, pretty much everywhere.

My backend files have a few helper functions to get and respond to data.

First, I have two functions I use for processing requests. The get_method() function gets the HTTP method used for the request. The get_request_data() method combines the request body (as JSON or a FormData object) and any query string parameters into an array of keys and values.

<?php

/**
 * Get the API method
 * @return String The API method
 */
function get_method () {
	return $_SERVER['REQUEST_METHOD'];
}

/**
 * Get data object from API data
 * @return Object The data object
 */
function get_request_data () {
	return array_merge(empty($_POST) ? array() : $_POST, (array) json_decode(file_get_contents('php://input'), true), $_GET);
}

I also have a function, is_ajax(), that checks if the request was a direct file load or an ajax request made with JavaScript.

<?php

/**
 * Check if request is Ajax
 * @return boolean If true, request is ajax
 */
function is_ajax () {
	return !empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
}

And I have a function I use to send back responses: send_response().

This accepts the $response to send (as an array), the status $code to use, a URL to $redirect to, and $query string parameter to use on the redirect (for displaying status messages with server-rendered PHP if JavaScript isn’t working).

<?php

/**
 * Send an API response
 * @param  array   $response The API response
 * @param  integer $code     The response code
 * @param  string  $redirect A URL to redirect to
 * @param  string  $query    The string to use for the redirect query string
 */
function send_response ($response, $code = 200, $redirect = null, $query = 'message') {

	// If ajax, respond
	if (is_ajax()) {
		http_response_code($code);
		die(json_encode($response));
	}

	// If redirect URL, redirect
	$url = array_key_exists('url', $response) ? $response['url'] : $redirect;
	$message = !array_key_exists('url', $response) && !empty($redirect) ? (strpos($redirect, '?') === false ? '?' : '&') . $query . '=' . $response['message'] . '&success=' . ($code === 200 ? 'true' : 'false') : '';
	if (!empty($url)) {
		header('Location: ' . $url . $message);
		exit;
	}

	// Otherwise, show message
	die($response['message']);

}

I also love using flat JSON file storage for my projects.

I have a server directory just for storing data. It includes an .htaccess file that blocks all web traffic.

deny from all

And I use two helper functions to read and write those files…

<?php

/**
 * Get file
 * @param  String  $filename  The filename
 * @param  *       $fallback  Fallback content if the file isn't found
 * @param  Boolean $as_string Return string instead of decoded object
 * @return *                The file content
 */
function get_file ($filename, $fallback = '{}') {

	// File path
	$path = dirname(__FILE__) . '/hidden-stuff-path/' . $filename . '.json';

	// If file exists, return it
	if (file_exists($path)) {
		$file = file_get_contents($path);
		return json_decode($file, true);
	}

	// Otherwise, return a fallback
	return json_decode($fallback, true);

}

/**
 * Create/update a file
 * @param String $filename The filename
 * @param *      $content  The content to save
*/
function set_file ($filename, $content, $fallback = '{}') {

	// File path
	$path = dirname(__FILE__) . '/hidden-stuff-path/' . $filename . '.json';

	// If there's no content but there's a fallback, use it
	if (empty($content)) {
		file_put_contents($path, $fallback);
		return;
	}

	// Otherwise, save the content
	file_put_contents($path, json_encode($content));

}

Let’s look at how to actually use all this stuff!

Putting it all together

Let’s say I’m adding an item to the list, using the form I shared above.

I start by getting the HTTP method used, and the request data. I also setup a URL to $redirect to in responses.

<?php

// Get the method and data
$method = get_method();
$data = get_request_data();

// The redirect URL
$redirect = 'https://url-of-my-awesome-site.com/add-items';

I make sure the $method is an allowed request type, and the $data includes any required values.

<?php

// Get the method and data
$method = get_method();
$data = get_request_data();

// The redirect URL
$redirect = 'https://url-of-my-awesome-site.com/add-items';

// Only support POST method
if ($method !== 'POST') {
	send_response(array(
		'message' => 'This method is not supported.',
	), 400, $redirect);
}

// Make sure all required data is provided
if (!isset($data['item']) || empty($data['item'])) {
	send_response(array(
		'message' => 'Please provide an item to add.',
	), 400, $redirect);
}

If I get past this point, the user has provided the data I need, and I can write it to storage.

Let’s assume for our purposes I have a file in my /hidden-stuff-path (not the real directory path for my storage) called items.json, and it stores the items the user adds.

I’ll use get_file() to read the file, modify it, and then use set_file() to write it back down.

<?php

// ...

// Get the user items, with an empty array as a fallback
$user_items = get_file('items', '[]');

// Add the new item to it
$user_items[] = $data['item'];

// Save the data back down
set_file('items', $user_items);

In a real-world application, I’ll typically start creating helper functions for tasks like this (like add_item_to_user_account($username, $item)).

Once saved, I can send back a success message.

<?php

// ...

// Save the data back down
set_file('items', $user_items);

// Send a success response
send_response(array(
	'message' => 'Item added to the list!'
), 200, $redirect);

A real application will probably have some additional checks to sanitize data before saving it or make sure it conforms to the required format, but this is the basics of how I handle all of my personal apps and my membership portal.

Showing the status message with PHP

If JavaScript is running, a Web Component sends the request, gets the response, and shows a status message in the [role="status"] element.

But if a full server reload happened, the send_response() method adds a query string parameter with the message to the $redirect URL. By default, it has a value of message, unless you pass in a different key to use.

In my apps, I’ll use that to display a message on the page after redirect.

<form action="/path/to/add-item.php" method="POST">
	<label for="item">The New Item</label>
	<input type="text" name="item" id="item" required>
	<button>Add Item</button>
	<div role="status"><?php if (isset($_GET['message'])) : ?><?php echo htmlspecialchars($_GET['message']); ?><?php endif; ?></div>
</form>

I also pass the query string through the PHP htmlspecialchars() method to encode the string and reduce the risk of cross-site scripting attacks.

Enhancing with JavaScript

So… how do you enhance this with JavaScript? That’s tomorrow’s article!