diff --git a/README.md b/README.md
index 18913b2..894ef84 100644
--- a/README.md
+++ b/README.md
@@ -245,6 +245,66 @@ Components can be called as macros:
- `hmpoTextCount(ctx, params)`: Generates a text area with a character or word count limit, applying validation, error messages, accessibility attributes, and custom messages for under, at, and over-limit states.
+- `hmpoTaskList(ctx, params, fieldName)`: Renders a GOV.UK task list showing task titles, status (completed/incomplete), and href links. All labels and status text are resolved from locale strings using the provided field name. Used for displaying application progress or workflow steps in a display-only format.
+
+#### Task list usage
+
+Define the field with a `type` of `task-list`, a `statuses` map, and an array of `tasks`:
+
+```js
+// fields.js
+module.exports = {
+ taskList: {
+ type: 'task-list',
+ statuses: {
+ default: 'blue', // blue tag for incomplete tasks
+ completed: false // false = plain text, no tag
+ },
+ tasks: [
+ { id: 'personalDetails', href: '/apply/your-name', statusField: 'personalDetailsComplete' },
+ { id: 'documents', href: '/apply/upload-passport', statusField: 'documentsComplete' }
+ ]
+ }
+}
+```
+
+Add matching locale strings:
+
+```yaml
+# locales/en/fields.yml
+taskList:
+ label: Your application
+ statuses:
+ default: Incomplete
+ completed: Completed
+ tasks:
+ personalDetails:
+ title: Personal details
+ documents:
+ title: Documents
+```
+
+In the corresponding steps configuration, use `noPost`, `checkJourney: false`, and `recordJourneyHistory` on the hub step, and `setValuesOnSave` on each section's final step:
+
+```js
+// steps.js
+module.exports = {
+ '/task-list': {
+ fields: ['taskList'],
+ noPost: true,
+ checkJourney: false,
+ recordJourneyHistory: true
+ },
+ '/upload-passport': {
+ editable: true,
+ next: 'task-list',
+ setValuesOnSave: [{ key: 'documentsComplete', value: 'completed' }]
+ }
+}
+```
+
+The `statuses` map keys correspond to session values stored via `setValuesOnSave`. When a task's `statusField` value matches a key in `statuses`, that status is rendered. The value in the map is a [GOV.UK tag colour](https://design-system.service.gov.uk/components/tag/) (e.g. `'blue'`, `'light-blue'`) or `false` for plain text with no tag.
+
### Field parameters
Most govuk-frontend parameters can be specified in the fields config, or supplied to the component directly.
diff --git a/components/hmpo-all.njk b/components/hmpo-all.njk
index e4f34c4..c0b1af3 100644
--- a/components/hmpo-all.njk
+++ b/components/hmpo-all.njk
@@ -14,6 +14,7 @@
{% include "hmpo-radios/macro.njk" %}
{% include "hmpo-select/macro.njk" %}
{% include "hmpo-submit/macro.njk" %}
+{% include "hmpo-task-list/macro.njk" %}
{% include "hmpo-text/macro.njk" %}
{% include "hmpo-textarea/macro.njk" %}
{% include "hmpo-warning-text/macro.njk" %}
diff --git a/components/hmpo-field/macro.njk b/components/hmpo-field/macro.njk
index a908260..394a7f1 100644
--- a/components/hmpo-field/macro.njk
+++ b/components/hmpo-field/macro.njk
@@ -38,6 +38,9 @@
{%- elif field.type == "wordcount"%}
{% from "../hmpo-word-count/macro.njk" import hmpoWordCount %}
{% set component = hmpoWordCount %}
+ {%- elif field.type == "task-list" %}
+ {% from "../hmpo-task-list/macro.njk" import hmpoTaskList %}
+ {{ hmpoTaskList(ctx, field, fieldName) }}
{%- endif %}
{%- if component %}
diff --git a/components/hmpo-task-list/macro.njk b/components/hmpo-task-list/macro.njk
new file mode 100644
index 0000000..b820b4f
--- /dev/null
+++ b/components/hmpo-task-list/macro.njk
@@ -0,0 +1,44 @@
+
+{% macro hmpoTaskList(ctx, params, fieldName) %}
+ {%- set translate = ctx("translate") %}
+ {%- set values = ctx("values") or {} %}
+
+ {%- set labelText = translate("fields." + fieldName + ".label", { default: null }) if fieldName else null %}
+ {%- if labelText %}
+
{{ labelText }}
+ {%- endif %}
+
+ {%- set taskItems = [] %}
+ {%- for task in params.tasks or [] %}
+ {%- set statusValue = values[task.statusField] %}
+ {%- set statusKey = statusValue if (statusValue and statusValue in params.statuses) else 'default' %}
+ {%- set statusColour = params.statuses[statusKey] %}
+ {%- set statusText = translate("fields." + fieldName + ".statuses." + statusKey) %}
+
+ {%- if statusColour %}
+ {%- set taskStatus = { tag: { text: statusText, classes: "govuk-tag--" + statusColour } } %}
+ {%- else %}
+ {%- set taskStatus = { text: statusText } %}
+ {%- endif %}
+
+ {%- set titleKey = "fields." + fieldName + ".tasks." + task.id + ".title" %}
+ {%- set translatedTitle = translate(titleKey) %}
+ {%- set taskItem = {
+ title: {
+ text: translatedTitle
+ },
+ href: task.href,
+ status: taskStatus
+ } %}
+
+ {%- set taskItems = taskItems.concat([taskItem]) %}
+ {%- endfor %}
+
+ {%- from "govuk/components/task-list/macro.njk" import govukTaskList %}
+ {{ govukTaskList({
+ idPrefix: fieldName | default("task-list"),
+ items: taskItems,
+ classes: params.classes,
+ attributes: params.attributes
+ }) }}
+{% endmacro %}
diff --git a/components/hmpo-task-list/spec.macro.js b/components/hmpo-task-list/spec.macro.js
new file mode 100644
index 0000000..5aa1dc5
--- /dev/null
+++ b/components/hmpo-task-list/spec.macro.js
@@ -0,0 +1,103 @@
+'use strict';
+
+describe('hmpoTaskList', () => {
+ let locals, tasks, statuses;
+
+ const renderTask = (params, fieldName = 'taskList', context = locals) => {
+ return render.withLocale({
+ string: '{% from "hmpo-task-list/macro.njk" import hmpoTaskList %}{{ hmpoTaskList(ctx, params, "' + fieldName + '") }}',
+ ctx: true
+ }, Object.assign({}, context, { params }));
+ };
+
+ beforeEach(() => {
+ locals = { values: {} };
+ statuses = {
+ 'default': 'blue',
+ completed: false
+ };
+ tasks = [
+ { id: 'taskOne', href: '/task-one', statusField: 'taskOneStatus' },
+ { id: 'taskTwo', href: '/task-two', statusField: 'taskTwoStatus' }
+ ];
+ });
+
+ it('renders a task list with items', () => {
+ const $ = renderTask({ tasks, statuses });
+ expect($('.govuk-task-list__item').length).to.equal(2);
+ });
+
+ it('renders task titles from locale', () => {
+ const $ = renderTask({ tasks: [tasks[0]], statuses });
+ expect($('.govuk-task-list__link').text().trim()).to.equal('Task one');
+ });
+
+ it('renders task href links', () => {
+ const $ = renderTask({ tasks: [tasks[0]], statuses });
+ expect($('.govuk-task-list__link').attr('href')).to.equal('/task-one');
+ });
+
+ it('renders default status as a tag when statusField value is not set', () => {
+ const $ = renderTask({ tasks: [tasks[0]], statuses });
+ const $tag = $('.govuk-tag');
+ expect($tag.text().trim()).to.equal('Incomplete');
+ expect($tag.attr('class')).to.contain('govuk-tag--blue');
+ });
+
+ it('renders completed status as plain text when statusField value matches', () => {
+ locals.values = { taskOneStatus: 'completed' };
+ const $ = renderTask({ tasks: [tasks[0]], statuses });
+ expect($('.govuk-task-list__status').text().trim()).to.equal('Completed');
+ expect($('.govuk-tag').length).to.equal(0);
+ });
+
+ it('renders mixed statuses for multiple tasks', () => {
+ locals.values = { taskOneStatus: 'completed' };
+ const $ = renderTask({ tasks, statuses });
+ const $items = $('.govuk-task-list__item');
+ expect($items.eq(0).find('.govuk-task-list__status').text().trim()).to.equal('Completed');
+ expect($items.eq(1).find('.govuk-tag').text().trim()).to.equal('Incomplete');
+ });
+
+ it('falls back to default when statusField value does not match any status key', () => {
+ locals.values = { taskOneStatus: 'unknownState' };
+ const $ = renderTask({ tasks: [tasks[0]], statuses });
+ const $tag = $('.govuk-tag');
+ expect($tag.text().trim()).to.equal('Incomplete');
+ expect($tag.attr('class')).to.contain('govuk-tag--blue');
+ });
+
+ it('supports additional statuses like inProgress', () => {
+ statuses.inProgress = 'light-blue';
+ locals.values = { taskOneStatus: 'inProgress' };
+ const $ = renderTask({ tasks: [tasks[0]], statuses });
+ const $tag = $('.govuk-tag');
+ expect($tag.text().trim()).to.equal('In progress');
+ expect($tag.attr('class')).to.contain('govuk-tag--light-blue');
+ });
+
+ it('idPrefix is derived from fieldName', () => {
+ const $ = renderTask({ tasks: [tasks[0]], statuses });
+ expect($('.govuk-task-list__status').attr('id')).to.equal('taskList-1-status');
+ });
+
+ it('idPrefix defaults to task-list when no fieldName provided', () => {
+ const $ = render.withLocale({ component: 'hmpoTaskList', params: { tasks: [tasks[0]], statuses }, ctx: true }, locals);
+ expect($('.govuk-task-list__status').attr('id')).to.equal('task-list-1-status');
+ });
+
+ it('renders a section heading from fieldName label key', () => {
+ const $ = renderTask({ tasks, statuses });
+ expect($('h2').text().trim()).to.equal('Your application');
+ });
+
+ it('does not render a section heading when no fieldName is provided', () => {
+ const $ = render.withLocale({ component: 'hmpoTaskList', params: { tasks, statuses }, ctx: true }, locals);
+ expect($('h2').length).to.equal(0);
+ });
+
+ it('renders an empty task list when tasks is not provided', () => {
+ const $ = renderTask({ statuses });
+ expect($('.govuk-task-list__item').length).to.equal(0);
+ });
+});
diff --git a/components/spec.task-list-template.js b/components/spec.task-list-template.js
new file mode 100644
index 0000000..ce4eb96
--- /dev/null
+++ b/components/spec.task-list-template.js
@@ -0,0 +1,47 @@
+'use strict';
+
+describe('task list template', () => {
+ let locals;
+
+ beforeEach(() => {
+ locals = {
+ options: {
+ route: '/task-list',
+ fields: {
+ taskList: {
+ type: 'task-list',
+ statuses: {
+ 'default': 'blue',
+ completed: false
+ },
+ tasks: [
+ { id: 'taskOne', href: '/task-one', statusField: 'taskOneStatus' }
+ ]
+ }
+ }
+ },
+ values: {}
+ };
+ });
+
+ it('renders without throwing', () => {
+ expect(() => {
+ render({ template: 'task-list-template.njk' }, locals);
+ }).to.not.throw();
+ });
+
+ it('renders the task list', () => {
+ const $ = render({ template: 'task-list-template.njk' }, locals);
+ expect($('.govuk-task-list').length).to.equal(1);
+ });
+
+ it('renders the submit button', () => {
+ const $ = render({ template: 'task-list-template.njk' }, locals);
+ expect($('.govuk-button').length).to.equal(1);
+ });
+
+ it('does not render a form', () => {
+ const $ = render({ template: 'task-list-template.njk' }, locals);
+ expect($('form').length).to.equal(0);
+ });
+});
diff --git a/components/task-list-template.njk b/components/task-list-template.njk
new file mode 100644
index 0000000..74e6e47
--- /dev/null
+++ b/components/task-list-template.njk
@@ -0,0 +1,16 @@
+{% extends "app-template.njk" %}
+
+{% from "hmpo-field/macro.njk" import hmpoField %}
+{% from "hmpo-submit/macro.njk" import hmpoSubmit %}
+
+{% block mainContentBody %}
+ {{ super() }}
+
+ {% for fieldName, field in options.fields %}
+ {{ hmpoField(ctx, fieldName) }}
+ {% endfor %}
+
+ {% block submitButton %}
+ {{ hmpoSubmit(ctx) }}
+ {% endblock %}
+{% endblock %}
diff --git a/test/locale.json b/test/locale.json
index 570af9a..8191c27 100644
--- a/test/locale.json
+++ b/test/locale.json
@@ -33,6 +33,22 @@
},
"legendtest": {
"legend": "Legend text"
+ },
+ "taskList": {
+ "label": "Your application",
+ "statuses": {
+ "default": "Incomplete",
+ "completed": "Completed",
+ "inProgress": "In progress"
+ },
+ "tasks": {
+ "taskOne": {
+ "title": "Task one"
+ },
+ "taskTwo": {
+ "title": "Task two"
+ }
+ }
}
}
}