Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/user-guides/cms/pages/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ The other settings:
| Placeholder (optional) | Pale example text inside the box. |
| Button text | The text on the button. |
| Put the button below the search box | Tick this when the box is in a narrow column. A button beside the box would leave little room to type. |
| Show a clear button in the search box | Adds a cross at the end of the box. It appears once the visitor has typed something, and pressing it empties the box so they can start again. |
| Form action URL | The page that shows the results. Leave it as `/search` unless you have built a results page of your own. |

## Show matches as people type: instant search
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public static class WidgetRegistry
new("card", "Card", ContributesToNav: false, """{"title":"","body":"","href":""}"""),
new("summarylist", "Summary list", ContributesToNav: false, """{"rows":[]}"""),
new("published", "Published callout", ContributesToNav: false, """{"text":""}"""),
new("search", "Search", ContributesToNav: false, """{"label":"Search","placeholder":"","action":"/search","buttonText":"Search","scope":"","scopePageIds":"","searchIn":"site","instant":"false","showButton":"true","buttonBelow":"false","noResultsText":"No results found"}"""),
new("search", "Search", ContributesToNav: false, """{"label":"Search","placeholder":"","action":"/search","buttonText":"Search","scope":"","scopePageIds":"","searchIn":"site","instant":"false","showButton":"true","buttonBelow":"false","clearButton":"false","noResultsText":"No results found"}"""),
new("results", "Search results", ContributesToNav: false, """{"scope":"","scopePageIds":"","emptyText":"No results found."}"""),
new("pagenav", "Page navigation", ContributesToNav: false, """{"mode":"headings","childrenParentPath":"","showSearch":false,"searchPath":"","searchLabel":"Search","h1":"false","h2":"true","h3":"true","h4":"false","h5":"false","h6":"false"}""")
];
Expand Down
48 changes: 24 additions & 24 deletions src/DfE.CheckPerformanceData.Web/Data/Import/cms-guide.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
// the author can ask for the button underneath. A widget saved before the option existed has
// no value and keeps the button beside the box.
var buttonBelow = Model.GetBool("buttonBelow") ?? false;
// A button at the end of the box that empties it. A widget saved before the option existed has
// no value and no clear button.
var clearButton = Model.GetBool("clearButton") ?? false;
// Two search widgets on one page must not share an input id: the label's `for` keys off it,
// and so do the listbox and status ids accessible-autocomplete derives from it — duplicates
// would point a screen reader at the wrong menu. Counted per request; the first widget keeps
Expand Down Expand Up @@ -72,8 +75,22 @@
<div class="govuk-form-group">
<label class="govuk-label govuk-label--s" for="@inputId">@label</label>
<div class="cypmd-search__row @(buttonBelow ? "cypmd-search__row--stacked" : null)">
<input class="govuk-input cypmd-search__input" id="@inputId" name="q" type="search"
placeholder="@placeholder" autocomplete="off" />
<div class="cypmd-search__field">
<input class="govuk-input cypmd-search__input" id="@inputId" name="q" type="search"
placeholder="@placeholder" autocomplete="off" />
@* A reset button empties the box without JavaScript. With it, search-clear.js hides
the button while the box is empty and puts the visitor back in the box. *@
@if (clearButton)
{
<button class="cypmd-search__clear" type="reset" data-cypmd-search-clear>
<span class="govuk-visually-hidden">Clear search</span>
<svg class="cypmd-search__clear-icon" aria-hidden="true" focusable="false"
width="16" height="16" viewBox="0 0 16 16">
<path d="M2 2 14 14M14 2 2 14" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
</button>
}
</div>
@if (showButton)
{
<button class="govuk-button cypmd-search__button" data-module="govuk-button" type="submit">
Expand All @@ -95,3 +112,7 @@
{
<script src="~/js/instant-search.js" asp-append-version="true"></script>
}
@if (clearButton)
{
<script src="~/js/search-clear.js" asp-append-version="true"></script>
}
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@
// No stored value means a widget saved before the option existed,
// which has the button beside the box, so the box starts unticked.
var sButtonBelow = w.GetBool("buttonBelow") ?? false;
// No stored value means a widget saved before the option existed,
// which has no clear button, so the box starts unticked.
var sClearButton = w.GetBool("clearButton") ?? false;
<div class="govuk-form-group">
<label class="govuk-label" for="si-@Model.Path">Search in</label>
<select class="govuk-select" id="si-@Model.Path" name="props[searchIn]"
Expand Down Expand Up @@ -279,6 +282,23 @@
Use this in a narrow column, where a button beside the box leaves little room to type.
</div>
</div>
<div class="govuk-form-group">
<div class="govuk-checkboxes__item">
<input class="govuk-checkboxes__input" id="scb-@Model.Path" name="props[clearButton]" type="checkbox"
value="true" aria-describedby="scb-@Model.Path-hint"
@(sClearButton ? "checked" : null) />
<label class="govuk-label govuk-checkboxes__label" for="scb-@Model.Path">
Show a clear button in the search box
</label>
@* An unticked box posts nothing, so this hidden "false" is what makes
unticking stick. It follows the label: the browser posts in DOM
order and the first value wins. *@
<input type="hidden" name="props[clearButton]" value="false" />
</div>
<div class="govuk-hint" id="scb-@Model.Path-hint">
A cross at the end of the box empties it, so visitors can start again.
</div>
</div>
<div class="govuk-form-group">
<label class="govuk-label" for="sa-@Model.Path">Form action URL</label>
<div class="govuk-hint">
Expand Down
37 changes: 37 additions & 0 deletions src/DfE.CheckPerformanceData.Web/wwwroot/css/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -1750,12 +1750,49 @@ body.js-enabled .rules-condition__apply {
.cypmd-search { margin: 20px 0 30px; }
.cypmd-search__row { display: flex; gap: 20px; max-width: 45em; }
.cypmd-search__input { flex: 1 1 auto; margin-bottom: 0; }
/* The box sits in a wrapper so an optional clear button can sit inside its right-hand end. The
wrapper is the flex child, so it takes the sizing the box used to have. */
.cypmd-search__field { position: relative; flex: 1 1 auto; min-width: 0; }
/* accessible-autocomplete replaces the input with its own wrapper, so the flex sizing has to
move to the element that actually becomes the flex child. */
.cypmd-search__row .cypmd-search__autocomplete,
.cypmd-search__row .autocomplete__wrapper { flex: 1 1 auto; min-width: 0; }
.cypmd-search__button { flex: 0 0 auto; margin: 0; }

/* The author's choice of a clear button: a cross inside the end of the box. The box keeps room
for it so text does not run underneath, and the browser's own clear cross is hidden so there
are never two. */
.cypmd-search__field:has(.cypmd-search__clear) .cypmd-search__input { padding-right: 40px; }
.cypmd-search__field:has(.cypmd-search__clear) .cypmd-search__input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
.cypmd-search__clear {
position: absolute;
top: 2px;
right: 2px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 0;
padding: 0;
border: 0;
background: none;
color: #505a5f;
cursor: pointer;
}
.cypmd-search__clear[hidden] { display: none; }
.cypmd-search__clear:hover { color: #0b0c0c; background-color: #f3f2f1; }
.cypmd-search__clear:focus {
outline: 3px solid transparent;
color: #0b0c0c;
background-color: #ffdd00;
box-shadow: inset 0 -4px 0 #0b0c0c;
}

/* A Card widget keeps clear of whatever follows it. Without this, a second row of cards sits
flush against the first and a heading after the cards touches them. */
.cpb-card {
Expand Down
46 changes: 46 additions & 0 deletions src/DfE.CheckPerformanceData.Web/wwwroot/js/search-clear.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// The search widget's optional clear button. Without JavaScript it is a reset button, which empties
// the box on its own. With it, the button is hidden while there is nothing to clear, and clearing
// puts the visitor back in the box.
(function () {
'use strict';

function bind(form) {
var button = form.querySelector('[data-cypmd-search-clear]');
if (!button || button.getAttribute('data-cypmd-clear-ready')) return;
button.setAttribute('data-cypmd-clear-ready', 'true');

// Instant search swaps the box for its own, so the box is looked up each time.
function box() { return form.querySelector('input[name="q"]'); }

function update() {
var field = box();
button.hidden = !field || field.value === '';
}

form.addEventListener('input', update);

button.addEventListener('click', function (event) {
event.preventDefault();
var field = box();
if (!field) return;
field.value = '';
// Instant search keeps its own copy of the text and closes its suggestions when told
// the box has changed.
field.dispatchEvent(new Event('input', { bubbles: true }));
field.focus();
update();
});

update();
}

function init() {
Array.prototype.forEach.call(document.querySelectorAll('form.cypmd-search'), bind);
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ private async Task AddAndSetAsync(Guid pageId, string path, string widgetType, D

private static Dictionary<string, string> SearchProps(
string searchIn, bool instant, string scope = "", string noResults = "Nothing on this page",
string? showButton = null, string? buttonBelow = null)
string? showButton = null, string? buttonBelow = null, string? clearButton = null)
{
var props = new Dictionary<string, string>
{
Expand All @@ -102,6 +102,7 @@ private static Dictionary<string, string> SearchProps(
if (instant) props["instant"] = "true";
if (showButton is not null) props["showButton"] = showButton;
if (buttonBelow is not null) props["buttonBelow"] = buttonBelow;
if (clearButton is not null) props["clearButton"] = clearButton;
return props;
}

Expand Down Expand Up @@ -385,7 +386,20 @@ public async Task WithoutJavaScript_ThePlainFormStillRendersAndSubmits()
{
var (url, _) = await SeedPageWithSectionsAsync(SearchProps("page", instant: true));

await using var context = await Browser.NewContextAsync(new BrowserNewContextOptions
await using var context = await NewContextWithoutJavaScriptAsync();
var page = await context.NewPageAsync();
await page.GotoAsync($"{Fixture.BaseUrl}{url}");

await page.Locator("input[name='q']").FillAsync("evidence");
await page.Locator("button[type='submit']").ClickAsync();

await page.WaitForURLAsync(new Regex(@"/search\?"));
Assert.Contains("scope=", page.Url, StringComparison.Ordinal);
}

private async Task<IBrowserContext> NewContextWithoutJavaScriptAsync()
{
var context = await Browser.NewContextAsync(new BrowserNewContextOptions
{
JavaScriptEnabled = false,
});
Expand All @@ -400,15 +414,7 @@ await context.AddCookiesAsync([new Cookie
Url = Fixture.BaseUrl,
}]);
}

var page = await context.NewPageAsync();
await page.GotoAsync($"{Fixture.BaseUrl}{url}");

await page.Locator("input[name='q']").FillAsync("evidence");
await page.Locator("button[type='submit']").ClickAsync();

await page.WaitForURLAsync(new Regex(@"/search\?"));
Assert.Contains("scope=", page.Url, StringComparison.Ordinal);
return context;
}

// ============================================================
Expand Down Expand Up @@ -492,7 +498,102 @@ public async Task WithoutButtonBelow_TheButtonStaysBesideTheBox()
}

// ============================================================
// 6b. Two instant widgets on one page each get their own input and their own menu.
// 6b. The author can add a clear button at the end of the box. It shows once there is
// something to clear, empties the box and puts the visitor back in it.
// ============================================================
private ILocator ClearButton =>
WidgetForm.GetByRole(AriaRole.Button, new() { Name = "Clear search" });

[Fact]
public async Task ClearButton_EmptiesTheBox_AndPutsTheVisitorBackInIt()
{
var (url, _) = await SeedPageWithSectionsAsync(SearchProps("site", instant: false, clearButton: "true"));

await Page.GotoAsync($"{Fixture.BaseUrl}{url}");
var box = WidgetForm.Locator("input[name='q']");

// Nothing to clear yet.
await Expect(ClearButton).ToBeHiddenAsync();

await box.FillAsync("evidence");
await Expect(ClearButton).ToBeVisibleAsync();

// The button sits at the end of the box, inside it.
var boxBounds = await box.BoundingBoxAsync();
var buttonBounds = await ClearButton.BoundingBoxAsync();
Assert.NotNull(boxBounds);
Assert.NotNull(buttonBounds);
Assert.True(buttonBounds.X + buttonBounds.Width <= boxBounds.X + boxBounds.Width + 1
&& buttonBounds.X > boxBounds.X + boxBounds.Width / 2,
"the clear button is not at the end of the box");
Assert.True(buttonBounds.Y >= boxBounds.Y - 1
&& buttonBounds.Y + buttonBounds.Height <= boxBounds.Y + boxBounds.Height + 1,
"the clear button is not inside the box");

await ClearButton.ClickAsync();

await Expect(box).ToHaveValueAsync("");
await Expect(box).ToBeFocusedAsync();
await Expect(ClearButton).ToBeHiddenAsync();
// Clearing is not a search.
Assert.DoesNotContain("/search", Page.Url, StringComparison.Ordinal);
}

[Fact]
public async Task ClearButton_OnInstantSearch_EmptiesTheBoxAndClosesTheSuggestions()
{
var (url, _) = await SeedPageWithSectionsAsync(SearchProps("page", instant: true, clearButton: "true"));

await Page.GotoAsync($"{Fixture.BaseUrl}{url}");
await Expect(Page.Locator("input.autocomplete__input")).ToBeVisibleAsync();
await Expect(ClearButton).ToBeHiddenAsync();

await TypeAsync("evidence");
await Expect(Options.First).ToBeVisibleAsync();
await Expect(ClearButton).ToBeVisibleAsync();

await ClearButton.ClickAsync();

var box = Page.Locator("input.autocomplete__input");
await Expect(box).ToHaveValueAsync("");
await Expect(box).ToBeFocusedAsync();
await Expect(Menu).ToBeHiddenAsync();
await Expect(ClearButton).ToBeHiddenAsync();

// The box still searches after it has been cleared.
await box.FillAsync("uploading");
await Expect(Options.First).ToContainTextAsync("Uploading files");
}

[Fact]
public async Task ClearButton_WorksWithoutJavaScript()
{
var (url, _) = await SeedPageWithSectionsAsync(SearchProps("site", instant: false, clearButton: "true"));

await using var context = await NewContextWithoutJavaScriptAsync();
var page = await context.NewPageAsync();
await page.GotoAsync($"{Fixture.BaseUrl}{url}");

var box = page.Locator("input[name='q']");
await box.FillAsync("evidence");
await page.GetByRole(AriaRole.Button, new() { Name = "Clear search" }).ClickAsync();

await Expect(box).ToHaveValueAsync("");
}

[Fact]
public async Task WithNoClearButtonChoiceSaved_ThereIsNoClearButton()
{
var (url, _) = await SeedPageWithSectionsAsync(SearchProps("site", instant: false));

await Page.GotoAsync($"{Fixture.BaseUrl}{url}");
await WidgetForm.Locator("input[name='q']").FillAsync("evidence");

await Expect(ClearButton).ToHaveCountAsync(0);
}

// ============================================================
// 6c. Two instant widgets on one page each get their own input and their own menu.
// ============================================================
[Fact]
public async Task TwoWidgetsOnOnePage_DoNotShareAnInputId()
Expand Down Expand Up @@ -528,7 +629,7 @@ await AddAndSetAsync(id, "0.1", "heading",
[Fact]
public async Task EnhancedWidget_HasNoAxeViolations()
{
var (url, _) = await SeedPageWithSectionsAsync(SearchProps("page", instant: true));
var (url, _) = await SeedPageWithSectionsAsync(SearchProps("page", instant: true, clearButton: "true"));

await Page.GotoAsync($"{Fixture.BaseUrl}{url}");
await Expect(Page.Locator("input.autocomplete__input")).ToBeVisibleAsync();
Expand Down
Loading
Loading