From 7442743db19f43ac41932a3c508edd1381fc3fba Mon Sep 17 00:00:00 2001 From: "Daniel A.C. Martin" Date: Tue, 10 Dec 2024 20:46:49 +0000 Subject: [PATCH 1/7] Allow form-action in the CSP to be configured --- lib/engine/src/index.ts | 5 ++++- lib/restify/src/index.ts | 3 ++- lib/restify/src/middleware/content-security-policy.ts | 10 ++++++++-- lib/restify/src/middleware/prevent-clickjacking.ts | 8 ++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/engine/src/index.ts b/lib/engine/src/index.ts index 48a735b8b..bb07d8c29 100644 --- a/lib/engine/src/index.ts +++ b/lib/engine/src/index.ts @@ -3,7 +3,7 @@ import { graphqlRestify, graphiqlRestify } from 'apollo-server-restify'; import { createWriteStream } from 'fs'; import { GraphQLSchema } from 'graphql'; import serverless, { Handler } from 'serverless-http'; -import restify, { CSPSources, IsReady, LogLevelString, LoggerOptions, Middleware, Router, Server, cspNone } from '@not-govuk/restify'; +import restify, { CSPSources, IsReady, LogLevelString, LoggerOptions, Middleware, Router, Server, cspNone, cspSelf } from '@not-govuk/restify'; import { PageLoader } from '@not-govuk/app-composer'; import { consentCookies } from '@not-govuk/consent-cookies'; import { ApplicationProps, ErrorPageProps, PageProps, reactRenderer } from '@not-govuk/server-renderer'; @@ -45,6 +45,7 @@ export type EngineOptions = { secure?: boolean } env: NodeEnv + formAction?: CSPSources frameAncestors?: CSPSources graphQL?: { schema: GraphQLSchema @@ -84,6 +85,7 @@ export const engine = async ({ auth: authOptions, cookies: cookieOptions, env, + formAction = cspSelf, frameAncestors = cspNone, graphQL: _graphQL, httpd: { host, port }, @@ -150,6 +152,7 @@ export const engine = async ({ 'application/xhtml+xml; q=0.2': formatHTML, 'text/html; q=0.2': formatHTML }, + formAction, frameAncestors, isReady, logger diff --git a/lib/restify/src/index.ts b/lib/restify/src/index.ts index c59c3cb95..ef2560afd 100644 --- a/lib/restify/src/index.ts +++ b/lib/restify/src/index.ts @@ -22,6 +22,7 @@ export type LoggerOptions = Omit<_LoggerOptions, 'name'> & { export type ServerOptions = _ServerOptions & { bodyParser?: plugins.BodyParserOptions | false + formAction?: CSPSources frameAncestors?: CSPSources grace?: number isReady?: IsReady @@ -88,7 +89,7 @@ export const createServer = (options: ServerOptions): Server => { httpd.pre(htmlByDefault(httpd)); httpd.pre(permissionsPolicy); - httpd.pre(preventClickjacking({ frameAncestors: options.frameAncestors })); + httpd.pre(preventClickjacking({ formAction: options.formAction, frameAncestors: options.frameAncestors })); httpd.pre(preventMimeSniffing); httpd.pre(noCacheByDefault); diff --git a/lib/restify/src/middleware/content-security-policy.ts b/lib/restify/src/middleware/content-security-policy.ts index f39e92176..f8cae3c0f 100644 --- a/lib/restify/src/middleware/content-security-policy.ts +++ b/lib/restify/src/middleware/content-security-policy.ts @@ -1,10 +1,10 @@ import { Middleware } from './common'; - export type Source = '\'none\'' | '\'self\'' | string export type Sources = Source | Source[] export type CSPOptions = { + formAction?: Sources frameAncestors?: Sources }; @@ -16,8 +16,14 @@ export const unsafeInline: Source = "'unsafe-inline'"; const id = (v: T): T => v; const csp = ({ + formAction: _formAction, frameAncestors: _frameAncestors }: CSPOptions, nonce: string): Record => { + const formAction = ( + Array.isArray(_formAction) + ? _formAction + : [_formAction] + ).filter(id) as Source[]; const frameAncestors = ( Array.isArray(_frameAncestors) ? _frameAncestors @@ -40,7 +46,7 @@ const csp = ({ ), 'style-src': [ self, unsafeInline ], // Only load our own CSS, but allow inline styles // Navigation directives - 'form-action': self, // Form submissions must come back to us + 'form-action': formAction.length && formAction || self, // Form submissions must come back to us 'frame-ancestors': frameAncestors.length && frameAncestors || none // Pages cannot be shown inside frames at all. Consider: self }; }; diff --git a/lib/restify/src/middleware/prevent-clickjacking.ts b/lib/restify/src/middleware/prevent-clickjacking.ts index cdfedd808..08ab49b12 100644 --- a/lib/restify/src/middleware/prevent-clickjacking.ts +++ b/lib/restify/src/middleware/prevent-clickjacking.ts @@ -3,14 +3,21 @@ import { Sources as CSPSources, contentSecurityPolicy, none as cspNone, self as import type { Middleware } from './common'; export type PreventClickJackingOptions = { + formAction?: CSPSources // This should be moved out, once we move out the CSP frameAncestors?: CSPSources }; const id = (v: T): T => v; export const preventClickjacking = ({ + formAction: _formAction = cspSelf, frameAncestors: _frameAncestors = cspNone // Consider: cspSelf }: PreventClickJackingOptions): Middleware => { + const formAction = ( + Array.isArray(_formAction) + ? _formAction + : [_formAction] + ).filter(id); const frameAncestors = ( Array.isArray(_frameAncestors) ? _frameAncestors @@ -31,6 +38,7 @@ export const preventClickjacking = ({ ) ); const cspMiddleware = contentSecurityPolicy({ + formAction: formAction, frameAncestors: frameAncestors }); From e21f112b40f9db00e11501c0c88f0b305b1dd56e Mon Sep 17 00:00:00 2001 From: "Daniel A.C. Martin" Date: Tue, 10 Dec 2024 20:47:25 +0000 Subject: [PATCH 2/7] docs: Allow submissions to Google This is for demonstration purposes. --- apps/govuk-docs/src/server/httpd.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/govuk-docs/src/server/httpd.ts b/apps/govuk-docs/src/server/httpd.ts index a2d35828b..716b01d8f 100644 --- a/apps/govuk-docs/src/server/httpd.ts +++ b/apps/govuk-docs/src/server/httpd.ts @@ -1,5 +1,5 @@ import { resolve } from 'path'; -import engine, { Handler, Mode } from '@not-govuk/engine'; +import engine, { cspSelf, Handler, Mode } from '@not-govuk/engine'; import config from './config'; import AppWrap from '../common/app-wrap'; import ErrorPage from '../common/error-page'; @@ -27,6 +27,7 @@ export const createServer = ({ entrypoints, port }: httpdOptions) => { secure: config.cookies.secure }, env: config.env, + formAction: [ 'www.google.co.uk', cspSelf ], httpd: { host: config.httpd.host, port: port || config.httpd.port From 6ea139bc25db77e9fdfd3272d56f957ed6837329 Mon Sep 17 00:00:00 2001 From: "Daniel A.C. Martin" Date: Tue, 10 Dec 2024 20:50:32 +0000 Subject: [PATCH 3/7] Allow labels, hints and errors to be hidden This is required for standalone inputs. --- .../error-message/assets/ErrorMessage.scss | 7 +++++++ components/error-message/src/ErrorMessage.tsx | 16 ++++++++++++++-- components/hint/assets/Hint.scss | 7 +++++++ components/hint/src/Hint.tsx | 16 ++++++++++++++-- components/label/assets/Label.scss | 5 +++++ components/label/src/Label.tsx | 16 ++++++++++++++-- 6 files changed, 61 insertions(+), 6 deletions(-) diff --git a/components/error-message/assets/ErrorMessage.scss b/components/error-message/assets/ErrorMessage.scss index 384c9245c..8f691da93 100644 --- a/components/error-message/assets/ErrorMessage.scss +++ b/components/error-message/assets/ErrorMessage.scss @@ -1,2 +1,9 @@ @import "@not-govuk/sass-base"; @import "govuk-frontend/dist/govuk/components/error-message/index"; +@import "govuk-frontend/dist/govuk/utilities/_visually-hidden"; + +.govuk-error-message { + &--hidden { + @extend .govuk-visually-hidden; + } +} diff --git a/components/error-message/src/ErrorMessage.tsx b/components/error-message/src/ErrorMessage.tsx index c1790ac84..d6ebcba70 100644 --- a/components/error-message/src/ErrorMessage.tsx +++ b/components/error-message/src/ErrorMessage.tsx @@ -6,13 +6,25 @@ import '../assets/ErrorMessage.scss'; export type ErrorMessageProps = StandardProps & HTMLAttributes & { children?: ReactNode + hidden?: boolean }; -export const ErrorMessage: FC = ({ children, classBlock, classModifiers, className, ...attrs }) => { +export const ErrorMessage: FC = ({ + children, + classBlock, + classModifiers: _classModifiers = [], + className, + hidden = false, + ...attrs +}) => { + const classModifiers = [ + hidden ? 'hidden' : undefined, + ...(Array.isArray(_classModifiers) ? _classModifiers : [_classModifiers]) + ]; const classes = classBuilder('govuk-error-message', classBlock, classModifiers, className); return ( -

+

Error: {children}

); diff --git a/components/hint/assets/Hint.scss b/components/hint/assets/Hint.scss index 3d16e86a6..1eb99b089 100644 --- a/components/hint/assets/Hint.scss +++ b/components/hint/assets/Hint.scss @@ -1,5 +1,12 @@ @import "@not-govuk/sass-base"; @import "govuk-frontend/dist/govuk/components/hint/index"; +@import "govuk-frontend/dist/govuk/utilities/_visually-hidden"; + +.govuk-hint { + &--hidden { + @extend .govuk-visually-hidden; + } +} // Global override .hint { diff --git a/components/hint/src/Hint.tsx b/components/hint/src/Hint.tsx index b929b2d2c..959df08f0 100644 --- a/components/hint/src/Hint.tsx +++ b/components/hint/src/Hint.tsx @@ -5,13 +5,25 @@ import '../assets/Hint.scss'; export type HintProps = StandardProps & HTMLAttributes & { children?: ReactNode + hidden?: boolean }; -export const Hint: FC = ({ children, classBlock, classModifiers, className, ...attrs }) => { +export const Hint: FC = ({ + children, + classBlock, + classModifiers: _classModifiers = [], + className, + hidden = false, + ...attrs +}) => { + const classModifiers = [ + hidden ? 'hidden' : undefined, + ...(Array.isArray(_classModifiers) ? _classModifiers : [_classModifiers]) + ]; const classes = classBuilder('govuk-hint', classBlock, classModifiers, className); return ( -
{children}
+
{children}
); }; diff --git a/components/label/assets/Label.scss b/components/label/assets/Label.scss index 2d6b958e8..666515a8f 100644 --- a/components/label/assets/Label.scss +++ b/components/label/assets/Label.scss @@ -1,5 +1,6 @@ @import "@not-govuk/sass-base"; @import "govuk-frontend/dist/govuk/components/label/index"; +@import "govuk-frontend/dist/govuk/utilities/_visually-hidden"; .govuk-label { h1, h2, h3, h4, h5, h6 { @@ -26,6 +27,10 @@ @extend .govuk-label--s; margin-bottom: govuk-spacing(1); } + + &--hidden { + @extend .govuk-visually-hidden; + } } // Global override diff --git a/components/label/src/Label.tsx b/components/label/src/Label.tsx index d314ef435..bcf8007dc 100644 --- a/components/label/src/Label.tsx +++ b/components/label/src/Label.tsx @@ -5,13 +5,25 @@ import '../assets/Label.scss'; export type LabelProps = StandardProps & LabelHTMLAttributes & { children?: ReactNode + hidden?: boolean }; -export const Label: FC = ({ children, classBlock, classModifiers, className, ...attrs }) => { +export const Label: FC = ({ + children, + classBlock, + classModifiers: _classModifiers = [], + className, + hidden = false, + ...attrs +}) => { + const classModifiers = [ + hidden ? 'hidden' : undefined, + ...(Array.isArray(_classModifiers) ? _classModifiers : [_classModifiers]) + ]; const classes = classBuilder('govuk-label', classBlock, classModifiers, className); return ( - + ); }; From 2ab229ec226ae50a8b7600ecd9eff87a4a19e656 Mon Sep 17 00:00:00 2001 From: "Daniel A.C. Martin" Date: Tue, 10 Dec 2024 20:52:32 +0000 Subject: [PATCH 4/7] FormGroup: Add standalone mode Adds a 'standalone' mode that hides labels, hints and error messages. It is designed to be used for forms with a single field. --- components/form-group/assets/FormGroup.scss | 9 +++++++++ components/form-group/src/FormGroup.tsx | 11 +++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/components/form-group/assets/FormGroup.scss b/components/form-group/assets/FormGroup.scss index d9bad54f0..6f5eebba9 100644 --- a/components/form-group/assets/FormGroup.scss +++ b/components/form-group/assets/FormGroup.scss @@ -1,2 +1,11 @@ @import "@not-govuk/sass-base"; @import "govuk-frontend/dist/govuk/objects/_form-group"; + +.govuk-form-group { + &--standalone { + &.govuk-form-group--error { + padding-left: 0; + border-left-width: 0; + } + } +} diff --git a/components/form-group/src/FormGroup.tsx b/components/form-group/src/FormGroup.tsx index fa757bcd2..39d03b413 100644 --- a/components/form-group/src/FormGroup.tsx +++ b/components/form-group/src/FormGroup.tsx @@ -1,7 +1,7 @@ import { FC, Fragment, HTMLAttributes, ReactNode, createElement as h } from 'react'; import { StandardProps, classBuilder } from '@not-govuk/component-helpers'; import { ErrorMessage } from '@not-govuk/error-message'; -import { FieldSet, FieldSetProps } from '@not-govuk/fieldset'; +import { FieldSet } from '@not-govuk/fieldset'; import { Hint } from '@not-govuk/hint'; import { Label } from '@not-govuk/label'; @@ -16,6 +16,7 @@ export type FormGroupProps = StandardProps & Omit hintId?: string id: string label: ReactNode + standalone?: boolean }; export const FormGroup: FC = ({ @@ -30,10 +31,12 @@ export const FormGroup: FC = ({ hintId: _hintId, id, label, + standalone = false, ...attrs }) => { const classModifiers = [ error ? 'error' : undefined, + standalone ? 'standalone' : undefined, ...(Array.isArray(_classModifiers) ? _classModifiers : [_classModifiers]) ]; const classes = classBuilder('govuk-form-group', classBlock, classModifiers, className); @@ -49,8 +52,8 @@ export const FormGroup: FC = ({ const children = ( - { !hint ? null : {hint} } - { !error ? null : {error}} + { !hint ? null : } + { !error ? null : } {_children} ); @@ -59,7 +62,7 @@ export const FormGroup: FC = ({
{ fieldId ? ( - + {children} ) : ( From 4488016d1994079f58670e103a30285b5137b049 Mon Sep 17 00:00:00 2001 From: "Daniel A.C. Martin" Date: Tue, 10 Dec 2024 20:58:16 +0000 Subject: [PATCH 5/7] Add StandaloneInput component --- .storybook/main.js | 1 + apps/govuk-docs/src/common/stories.ts | 3 +- components/form/package.json | 1 + components/form/src/Form.ts | 3 + components/form/src/fields.ts | 2 + components/standalone-input/.gitignore | 5 + components/standalone-input/README.md | 69 +++++++++++ .../assets/StandaloneInput.scss | 43 +++++++ components/standalone-input/jest.config.js | 15 +++ components/standalone-input/package.json | 56 +++++++++ .../spec/StandaloneInput.stories.mdx | 110 ++++++++++++++++++ .../standalone-input/spec/StandaloneInput.ts | 36 ++++++ .../standalone-input/src/StandaloneInput.tsx | 82 +++++++++++++ components/standalone-input/tsconfig.json | 1 + lib-govuk/simple-components/package.json | 1 + lib-govuk/simple-components/src/index.ts | 1 + 16 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 components/standalone-input/.gitignore create mode 100644 components/standalone-input/README.md create mode 100644 components/standalone-input/assets/StandaloneInput.scss create mode 100644 components/standalone-input/jest.config.js create mode 100644 components/standalone-input/package.json create mode 100644 components/standalone-input/spec/StandaloneInput.stories.mdx create mode 100644 components/standalone-input/spec/StandaloneInput.ts create mode 100644 components/standalone-input/src/StandaloneInput.tsx create mode 120000 components/standalone-input/tsconfig.json diff --git a/.storybook/main.js b/.storybook/main.js index cc2d86442..6702387aa 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -31,6 +31,7 @@ module.exports = { '../components/radios/spec/*.stories.@(js|mdx)', '../components/select/spec/*.stories.@(js|mdx)', '../components/skip-link/spec/*.stories.@(js|mdx)', + '../components/standalone-input/spec/*.stories.@(js|mdx)', '../components/table/spec/*.stories.@(js|mdx)', '../components/tabs/spec/*.stories.@(js|mdx)', '../components/tag/spec/*.stories.@(js|mdx)', diff --git a/apps/govuk-docs/src/common/stories.ts b/apps/govuk-docs/src/common/stories.ts index 18384fd6c..8295eeb13 100644 --- a/apps/govuk-docs/src/common/stories.ts +++ b/apps/govuk-docs/src/common/stories.ts @@ -46,7 +46,8 @@ const unofficialStories = [ require('../../../../components/form/spec/Form.stories.mdx'), require('../../../../components/form-field/spec/FormField.stories.mdx'), require('../../../../components/navigation-menu/spec/NavigationMenu.stories.mdx'), - require('../../../../components/page/spec/Page.stories.mdx') + require('../../../../components/page/spec/Page.stories.mdx'), + require('../../../../components/standalone-input/spec/StandaloneInput.stories.mdx') ]; const internalStories = [ require('../../../../components/button-group/spec/ButtonGroup.stories.mdx'), diff --git a/components/form/package.json b/components/form/package.json index 44a4f5e43..1771cf050 100644 --- a/components/form/package.json +++ b/components/form/package.json @@ -32,6 +32,7 @@ "@not-govuk/forms": "workspace:^0.15.1", "@not-govuk/radios": "workspace:^0.15.1", "@not-govuk/select": "workspace:^0.15.1", + "@not-govuk/standalone-input": "workspace:^0.15.1", "@not-govuk/text-input": "workspace:^0.15.1", "@not-govuk/textarea": "workspace:^0.15.1" }, diff --git a/components/form/src/Form.ts b/components/form/src/Form.ts index 620be9f60..85444968f 100644 --- a/components/form/src/Form.ts +++ b/components/form/src/Form.ts @@ -14,6 +14,7 @@ import { Field, Radios, Select, + StandaloneInput, TextInput, Textarea } from './fields'; @@ -30,6 +31,7 @@ type TForm = ComponentType & { Radios: ComponentType Select: ComponentType Submit: ComponentType + StandaloneInput: ComponentType TextInput: ComponentType Textarea: ComponentType }; @@ -49,6 +51,7 @@ export const Form: TForm = Object.assign( Page, Radios, Select, + StandaloneInput, Submit, TextInput, Textarea diff --git a/components/form/src/fields.ts b/components/form/src/fields.ts index 63592a818..d6d596b1c 100644 --- a/components/form/src/fields.ts +++ b/components/form/src/fields.ts @@ -5,6 +5,7 @@ import { DateInput as _DateInput, DateInputProps } from '@not-govuk/date-input'; import { FormField as _Field, FormFieldProps } from '@not-govuk/form-field'; import { Radios as _Radios, RadiosProps } from '@not-govuk/radios'; import { Select as _Select, SelectProps } from '@not-govuk/select'; +import { StandaloneInput as _StandaloneInput, StandaloneInputProps } from '@not-govuk/standalone-input'; import { TextInput as _TextInput, TextInputProps } from '@not-govuk/text-input'; import { Textarea as _Textarea, TextareaProps } from '@not-govuk/textarea'; @@ -17,5 +18,6 @@ export const DateInput: ComponentType = withForm(_D export const Field: ComponentType = withForm(_Field as any); export const Radios: ComponentType = withForm(_Radios); export const Select: ComponentType = withForm(_Select); +export const StandaloneInput: ComponentType = withForm(_StandaloneInput); export const TextInput: ComponentType = withForm(_TextInput); export const Textarea: ComponentType = withForm(_Textarea); diff --git a/components/standalone-input/.gitignore b/components/standalone-input/.gitignore new file mode 100644 index 000000000..aa49ac15e --- /dev/null +++ b/components/standalone-input/.gitignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +package-lock.json +pnpm-lock.yaml +tsconfig.tsbuildinfo diff --git a/components/standalone-input/README.md b/components/standalone-input/README.md new file mode 100644 index 000000000..2d790ce03 --- /dev/null +++ b/components/standalone-input/README.md @@ -0,0 +1,69 @@ +NotGovUK - Standalone Input +=========================== + +A single-line, form field with a submit button. + + +Using this package +------------------ + +First install the package into your project: + +```shell +npm install -S @not-govuk/standalone-input +``` + +Then use it in your code as follows: + +```js +import React, { createElement as h } from 'react'; +import StandaloneInput from '@not-govuk/standalone-input'; + +export const MyComponent = props => ( + +); + +export default MyComponent; +``` + + +Working on this package +----------------------- + +Before working on this package you must install its dependencies using +the following command: + +```shell +pnpm install +``` + + +### Testing + +Run the unit tests. + +```shell +npm test +``` + + +### Building + +Build the package by compiling the TypeScript source code. + +```shell +npm run build +``` + + +### Clean-up + +Remove any previously built files. + +```shell +npm run clean +``` diff --git a/components/standalone-input/assets/StandaloneInput.scss b/components/standalone-input/assets/StandaloneInput.scss new file mode 100644 index 000000000..8a2ff3d9e --- /dev/null +++ b/components/standalone-input/assets/StandaloneInput.scss @@ -0,0 +1,43 @@ +@import "@not-govuk/sass-base"; + +.not-govuk-standalone-input { + display: flex; + width: 100%; + + &__input { + display: flex; + flex: 0 1 auto; + + &.govuk-input { + border-right-width: 0; + + &:focus { + border-right-width: 2px; + margin-right: -2px; + z-index: 2; + } + } + } + + &__button { + $button-shadow-size: $govuk-border-width-form-element; + + display: flex; + flex: 0 0 auto; + margin-bottom: 0; + + &.govuk-button { + margin-bottom: $button-shadow-size; + } + } + + &--fixed-width { + .not-govuk-standalone-input__input { + &.govuk-input { + &:focus { + margin-right: 0; + } + } + } + } +} diff --git a/components/standalone-input/jest.config.js b/components/standalone-input/jest.config.js new file mode 100644 index 000000000..116a1cfbe --- /dev/null +++ b/components/standalone-input/jest.config.js @@ -0,0 +1,15 @@ +'use strict'; + +const baseConfig = require('../../jest.config.base'); + +const config = { + ...baseConfig, + collectCoverageFrom: [ + '/src/**.{ts,tsx}', + ], + testMatch: [ + '/spec/**.{ts,tsx}' + ] +}; + +module.exports = config; diff --git a/components/standalone-input/package.json b/components/standalone-input/package.json new file mode 100644 index 000000000..63ca3b9ce --- /dev/null +++ b/components/standalone-input/package.json @@ -0,0 +1,56 @@ +{ + "name": "@not-govuk/standalone-input", + "version": "0.15.1", + "description": "A single-line, form field with a submit button.", + "main": "src/StandaloneInput.tsx", + "sass": "assets/StandaloneInput.scss", + "publishConfig": { + "main": "dist/StandaloneInput.js", + "typings": "dist/StandaloneInput.d.ts" + }, + "files": [ + "/assets", + "/dist" + ], + "scripts": { + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "prepublishOnly": "npm run clean && npm run build", + "build": "tsc", + "clean": "rm -rf dist tsconfig.tsbuildinfo" + }, + "author": "Daniel A.C. Martin (http://daniel-martin.co.uk/)", + "license": "MIT", + "keywords": [ + "react-components" + ], + "dependencies": { + "@not-govuk/button": "workspace:^0.15.1", + "@not-govuk/component-helpers": "workspace:^0.15.1", + "@not-govuk/form-group": "workspace:^0.15.1", + "@not-govuk/input": "workspace:^0.15.1", + "@not-govuk/sass-base": "workspace:^0.15.1" + }, + "peerDependencies": { + "@not-govuk/docs-components": "^0.15.1", + "@storybook/addon-docs": "^6.5.16", + "react": "^18.3.1" + }, + "peerDependenciesMeta": { + "@not-govuk/docs-components": { + "optional": true + }, + "@storybook/addon-docs": { + "optional": true + } + }, + "devDependencies": { + "@mdx-js/react": "1.6.22", + "@not-govuk/component-test-helpers": "workspace:^0.15.1", + "@not-govuk/tag": "workspace:^0.15.1", + "@types/react": "18.3.12", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "ts-jest": "29.2.5", + "typescript": "4.9.5" + } +} diff --git a/components/standalone-input/spec/StandaloneInput.stories.mdx b/components/standalone-input/spec/StandaloneInput.stories.mdx new file mode 100644 index 000000000..b23fa20d1 --- /dev/null +++ b/components/standalone-input/spec/StandaloneInput.stories.mdx @@ -0,0 +1,110 @@ +import { Meta, Preview, Props, Story } from '@storybook/addon-docs'; +import { Tag } from '@not-govuk/tag'; +import { StandaloneInput } from '../src/StandaloneInput'; +import readMe from '../README.md'; + + + +# Standalone input + +

+ +A single-line, form field with a submit button. + +(Created to support the [Search box component] but may have other uses.) + + + + + + + + + + +## Stories +### Standard + +A standard Standalone input. + + + + + + + + +### Hint + +If you provide a hint, it will be displayed instead of the label. + + + + + + + + +### Custom submit button text + +The text of the submit button is visually hidden, but can be customised for screen-readers. + + + + + + + + +### Fixed width + + + + + + + + +### Fluid width + + + + + + + + +### Error + +Currently errors are visually hidden, so it is best to avoid them. They will however be accessible to screen-readers, and the field will still be highlighted in red. + + + + + + + + +### Disabled + + + + + + + + +[Search box component]: /components?name=Search%20box diff --git a/components/standalone-input/spec/StandaloneInput.ts b/components/standalone-input/spec/StandaloneInput.ts new file mode 100644 index 000000000..e26077761 --- /dev/null +++ b/components/standalone-input/spec/StandaloneInput.ts @@ -0,0 +1,36 @@ +import { createElement as h } from 'react'; +import { render, screen } from '@not-govuk/component-test-helpers'; +import StandaloneInput from '../src/StandaloneInput'; + +describe('StandaloneInput', () => { + const minimalProps = { + name: 'message', + label: 'Message' + }; + + describe('when given minimal valid props', () => { + beforeEach(async () => { + render(h(StandaloneInput, minimalProps)); + }); + + it('renders a text field', async () => expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text')); + it('renders the label', async () => expect(screen.getByLabelText('Message')).toBeInTheDocument()); + }); + + describe('when given all valid props', () => { + const props = { + ...minimalProps, + button: 'Send', + error: 'Something went wrong', + hint: 'What do you want to say?', + width: 10 + }; + + beforeEach(async () => { + render(h(StandaloneInput, props)); + }); + + it('renders a text field', async () => expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text')); + it('renders the label', async () => expect(screen.getByLabelText('Message')).toBeInTheDocument()); + }); +}); diff --git a/components/standalone-input/src/StandaloneInput.tsx b/components/standalone-input/src/StandaloneInput.tsx new file mode 100644 index 000000000..035dafeae --- /dev/null +++ b/components/standalone-input/src/StandaloneInput.tsx @@ -0,0 +1,82 @@ +import { FC, ReactNode, createElement as h } from 'react'; +import { SubmitButton } from '@not-govuk/button'; +import { classBuilder } from '@not-govuk/component-helpers'; +import { FormGroup } from '@not-govuk/form-group'; +import { Input, InputProps } from '@not-govuk/input'; + +import '../assets/StandaloneInput.scss'; + +export type StandaloneInputProps = InputProps & { + /** Submit button text */ + button?: ReactNode + /** Error message */ + error?: ReactNode + /** Hint */ + hint?: string + /** Label */ + label?: string + /** HTML name */ + name: string +}; + +export const StandaloneInput: FC = ({ + button = 'Submit', + classBlock, + classModifiers: _classModifiers = [], + className, + disabled, + error, + hint, + id: _id, + label, + width, + ...attrs +}) => { + const classModifiers = [ + error ? 'error' : undefined, + width ? 'fixed-width' : undefined, + ...(Array.isArray(_classModifiers) ? _classModifiers : [_classModifiers]) + ]; + const classes = classBuilder('not-govuk-standalone-input', classBlock, classModifiers, className); + const id = _id || attrs.name; + const fieldId = `${id}-input`; + const hintId = `${id}-hint`; + const errorId = `${id}-error`; + const describedBy = ([ + hint && hintId, + error && errorId + ] + .filter(e => e) + .join(' ') || undefined + ); + + return ( + + + {button} + + ); +}; + +StandaloneInput.displayName = 'StandaloneInput'; + +export default StandaloneInput; diff --git a/components/standalone-input/tsconfig.json b/components/standalone-input/tsconfig.json new file mode 120000 index 000000000..f23066030 --- /dev/null +++ b/components/standalone-input/tsconfig.json @@ -0,0 +1 @@ +../../lib/plop-pack/skel/component/tsconfig.json \ No newline at end of file diff --git a/lib-govuk/simple-components/package.json b/lib-govuk/simple-components/package.json index 5454b1b14..e342dd56a 100644 --- a/lib-govuk/simple-components/package.json +++ b/lib-govuk/simple-components/package.json @@ -55,6 +55,7 @@ "@not-govuk/sass-base": "workspace:^0.15.1", "@not-govuk/select": "workspace:^0.15.1", "@not-govuk/skip-link": "workspace:^0.15.1", + "@not-govuk/standalone-input": "workspace:^0.15.1", "@not-govuk/summary-card": "workspace:^0.15.1", "@not-govuk/summary-list": "workspace:^0.15.1", "@not-govuk/table": "workspace:^0.15.1", diff --git a/lib-govuk/simple-components/src/index.ts b/lib-govuk/simple-components/src/index.ts index 4ff709d7b..cc76b5101 100644 --- a/lib-govuk/simple-components/src/index.ts +++ b/lib-govuk/simple-components/src/index.ts @@ -26,6 +26,7 @@ export { default as PhaseBanner } from '@not-govuk/phase-banner'; export { default as Radios } from '@not-govuk/radios'; export { default as Select } from '@not-govuk/select'; export { default as SkipLink } from '@not-govuk/skip-link'; +export { default as StandaloneInput } from '@not-govuk/standalone-input'; export { default as SummaryCard } from '@not-govuk/summary-card'; export { default as SummaryList } from '@not-govuk/summary-list'; export { default as Tag } from '@not-govuk/tag'; From c1fee716256d7fd415609249be221ee82dfd7198 Mon Sep 17 00:00:00 2001 From: "Daniel A.C. Martin" Date: Tue, 10 Dec 2024 21:02:24 +0000 Subject: [PATCH 6/7] Add SearchBox component --- .storybook/main.js | 1 + apps/govuk-docs/src/common/stories.ts | 1 + components/form/package.json | 1 + components/form/src/Form.ts | 3 + components/form/src/fields.ts | 2 + components/search-box/.gitignore | 5 + components/search-box/README.md | 71 +++++++++++ components/search-box/assets/SearchBox.scss | 70 +++++++++++ components/search-box/jest.config.js | 15 +++ components/search-box/package.json | 54 ++++++++ .../search-box/spec/SearchBox.stories.mdx | 118 ++++++++++++++++++ components/search-box/spec/SearchBox.ts | 36 ++++++ components/search-box/src/SearchBox.tsx | 37 ++++++ components/search-box/tsconfig.json | 1 + lib-govuk/simple-components/package.json | 1 + lib-govuk/simple-components/src/index.ts | 1 + 16 files changed, 417 insertions(+) create mode 100644 components/search-box/.gitignore create mode 100644 components/search-box/README.md create mode 100644 components/search-box/assets/SearchBox.scss create mode 100644 components/search-box/jest.config.js create mode 100644 components/search-box/package.json create mode 100644 components/search-box/spec/SearchBox.stories.mdx create mode 100644 components/search-box/spec/SearchBox.ts create mode 100644 components/search-box/src/SearchBox.tsx create mode 120000 components/search-box/tsconfig.json diff --git a/.storybook/main.js b/.storybook/main.js index 6702387aa..b1d6c67e1 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -29,6 +29,7 @@ module.exports = { '../components/panel/spec/*.stories.@(js|mdx)', '../components/phase-banner/spec/*.stories.@(js|mdx)', '../components/radios/spec/*.stories.@(js|mdx)', + '../components/search-box/spec/*.stories.@(js|mdx)', '../components/select/spec/*.stories.@(js|mdx)', '../components/skip-link/spec/*.stories.@(js|mdx)', '../components/standalone-input/spec/*.stories.@(js|mdx)', diff --git a/apps/govuk-docs/src/common/stories.ts b/apps/govuk-docs/src/common/stories.ts index 8295eeb13..6e266fb19 100644 --- a/apps/govuk-docs/src/common/stories.ts +++ b/apps/govuk-docs/src/common/stories.ts @@ -47,6 +47,7 @@ const unofficialStories = [ require('../../../../components/form-field/spec/FormField.stories.mdx'), require('../../../../components/navigation-menu/spec/NavigationMenu.stories.mdx'), require('../../../../components/page/spec/Page.stories.mdx'), + require('../../../../components/search-box/spec/SearchBox.stories.mdx'), require('../../../../components/standalone-input/spec/StandaloneInput.stories.mdx') ]; const internalStories = [ diff --git a/components/form/package.json b/components/form/package.json index 1771cf050..3fe9eb0dc 100644 --- a/components/form/package.json +++ b/components/form/package.json @@ -31,6 +31,7 @@ "@not-govuk/form-field": "workspace:^0.15.1", "@not-govuk/forms": "workspace:^0.15.1", "@not-govuk/radios": "workspace:^0.15.1", + "@not-govuk/search-box": "workspace:^0.15.1", "@not-govuk/select": "workspace:^0.15.1", "@not-govuk/standalone-input": "workspace:^0.15.1", "@not-govuk/text-input": "workspace:^0.15.1", diff --git a/components/form/src/Form.ts b/components/form/src/Form.ts index 85444968f..bb226d8ce 100644 --- a/components/form/src/Form.ts +++ b/components/form/src/Form.ts @@ -13,6 +13,7 @@ import { DateInput, Field, Radios, + SearchBox, Select, StandaloneInput, TextInput, @@ -29,6 +30,7 @@ type TForm = ComponentType & { Fork: ComponentType Page: ComponentType Radios: ComponentType + SearchBox: ComponentType Select: ComponentType Submit: ComponentType StandaloneInput: ComponentType @@ -50,6 +52,7 @@ export const Form: TForm = Object.assign( Fork, Page, Radios, + SearchBox, Select, StandaloneInput, Submit, diff --git a/components/form/src/fields.ts b/components/form/src/fields.ts index d6d596b1c..15fbaa463 100644 --- a/components/form/src/fields.ts +++ b/components/form/src/fields.ts @@ -4,6 +4,7 @@ import { Checkboxes as _Checkboxes, CheckboxesProps } from '@not-govuk/checkboxe import { DateInput as _DateInput, DateInputProps } from '@not-govuk/date-input'; import { FormField as _Field, FormFieldProps } from '@not-govuk/form-field'; import { Radios as _Radios, RadiosProps } from '@not-govuk/radios'; +import { SearchBox as _SearchBox, SearchBoxProps } from '@not-govuk/search-box'; import { Select as _Select, SelectProps } from '@not-govuk/select'; import { StandaloneInput as _StandaloneInput, StandaloneInputProps } from '@not-govuk/standalone-input'; import { TextInput as _TextInput, TextInputProps } from '@not-govuk/text-input'; @@ -17,6 +18,7 @@ export const DateInput: ComponentType = withForm(_D }); export const Field: ComponentType = withForm(_Field as any); export const Radios: ComponentType = withForm(_Radios); +export const SearchBox: ComponentType = withForm(_SearchBox); export const Select: ComponentType = withForm(_Select); export const StandaloneInput: ComponentType = withForm(_StandaloneInput); export const TextInput: ComponentType = withForm(_TextInput); diff --git a/components/search-box/.gitignore b/components/search-box/.gitignore new file mode 100644 index 000000000..aa49ac15e --- /dev/null +++ b/components/search-box/.gitignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +package-lock.json +pnpm-lock.yaml +tsconfig.tsbuildinfo diff --git a/components/search-box/README.md b/components/search-box/README.md new file mode 100644 index 000000000..1905efd8e --- /dev/null +++ b/components/search-box/README.md @@ -0,0 +1,71 @@ +NotGovUK - Search Box +===================== + +A single-line, form field for searching, with submit button. + +(Heavily inspired by the [Search component] in the [GOV.UK Component Guide].) + +Using this package +------------------ + +First install the package into your project: + +```shell +npm install -S @not-govuk/search-box +``` + +Then use it in your code as follows: + +```js +import React, { createElement as h } from 'react'; +import SearchBox from '@not-govuk/search-box'; + +export const MyComponent = props => ( +
+ + +); + +export default MyComponent; +``` + + +Working on this package +----------------------- + +Before working on this package you must install its dependencies using +the following command: + +```shell +pnpm install +``` + + +### Testing + +Run the unit tests. + +```shell +npm test +``` + + +### Building + +Build the package by compiling the TypeScript source code. + +```shell +npm run build +``` + + +### Clean-up + +Remove any previously built files. + +```shell +npm run clean +``` + +[Search component]: https://components.publishing.service.gov.uk/component-guide/search +[GOV.UK Component Guide]: https://components.publishing.service.gov.uk/component-guide diff --git a/components/search-box/assets/SearchBox.scss b/components/search-box/assets/SearchBox.scss new file mode 100644 index 000000000..85053ee4e --- /dev/null +++ b/components/search-box/assets/SearchBox.scss @@ -0,0 +1,70 @@ +@use "sass:color"; +@import "@not-govuk/sass-base"; + +// Inspired by the Search component in the GDS Component Guide: +// - https://components.publishing.service.gov.uk/component-guide/search +// - https://github.com/alphagov/govuk_publishing_components/blob/4496b318c0407ca84633cd7dd7928f329d0a375f/app/assets/stylesheets/govuk_publishing_components/components/_search.scss +// - https://frontend.design-system.service.gov.uk/ + +.not-govuk-search-box { + .not-govuk-standalone-input__button { + margin-bottom: 0; + background-color: govuk-colour("blue"); + color: govuk-colour("white"); + box-shadow: none; + border: 0; + cursor: pointer; + border-radius: 0; + outline: 2px solid rgba(0,0,0,0); + outline-offset: 0; + position: relative; + padding: 0; + width: 40px; + height: 40px; + text-indent: -5000px; + overflow: hidden; + + &:hover { + background-color: color.adjust(govuk-colour("blue"), $lightness: 5%); + } + + &.govuk-button:focus { + border: $govuk-border-width-form-element solid $govuk-focus-colour; + outline: $govuk-focus-width solid transparent; + box-shadow: inset 0 0 0 1px $govuk-focus-colour; + } + + &.govuk-button:focus:not(:active):not(:hover) { + border-color: $govuk-focus-colour; + color: $govuk-focus-text-colour; + background-color: $govuk-focus-colour; + box-shadow: none; + border-bottom: 2px solid $govuk-focus-text-colour; + } + } + + &__icon { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + position: absolute; + height: 100%; + width: 100%; + transform: scale(0.5); + } + + &--hods { + .not-govuk-standalone-input__button { + background-color: govuk-colour("light-grey"); + color: govuk-colour("black"); + border: 2px solid govuk-colour("black"); + width: 40px; + height: 40px; + + &:hover { + background-color: color.adjust(govuk-colour("light-grey"), $lightness: 5%); + } + } + } +} diff --git a/components/search-box/jest.config.js b/components/search-box/jest.config.js new file mode 100644 index 000000000..116a1cfbe --- /dev/null +++ b/components/search-box/jest.config.js @@ -0,0 +1,15 @@ +'use strict'; + +const baseConfig = require('../../jest.config.base'); + +const config = { + ...baseConfig, + collectCoverageFrom: [ + '/src/**.{ts,tsx}', + ], + testMatch: [ + '/spec/**.{ts,tsx}' + ] +}; + +module.exports = config; diff --git a/components/search-box/package.json b/components/search-box/package.json new file mode 100644 index 000000000..1287ea2f4 --- /dev/null +++ b/components/search-box/package.json @@ -0,0 +1,54 @@ +{ + "name": "@not-govuk/search-box", + "version": "0.15.1", + "description": "A single-line, form field for searching, with submit button.", + "main": "src/SearchBox.tsx", + "sass": "assets/SearchBox.scss", + "publishConfig": { + "main": "dist/SearchBox.js", + "typings": "dist/SearchBox.d.ts" + }, + "files": [ + "/assets", + "/dist" + ], + "scripts": { + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "prepublishOnly": "npm run clean && npm run build", + "build": "tsc", + "clean": "rm -rf dist tsconfig.tsbuildinfo" + }, + "author": "Daniel A.C. Martin (http://daniel-martin.co.uk/)", + "license": "MIT", + "keywords": [ + "react-components" + ], + "dependencies": { + "@not-govuk/component-helpers": "workspace:^0.15.1", + "@not-govuk/sass-base": "workspace:^0.15.1", + "@not-govuk/standalone-input": "workspace:^0.15.1" + }, + "peerDependencies": { + "@not-govuk/docs-components": "^0.15.1", + "@storybook/addon-docs": "^6.5.16", + "react": "^18.3.1" + }, + "peerDependenciesMeta": { + "@not-govuk/docs-components": { + "optional": true + }, + "@storybook/addon-docs": { + "optional": true + } + }, + "devDependencies": { + "@mdx-js/react": "1.6.22", + "@not-govuk/component-test-helpers": "workspace:^0.15.1", + "@not-govuk/tag": "workspace:^0.15.1", + "@types/react": "18.3.12", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "ts-jest": "29.2.5", + "typescript": "4.9.5" + } +} diff --git a/components/search-box/spec/SearchBox.stories.mdx b/components/search-box/spec/SearchBox.stories.mdx new file mode 100644 index 000000000..cddb366e5 --- /dev/null +++ b/components/search-box/spec/SearchBox.stories.mdx @@ -0,0 +1,118 @@ +import { Meta, Preview, Props, Story } from '@storybook/addon-docs'; +import { Tag } from '@not-govuk/tag'; +import { SearchBox } from '../src/SearchBox'; +import readMe from '../README.md'; + + + +# Search box + +

+ +A single-line, form field for searching, with submit button. + +(Heavily inspired by the [Search component] in the [GOV.UK Component Guide].) + + + +
+ + +
+
+ + + + +## Stories +### Standard + +A standard Search box. + + + + + + + + +### Custom label + + + + + + + + +### Hint + +If you provide a hint, it will be displayed instead of the label. + + + + + + + + +### Custom submit button text + +The text of the submit button is visually hidden, but can be customised for screen-readers. + + + + + + + + +### Fixed width + + + + + + + + +### Fluid width + + + + + + + + +### Error + +Currently errors are visually hidden, so it is best to avoid them. They will however be accessible to screen-readers, and the field will still be highlighted in red. + + + + + + + + +### Disabled + + + + + + + + +[Search component]: https://components.publishing.service.gov.uk/component-guide/search +[GOV.UK Component Guide]: https://components.publishing.service.gov.uk/component-guide diff --git a/components/search-box/spec/SearchBox.ts b/components/search-box/spec/SearchBox.ts new file mode 100644 index 000000000..ff48ea8d2 --- /dev/null +++ b/components/search-box/spec/SearchBox.ts @@ -0,0 +1,36 @@ +import { createElement as h } from 'react'; +import { render, screen } from '@not-govuk/component-test-helpers'; +import SearchBox from '../src/SearchBox'; + +describe('SearchBox', () => { + const minimalProps = { + name: 'q' + }; + + describe('when given minimal valid props', () => { + beforeEach(async () => { + render(h(SearchBox, minimalProps)); + }); + + it('renders a text field', async () => expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text')); + it('renders the label', async () => expect(screen.getByLabelText('Search')).toBeInTheDocument()); + }); + + describe('when given all valid props', () => { + const props = { + ...minimalProps, + button: 'Query', + error: 'Something went wrong', + hint: 'Query', + label: 'Query', + width: 10 + }; + + beforeEach(async () => { + render(h(SearchBox, props)); + }); + + it('renders a text field', async () => expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text')); + it('renders the label', async () => expect(screen.getByLabelText('Query')).toBeInTheDocument()); + }); +}); diff --git a/components/search-box/src/SearchBox.tsx b/components/search-box/src/SearchBox.tsx new file mode 100644 index 000000000..205f3f694 --- /dev/null +++ b/components/search-box/src/SearchBox.tsx @@ -0,0 +1,37 @@ +import { FC, Fragment, createElement as h } from 'react'; +import { classBuilder } from '@not-govuk/component-helpers'; +import { StandaloneInput, StandaloneInputProps } from '@not-govuk/standalone-input'; + +import '../assets/SearchBox.scss'; + +export type SearchBoxProps = StandaloneInputProps & { +}; + +export const SearchBox: FC = ({ + button: _button = 'Search', + classBlock, + classModifiers, + className, + label = 'Search', + name = 'q', + ...props +}) => { + const classes = classBuilder('not-govuk-search-box', classBlock, classModifiers, className); + const button = ( + + {_button} + + + ); + + return ( + + ); +}; + +SearchBox.displayName = 'SearchBox'; + +export default SearchBox; diff --git a/components/search-box/tsconfig.json b/components/search-box/tsconfig.json new file mode 120000 index 000000000..f23066030 --- /dev/null +++ b/components/search-box/tsconfig.json @@ -0,0 +1 @@ +../../lib/plop-pack/skel/component/tsconfig.json \ No newline at end of file diff --git a/lib-govuk/simple-components/package.json b/lib-govuk/simple-components/package.json index e342dd56a..a5146509c 100644 --- a/lib-govuk/simple-components/package.json +++ b/lib-govuk/simple-components/package.json @@ -53,6 +53,7 @@ "@not-govuk/phase-banner": "workspace:^0.15.1", "@not-govuk/radios": "workspace:^0.15.1", "@not-govuk/sass-base": "workspace:^0.15.1", + "@not-govuk/search-box": "workspace:^0.15.1", "@not-govuk/select": "workspace:^0.15.1", "@not-govuk/skip-link": "workspace:^0.15.1", "@not-govuk/standalone-input": "workspace:^0.15.1", diff --git a/lib-govuk/simple-components/src/index.ts b/lib-govuk/simple-components/src/index.ts index cc76b5101..60951c86b 100644 --- a/lib-govuk/simple-components/src/index.ts +++ b/lib-govuk/simple-components/src/index.ts @@ -24,6 +24,7 @@ export { default as Pagination } from '@not-govuk/pagination'; export { default as Panel } from '@not-govuk/panel'; export { default as PhaseBanner } from '@not-govuk/phase-banner'; export { default as Radios } from '@not-govuk/radios'; +export { default as SearchBox } from '@not-govuk/search-box'; export { default as Select } from '@not-govuk/select'; export { default as SkipLink } from '@not-govuk/skip-link'; export { default as StandaloneInput } from '@not-govuk/standalone-input'; From 73bea9c8bb2f16fccc7ccff9ff0198cedafee394 Mon Sep 17 00:00:00 2001 From: "Daniel A.C. Martin" Date: Tue, 10 Dec 2024 21:02:51 +0000 Subject: [PATCH 7/7] template: Make use of the new SearchBox component --- apps/govuk-template/src/common/pages/search.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/govuk-template/src/common/pages/search.tsx b/apps/govuk-template/src/common/pages/search.tsx index 6e3e94978..24dfe3746 100644 --- a/apps/govuk-template/src/common/pages/search.tsx +++ b/apps/govuk-template/src/common/pages/search.tsx @@ -10,12 +10,9 @@ const Page: FC = () => { return ( +

Search

- Search} - /> - Search +

Result