diff --git a/.babelrc b/.babelrc index c34c67d..4bc3750 100644 --- a/.babelrc +++ b/.babelrc @@ -1,9 +1,13 @@ { - "presets": ["@babel/env", "@babel/typescript", "@babel/preset-react"], + "presets": [ + "@babel/preset-env", + "@babel/preset-typescript", + "@babel/preset-react" + ], "plugins": [ - "@babel/plugin-transform-numeric-separator", - "@babel/proposal-class-properties", - "@babel/proposal-object-rest-spread" + "@babel/plugin-proposal-class-properties", + "@babel/plugin-proposal-object-rest-spread", + "@babel/plugin-transform-numeric-separator" ], "ignore": ["**/__tests__/*", "*/__mocks__/*"] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eb8972..c3a0d6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Yarn Install - run: yarn + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Enable Corepack + run: corepack enable + - name: Install dependencies + run: yarn install - name: Lint run: yarn lint - name: Jest Tests diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index c1bba0c..374b1bc 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -3,20 +3,22 @@ name: Build & Deploy Storybook on: push: branches: - - master + - main + workflow_dispatch: jobs: build-and-deploy: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@master + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - persist-credentials: false - - - name: Yarn Install + node-version: '20' + - name: Enable Corepack + run: corepack enable + - name: Install dependencies run: yarn install - - name: Deploy Storybook run: yarn storybook-to-ghpages --ci env: diff --git a/.storybook/_storybook.scss b/.storybook/_storybook.scss index c18a59a..f2e1cd2 100644 --- a/.storybook/_storybook.scss +++ b/.storybook/_storybook.scss @@ -1,6 +1,8 @@ -@import '../src/styles/core.scss'; -@import '../src/styles/variables.scss'; -@import '../src/all.scss'; +@use '/node_modules/nhsuk-frontend/dist/nhsuk.scss'; +@use '../src/styles/core.scss'; +@use '../src/styles/variables.scss'; +@use '../src.all.scss'; +@use '../src/components.scss'; %demo-centered { display: block; diff --git a/.storybook/config.js b/.storybook/config.js deleted file mode 100644 index 002d8c7..0000000 --- a/.storybook/config.js +++ /dev/null @@ -1,13 +0,0 @@ -import { configure, addParameters } from '@storybook/react'; -import './_storybook.scss'; - - -import NHSTheme from './theme'; - -addParameters({ - options: { - theme: NHSTheme, - }, -}); - -configure(require.context('../stories', true, /\.stories\.tsx$/), module); diff --git a/.storybook/main.js b/.storybook/main.js index 2e11c82..eafa802 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,3 +1,25 @@ +// .storybook/main.js +const path = require('path'); +const fs = require('fs'); + +function nhsAssetsDir() { + // Find the installed package root + const pkgDir = path.dirname(require.resolve('nhsuk-frontend/package.json')); + // Try common locations across versions + const candidates = [ + path.join(pkgDir, 'assets'), + path.join(pkgDir, 'dist', 'assets'), + path.join(pkgDir, 'packages', 'assets'), + ]; + return candidates.find(fs.existsSync) || null; +} + +const assets = nhsAssetsDir(); + module.exports = { - addons: ['@storybook/addon-storysource'], + framework: { name: '@storybook/react-vite', options: {} }, + stories: ['../stories/**/*.stories.@(ts|tsx|js|jsx|mdx)'], + addons: [], + staticDirs: assets ? [{ from: assets, to: '/assets' }] : [], + typescript: { reactDocgen: 'react-docgen-typescript' }, }; diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000..f8b8414 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1 @@ + diff --git a/.storybook/preview.js b/.storybook/preview.js new file mode 100644 index 0000000..e9954dc --- /dev/null +++ b/.storybook/preview.js @@ -0,0 +1,11 @@ + +import 'nhsuk-frontend/dist/nhsuk/index.scss'; + + +const preview = { + parameters: { + controls: { expanded: true }, + }, +}; + +export default preview; diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 4e83ecd..6f87819 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/README.md b/README.md index 0f5a4ab..77773c2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ import SubNavigation from 'nhsuk-react-components/dist/src/components/sub-naviga ### Styles -The package comes with two separate "master" stylesheets. These can be found at `~nhsuk-react-components-extensions/css/all.css` and `~nhsuk-react-components-extensions/css/components.css`. +The package comes with two separate "master" stylesheets. These can be found at `~nhsuk-react-components-extensions/css.css` and `~nhsuk-react-components-extensions/css/components.css`. If you are already using components from `nhsuk-frontend` or the `nhsuk-react-components` packages, it is strongly recommended to use the `components.css` file as this only contains the additional styles required to use the extra components in this library. @@ -40,7 +40,7 @@ If you are not using any of those other packages, or the standard NHS.UK stylesh ```scss // Core NHS.UK Styles and Components -@import '~nhsuk-react-components-extensions/css/all.css'; +@import '~nhsuk-react-components-extensions/css.css'; // Just Components @import '~nhsuk-react-components-extensions/css/components.css'; diff --git a/eslint.config.mjs b/eslint.config.mjs index d6f44c1..f6e8f3e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,6 @@ +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from "eslint-plugin-storybook"; + import react from "eslint-plugin-react"; import typescriptEslint from "@typescript-eslint/eslint-plugin"; import jest from "eslint-plugin-jest"; @@ -41,11 +44,7 @@ export default [...compat.extends("airbnb-typescript"), { ecmaFeatures: { jsx: true, }, - projectService: { - // this is to allow the test files to be linted, even though they aren't included in tsconfig.json - allowDefaultProject: ["src/components/*/__tests__/*.test.tsx", ], - maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 10, - }, + projectService: true, tsconfigRootDir: import.meta.dirname, }, }, @@ -79,4 +78,4 @@ export default [...compat.extends("airbnb-typescript"), { "jsx-a11y/anchor-has-content": 0, "jsx-a11y/heading-has-content": 0, }, -}]; \ No newline at end of file +}, ...storybook.configs["flat/recommended"]]; \ No newline at end of file diff --git a/package.json b/package.json index 498c317..2d9f17e 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,51 @@ { "name": "nhsuk-react-components-extensions", - "version": "2.3.0-beta", + "version": "2.3.5-beta", + "style": "dist/index.css", "author": { "email": "thomas.judd-cooper1@nhs.net", "name": "Thomas Judd-Cooper", "url": "https://tomjuddcooper.co.uk" }, "license": "MIT", + "dependencies": { + "classnames": "^2.2.6", + "nhsuk-react-components": "6.0.0-beta.2" + }, "devDependencies": { "@babel/cli": "^7.25.9", "@babel/core": "^7.26.0", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.20.7", "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/preset-env": "^7.28.3", "@babel/preset-react": "^7.25.9", - "@babel/preset-typescript": "^7.26.0", + "@babel/preset-typescript": "^7.27.1", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.16.0", - "@rollup/plugin-commonjs": "^28.0.1", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.1", - "@storybook/addon-storysource": "^5.3.3", - "@storybook/react": "^5.2.1", + "@react-input/mask": "^2.0.4", + "@rollup/plugin-commonjs": "^28.0.6", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.4", + "@storybook/addon-a11y": "9.1.3", + "@storybook/addon-docs": "9.1.3", + "@storybook/react-vite": "9.1.3", "@storybook/storybook-deployer": "^2.8.16", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "~12.0.0", + "@testing-library/react": "^16.0.1", + "@types/add": "^2", + "@types/babel__preset-env": "^7", "@types/classnames": "^2.2.9", "@types/jest": "^29.5.14", "@types/node": "^22.10.2", - "@types/react": "^18.2.60", - "@types/react-dom": "^18.2.19", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@types/react-input-mask": "^3.0.5", + "@types/stylus": "^0", "@typescript-eslint/eslint-plugin": "^8.13.0", "@typescript-eslint/parser": "^8.13.0", + "add": "^2.0.6", "babel-jest": "^29.7.0", "babel-loader": "^8.0.0", "css-loader": "^5.0.0", @@ -44,48 +59,51 @@ "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-storybook": "9.1.3", "globals": "^15.13.0", "jest": "^29.7.0", "jest-axe": "^9.0.0", "jest-environment-jsdom": "^29.7.0", - "nhsuk-frontend": "8.1.0", - "nhsuk-react-components": "^5.0.0", + "less": "^4.4.1", + "nhsuk-frontend": "^10", "postcss": "^8.4.49", "prettier": "^3.3.3", - "react": "^16.9.3", - "react-dom": "^16.9.3", - "rollup": "^4.0.0", - "sass": "~1.70.0", + "react": "^19.1.1", + "react-docgen-typescript": "^2.4.0", + "react-dom": "^19.1.1", + "rollup": "^4.50.1", + "rollup-plugin-dts": "^6.2.3", + "rollup-plugin-postcss": "^4.0.2", + "sass": "^1.92.1", + "sass-embedded": "^1.92.1", "sass-loader": "^10.0.0", - "storybook": "^8.4.6", + "storybook": "9.1.3", "style-loader": "^2.0.0", "stylelint": "^16.10.0", "stylelint-config-sass-guidelines": "^12.1.0", "stylelint-scss": "^6.8.1", + "stylus": "^0.64.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tslib": "^2.8.1", - "typescript": "^5.6.3", + "typescript": "^5.9.2", + "vite": "^7.1.3", "webpack": "^5.0.0" }, "peerDependencies": { - "nhsuk-react-components": "^5.0.0", - "react": "^16.9.3", - "react-dom": "^16.9.3" - }, - "dependencies": { - "classnames": "^2.2.6", - "react-input-mask": "^2.0.4" + "react": "^19", + "react-dom": "^19" }, "scripts": { "build": "yarn build:dist && yarn build:lib && yarn build:sass", "build:dist": "rollup -c", - "build:lib": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline && tsc --emitDeclarationOnly", + "build:lib": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline && tsc --emitDeclarationOnly --skipLibCheck", "build:sass": "sass src:css", + "build-storybook": "storybook build", "cleanup": "bash scripts/cleanup.sh", "lint": "stylelint --fix src/**/*.scss src/**/**/*.scss && eslint --fix src/*.ts src/components/**/*.ts src/components/**/*.tsx", "prebuild": "yarn lint && yarn test --coverage && yarn cleanup", - "storybook": "start-storybook", + "storybook": "storybook dev -p 6006", "test": "jest", "test:ci": "jest --coverage" }, @@ -94,7 +112,8 @@ "lib", "css" ], - "module": "dist/index.es.js", + "module": "dist/index.esm.js", "types": "dist/index.d.ts", - "main": "dist/index.js" + "main": "dist/index.js", + "packageManager": "yarn@4.4.0+sha512.91d93b445d9284e7ed52931369bc89a663414e5582d00eea45c67ddc459a2582919eece27c412d6ffd1bd0793ff35399381cb229326b961798ce4f4cc60ddfdb" } diff --git a/rollup.config.mjs b/rollup.config.mjs index 323d6d7..668f215 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,20 +1,58 @@ -import typescript from '@rollup/plugin-typescript'; -import pkg from './package.json' assert { type: 'json' }; -import { nodeResolve } from '@rollup/plugin-node-resolve'; +// rollup.config.mjs +import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; +import typescript from '@rollup/plugin-typescript'; +import postcss from 'rollup-plugin-postcss'; +import dts from 'rollup-plugin-dts'; +import pkg from './package.json' with { type: 'json' }; + +export default [ + // JS + CSS bundle + { + input: 'src/index.ts', + // DO NOT externalize styles here — let postcss handle them + external: [ + /^react($|\/)/, + /^react-dom($|\/)/, + /^@types\/react($|\/)/, + /^@types\/react-dom($|\/)/, + ], + plugins: [ + resolve(), + commonjs(), + postcss({ + extensions: ['.css', '.scss'], + extract: 'index.css', // make the output predictable + minimize: true, + // optional: quiet dart-sass deprecations if you use legacy APIs + modules: false, + use: { + sass: { + quietDeps: true, + silenceDeprecations: ['legacy-js-api', 'misplaced-rest'] + }, + } + }), + typescript({ + tsconfig: './tsconfig.json', + declaration: true, + declarationDir: './dist/types', + rootDir: './src', + noEmitOnError: true + }), + ], + output: [ + { file: "dist/index.js", format: "cjs" }, + { file: "dist/index.esm.js", format: "esm" }, + ], + }, -export default { - input: 'src/index.ts', - output: [ - { file: pkg.main, format: 'cjs', sourcemap: true }, - { file: pkg.module, format: 'es', sourcemap: true } - ], - external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})], - plugins: [ - typescript({ - declarationDir: './dist' - }), - nodeResolve(), - commonjs() - ], -}; + // .d.ts bundle + { + input: 'dist/types/index.d.ts', + output: [{ file: pkg.types, format: 'esm' }], + plugins: [dts()], + // Tell dts bundler to ignore style imports + external: [/\.s?css$/i, /\.less$/i, /\.styl(us)?$/i], + }, +]; diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index b02cb52..fe10376 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -5,13 +5,7 @@ describe('Index', () => { expect(Object.keys(index)).toEqual([ 'AccordionMenu', 'HeaderWithLogo', - 'MaskedInput', - 'FormGroup', - 'Label', - 'getRandomString', - 'generateRandomID', - 'generateRandomName', - 'FieldsetContext', + 'InputMask', 'RibbonLink', 'SubNavigation', 'TabSet', diff --git a/src/all.scss b/src/all.scss index 067a969..9359367 100644 --- a/src/all.scss +++ b/src/all.scss @@ -1,3 +1,4 @@ + // SubNavigation Component @use './components/sub-navigation/SubNavigation'; @@ -11,5 +12,4 @@ @use './components/ribbon-link/RibbonLink'; @use './components/timeline/Timeline'; -@use './components/header-with-logo/headerWithLogo' - +@use './components/header-with-logo/headerWithLogo'; diff --git a/src/components/InputMask/InputMask.tsx b/src/components/InputMask/InputMask.tsx new file mode 100644 index 0000000..3e95802 --- /dev/null +++ b/src/components/InputMask/InputMask.tsx @@ -0,0 +1,565 @@ +import React, { useRef, useEffect, useCallback, useState, useMemo } from 'react'; +import parseMask from './utils/parseMask'; +import { isAndroidBrowser, isWindowsPhoneBrowser, isAndroidFirefox } from './utils/environment'; +import { clearRange, formatValue, getFilledLength, isEmpty, isPermanentChar, getInsertStringLength, insertString } from './utils/string'; +import defer from './utils/defer'; +import { TextInput } from "nhsuk-react-components"; + +export interface InputMaskProps extends Omit { + mask?: string; + maskChar?: string; + formatChars?: Record; + alwaysShowMask?: boolean; + inputRef?: React.Ref; + value?: string | null; + defaultValue?: string | null; + onChange?: (event: React.ChangeEvent) => void; +} + +const InputElement: React.FC = ({ + mask, + maskChar, + formatChars, + defaultValue = '', + value, + alwaysShowMask = false, + inputRef, + disabled = false, + readOnly, + onChange, + onKeyDown, + onPaste, + onMouseDown, + onFocus, + onBlur, + ...props +}) => { + const inputElRef = useRef(null); + const [internalValue, setInternalValue] = useState(''); + const lastCursorPos = useRef(null); + const focused = useRef(false); + const backspaceOrDeleteRemoval = useRef<{ + key: string; + selection: { start: number; end: number; length: number }; + } | null>(null); + const beforePasteState = useRef<{ + value: string; + selection: { start: number; end: number; length: number }; + } | null>(null); + const isAndroidBrowserRef = useRef(false); + const isWindowsPhoneBrowserRef = useRef(false); + const isAndroidFirefoxRef = useRef(false); + const mouseDownX = useRef(0); + const mouseDownY = useRef(0); + const mouseDownTime = useRef(0); + + const hasValue = value != null; + const maskOptions = useMemo(() => parseMask(mask, maskChar, formatChars), [mask, maskChar, formatChars]); + + const getStringValue = useCallback((val: unknown): string => { + return !val && val !== 0 ? '' : val + ''; + }, []); + + const isDOMElement = useCallback((element: unknown): element is HTMLElement => { + return (typeof HTMLElement === 'object') + ? element instanceof HTMLElement + : typeof element === 'object' && element !== null && 'nodeType' in element && + (element as { nodeType: number }).nodeType === 1 && + 'nodeName' in element && typeof (element as { nodeName: unknown }).nodeName === 'string'; + }, []); + + const getInputDOMNode = useCallback((): HTMLInputElement | null => { + const input = inputElRef.current; + if (!input) return null; + return isDOMElement(input) ? input : null; + }, [isDOMElement]); + + const getInputValue = useCallback((): string | null => { + const input = getInputDOMNode(); + return input ? input.value : null; + }, [getInputDOMNode]); + + const setInputValue = useCallback((val: string): void => { + const input = getInputDOMNode(); + if (!input) return; + setInternalValue(val); + input.value = val; + }, [getInputDOMNode]); + + const getLeftEditablePos = useCallback((pos: number): number | null => { + for (let i = pos; i >= 0; --i) { + if (!isPermanentChar(maskOptions, i)) { + return i; + } + } + return null; + }, [maskOptions]); + + const getRightEditablePos = useCallback((pos: number): number | null => { + const maskStr = maskOptions.mask; + if (!maskStr) return null; + + for (let i = pos; i < maskStr.length; ++i) { + if (!isPermanentChar(maskOptions, i)) { + return i; + } + } + return null; + }, [maskOptions]); + + const setSelection = useCallback((start: number, len: number = 0): void => { + const input = getInputDOMNode(); + if (!input) return; + + const end = start + len; + + if ('selectionStart' in input && 'selectionEnd' in input) { + input.selectionStart = start; + input.selectionEnd = end; + } else { + // Legacy IE support + const inputWithRange = input as HTMLInputElement & { + createTextRange?: () => { + collapse: (toStart: boolean) => void; + moveStart: (unit: string, count: number) => void; + moveEnd: (unit: string, count: number) => void; + select: () => void; + } + }; + if (inputWithRange.createTextRange) { + const range = inputWithRange.createTextRange(); + range.collapse(true); + range.moveStart('character', start); + range.moveEnd('character', end - start); + range.select(); + } + } + }, [getInputDOMNode]); + + const setCursorToEnd = useCallback((): void => { + const filledLen = getFilledLength(maskOptions, internalValue); + const pos = getRightEditablePos(filledLen); + + if (pos !== null) { + setSelection(pos, 0); + defer(() => { + setSelection(pos, 0); + }); + lastCursorPos.current = pos; + } + }, [maskOptions, internalValue, getRightEditablePos, setSelection]); + + + const getSelection = useCallback(() => { + const input = getInputDOMNode(); + let start = 0; + let end = 0; + + if (input && 'selectionStart' in input && 'selectionEnd' in input) { + start = input.selectionStart || 0; + end = input.selectionEnd || 0; + } else if (input) { + // Legacy IE support + const docWithSelection = document as Document & { + selection?: { + createRange: () => { + parentElement: () => Element; + moveStart: (unit: string, count: number) => number; + moveEnd: (unit: string, count: number) => number; + }; + }; + }; + if (docWithSelection.selection) { + const range = docWithSelection.selection.createRange(); + if (range && range.parentElement() === input && 'value' in input && (input as any).value) { + start = -range.moveStart('character', -(input as any).value.length); + end = -range.moveEnd('character', -(input as any).value.length); + } + } + } + + return { + start, + end, + length: end - start + }; + }, [getInputDOMNode]); + + + const setCursorPos = useCallback((pos: number): void => { + setSelection(pos, 0); + defer(() => { + setSelection(pos, 0); + }); + lastCursorPos.current = pos; + }, [setSelection]); + + const isFocused = useCallback((): boolean => { + return focused.current; + }, []); + + // Initialize value + useEffect(() => { + let initialValue = hasValue ? getStringValue(value) : defaultValue || ''; + + if (maskOptions.mask && (alwaysShowMask || initialValue)) { + initialValue = formatValue(maskOptions, initialValue); + } + + setInternalValue(initialValue); + }, [mask, maskChar, formatChars, value, defaultValue, alwaysShowMask, hasValue, getStringValue]); + + // Mount effect + useEffect(() => { + isAndroidBrowserRef.current = isAndroidBrowser(); + isWindowsPhoneBrowserRef.current = isWindowsPhoneBrowser(); + isAndroidFirefoxRef.current = isAndroidFirefox(); + + if (maskOptions.mask && getInputValue() !== internalValue) { + setInputValue(internalValue); + } + }, [maskOptions.mask, getInputValue, internalValue, setInputValue]); + + // Update effect + useEffect(() => { + if (maskOptions.mask && getInputValue() !== internalValue) { + setInputValue(internalValue); + } + }, [maskOptions.mask, getInputValue, internalValue, setInputValue]); + + // Props change effect + useEffect(() => { + if (!maskOptions.mask) { + backspaceOrDeleteRemoval.current = null; + lastCursorPos.current = null; + return; + } + + const showEmpty = alwaysShowMask || isFocused(); + let newValue = hasValue ? getStringValue(value) : internalValue; + + if (maskOptions.mask && (newValue || showEmpty)) { + newValue = formatValue(maskOptions, newValue); + } + + if (maskOptions.mask && isEmpty(maskOptions, newValue) && !showEmpty && (!hasValue || !value)) { + newValue = ''; + } + + if (newValue !== internalValue) { + setInternalValue(newValue); + } + }, [mask, maskChar, formatChars, value, alwaysShowMask, hasValue, getStringValue, isFocused]); + + const pasteText = useCallback((val: string, text: string, selection: { start: number; length: number }, event: React.ChangeEvent): void => { + let cursorPos = selection.start; + + if (selection.length) { + val = clearRange(maskOptions, val, cursorPos, selection.length); + } + + const textLen = getInsertStringLength(maskOptions, val, cursorPos); + val = insertString(maskOptions, val, text, cursorPos); + cursorPos += textLen; + cursorPos = getRightEditablePos(cursorPos) || cursorPos; + + setInputValue(val); + + if (event && onChange) { + onChange(event); + } + + setCursorPos(cursorPos); + }, [maskOptions, getRightEditablePos, setInputValue, onChange, setCursorPos]); + + const handleKeyDown = useCallback((event: React.KeyboardEvent): void => { + backspaceOrDeleteRemoval.current = null; + + if (onKeyDown) { + onKeyDown(event); + } + + const { key, ctrlKey, metaKey, defaultPrevented } = event; + + if (ctrlKey || metaKey || defaultPrevented) { + return; + } + + if (key === 'Backspace' || key === 'Delete') { + const selection = getSelection(); + const canRemove = (key === 'Backspace' && selection.end > 0) || + (key === 'Delete' && internalValue.length > selection.start); + + if (!canRemove) { + return; + } + + backspaceOrDeleteRemoval.current = { + key, + selection: getSelection() + }; + } + }, [onKeyDown, getSelection, internalValue]); + + const handleChange = useCallback((event: React.ChangeEvent): void => { + const beforePaste = beforePasteState.current; + const { mask: maskStr, maskChar: maskCharacter, lastEditablePos, prefix } = maskOptions; + + const inputValue = getInputValue(); + if (!inputValue || !maskStr) return; + + if (beforePaste) { + beforePasteState.current = null; + pasteText(beforePaste.value, inputValue, beforePaste.selection, event); + return; + } + + const oldValue = internalValue; + let currentValue = inputValue; + const input = getInputDOMNode(); + + // Handle autofill + try { + if (input && typeof input.matches === 'function' && input.matches(':-webkit-autofill')) { + // Treat autofill as complete replacement + } + } catch { + // Ignore matches errors + } + + const selection = getSelection(); + let cursorPos = selection.end; + const maskLen = maskStr.length; + const valueLen = currentValue.length; + const oldValueLen = oldValue.length; + const prefixLength = prefix?.length || 0; + const lastEditablePosValue = lastEditablePos || 0; + let clearedValue: string; + let enteredString: string; + + if (backspaceOrDeleteRemoval.current) { + const deleteFromRight = backspaceOrDeleteRemoval.current.key === 'Delete'; + currentValue = internalValue; + const removalSelection = backspaceOrDeleteRemoval.current.selection; + cursorPos = removalSelection.start; + backspaceOrDeleteRemoval.current = null; + + if (removalSelection.length) { + currentValue = clearRange(maskOptions, currentValue, removalSelection.start, removalSelection.length); + } else if (removalSelection.start < prefixLength || (!deleteFromRight && removalSelection.start === prefixLength)) { + cursorPos = prefixLength; + } else { + const editablePos = deleteFromRight + ? getRightEditablePos(cursorPos) + : getLeftEditablePos(cursorPos - 1); + + if (editablePos !== null) { + if (!maskCharacter) { + currentValue = currentValue.substring(0, getFilledLength(maskOptions, currentValue)); + } + currentValue = clearRange(maskOptions, currentValue, editablePos, 1); + cursorPos = editablePos; + } + } + } else if (valueLen > oldValueLen) { + const enteredStringLen = valueLen - oldValueLen; + const startPos = selection.end - enteredStringLen; + enteredString = currentValue.substring(startPos, startPos + enteredStringLen); + + if (startPos < lastEditablePosValue && (enteredStringLen !== 1 || enteredString !== maskStr[startPos])) { + cursorPos = getRightEditablePos(startPos) || startPos; + } else { + cursorPos = startPos; + } + + currentValue = currentValue.substring(0, startPos) + currentValue.substring(startPos + enteredStringLen); + clearedValue = clearRange(maskOptions, currentValue, startPos, maskLen - startPos); + clearedValue = insertString(maskOptions, clearedValue, enteredString, cursorPos); + currentValue = insertString(maskOptions, oldValue, enteredString, cursorPos); + + if (enteredStringLen !== 1 || (cursorPos >= prefixLength && cursorPos < lastEditablePosValue)) { + cursorPos = Math.max(getFilledLength(maskOptions, clearedValue), cursorPos); + if (cursorPos < lastEditablePosValue) { + cursorPos = getRightEditablePos(cursorPos) || cursorPos; + } + } else if (cursorPos < lastEditablePosValue) { + cursorPos++; + } + } else if (valueLen < oldValueLen) { + const removedLen = maskLen - valueLen; + enteredString = currentValue.substring(0, selection.end); + const clearOnly = enteredString === oldValue.substring(0, selection.end); + clearedValue = clearRange(maskOptions, oldValue, selection.end, removedLen); + + if (maskCharacter) { + currentValue = insertString(maskOptions, clearedValue, enteredString, 0); + } + + clearedValue = clearRange(maskOptions, clearedValue, selection.end, maskLen - selection.end); + clearedValue = insertString(maskOptions, clearedValue, enteredString, 0); + + if (!clearOnly) { + cursorPos = Math.max(getFilledLength(maskOptions, clearedValue), cursorPos); + if (cursorPos < lastEditablePosValue) { + cursorPos = getRightEditablePos(cursorPos) || cursorPos; + } + } else if (cursorPos < prefixLength) { + cursorPos = prefixLength; + } + } + + currentValue = formatValue(maskOptions, currentValue); + setInputValue(currentValue); + + if (onChange) { + onChange(event); + } + + if (isWindowsPhoneBrowserRef.current) { + defer(() => { + setSelection(cursorPos, 0); + }); + } else { + setCursorPos(cursorPos); + } + }, [maskOptions, getInputValue, internalValue, getInputDOMNode, getSelection, pasteText, getRightEditablePos, getLeftEditablePos, setInputValue, onChange, setSelection, setCursorPos]); + + const handleFocus = useCallback((event: React.FocusEvent): void => { + focused.current = true; + + if (maskOptions.mask) { + if (!internalValue) { + const { prefix } = maskOptions; + const formattedValue = formatValue(maskOptions, prefix || ''); + const inputValue = formatValue(maskOptions, formattedValue); + + const isInputValueChanged = inputValue !== event.target.value; + + if (isInputValueChanged) { + event.target.value = inputValue; + } + + setInternalValue(inputValue); + + if (isInputValueChanged && onChange) { + onChange(event as React.ChangeEvent); + } + + setCursorToEnd(); + } else if (getFilledLength(maskOptions, internalValue) < maskOptions.mask.length) { + setCursorToEnd(); + } + } + + if (onFocus) { + onFocus(event); + } + }, [maskOptions, internalValue, onChange, setCursorToEnd, onFocus]); + + const handleBlur = useCallback((event: React.FocusEvent): void => { + focused.current = false; + + if (maskOptions.mask && !alwaysShowMask && isEmpty(maskOptions, internalValue)) { + const inputValue = ''; + const isInputValueChanged = inputValue !== getInputValue(); + + if (isInputValueChanged) { + setInputValue(inputValue); + } + + if (isInputValueChanged && onChange) { + onChange(event as React.ChangeEvent); + } + } + + if (onBlur) { + onBlur(event); + } + }, [maskOptions, alwaysShowMask, internalValue, getInputValue, setInputValue, onChange, onBlur]); + + const handleMouseDown = useCallback((event: React.MouseEvent): void => { + if (!focused.current && document.addEventListener) { + mouseDownX.current = event.clientX; + mouseDownY.current = event.clientY; + mouseDownTime.current = new Date().getTime(); + + const mouseUpHandler = (mouseUpEvent: MouseEvent) => { + document.removeEventListener('mouseup', mouseUpHandler); + + if (!focused.current) { + return; + } + + const deltaX = Math.abs(mouseUpEvent.clientX - mouseDownX.current); + const deltaY = Math.abs(mouseUpEvent.clientY - mouseDownY.current); + const axisDelta = Math.max(deltaX, deltaY); + const timeDelta = new Date().getTime() - mouseDownTime.current; + + if ((axisDelta <= 10 && timeDelta <= 200) || (axisDelta <= 5 && timeDelta <= 300)) { + setCursorToEnd(); + } + }; + + document.addEventListener('mouseup', mouseUpHandler); + } + + if (onMouseDown) { + onMouseDown(event); + } + }, [setCursorToEnd, onMouseDown]); + + const handlePaste = useCallback((event: React.ClipboardEvent): void => { + if (onPaste) { + onPaste(event); + } + + if (!event.defaultPrevented) { + const inputValue = getInputValue(); + if (inputValue !== null) { + beforePasteState.current = { + value: inputValue, + selection: getSelection() + }; + setInputValue(''); + } + } + }, [onPaste, getInputValue, getSelection, setInputValue]); + + const handleRef = useCallback((ref: HTMLInputElement | null) => { + if (inputElRef) { + inputElRef.current = ref; + } + + if (inputRef) { + if (typeof inputRef === 'function') { + inputRef(ref); + } else if (inputRef && 'current' in inputRef) { + const refObj = inputRef as { current: HTMLInputElement | null }; + refObj.current = ref; + } + } + }, [inputRef]); + + const inputProps: any = { + ...props, + onFocus: handleFocus, + onBlur: handleBlur + }; + + if (maskOptions.mask) { + if (!disabled && !readOnly) { + inputProps.onChange = handleChange; + inputProps.onKeyDown = handleKeyDown; + inputProps.onPaste = handlePaste; + inputProps.onMouseDown = handleMouseDown; + } + } + + if (maskOptions.mask && hasValue) { + inputProps.value = internalValue; + } + + return ; +}; + +export default InputElement; \ No newline at end of file diff --git a/src/components/InputMask/constants.ts b/src/components/InputMask/constants.ts new file mode 100644 index 0000000..15e1e83 --- /dev/null +++ b/src/components/InputMask/constants.ts @@ -0,0 +1,7 @@ +export const defaultMaskChar = '_'; + +export const defaultCharsRules = { + '9': '[0-9]', + 'a': '[A-Za-z]', + '*': '[A-Za-z0-9]' +}; \ No newline at end of file diff --git a/src/components/InputMask/index.ts b/src/components/InputMask/index.ts new file mode 100644 index 0000000..4616b2d --- /dev/null +++ b/src/components/InputMask/index.ts @@ -0,0 +1,2 @@ +export { default } from './InputMask'; +export type { InputMaskProps } from './InputMask'; \ No newline at end of file diff --git a/src/components/InputMask/utils/defer.ts b/src/components/InputMask/utils/defer.ts new file mode 100644 index 0000000..b105b5c --- /dev/null +++ b/src/components/InputMask/utils/defer.ts @@ -0,0 +1,12 @@ +export default function (fn: () => void) { + const windowWithPrefix = window as Window & { + webkitRequestAnimationFrame?: typeof window.requestAnimationFrame; + mozRequestAnimationFrame?: typeof window.requestAnimationFrame; + }; + + const defer = window.requestAnimationFrame || windowWithPrefix.webkitRequestAnimationFrame || windowWithPrefix.mozRequestAnimationFrame || function () { + return setTimeout(fn, 0); + }; + + return defer(fn); +} \ No newline at end of file diff --git a/src/components/InputMask/utils/environment.ts b/src/components/InputMask/utils/environment.ts new file mode 100644 index 0000000..8673853 --- /dev/null +++ b/src/components/InputMask/utils/environment.ts @@ -0,0 +1,26 @@ +export function isAndroidBrowser() { + const windows = new RegExp('windows', 'i'); + const firefox = new RegExp('firefox', 'i'); + const android = new RegExp('android', 'i'); + const ua = navigator.userAgent; + return !windows.test(ua) && !firefox.test(ua) && android.test(ua); +} +export function isWindowsPhoneBrowser() { + const windows = new RegExp('windows', 'i'); + const phone = new RegExp('phone', 'i'); + const ua = navigator.userAgent; + return windows.test(ua) && phone.test(ua); +} +export function isAndroidFirefox() { + const windows = new RegExp('windows', 'i'); + const firefox = new RegExp('firefox', 'i'); + const android = new RegExp('android', 'i'); + const ua = navigator.userAgent; + return !windows.test(ua) && firefox.test(ua) && android.test(ua); +} +export function isIOS() { + const windows = new RegExp('windows', 'i'); + const ios = new RegExp('(ipod|iphone|ipad)', 'i'); + const ua = navigator.userAgent; + return !windows.test(ua) && ios.test(ua); +} \ No newline at end of file diff --git a/src/components/InputMask/utils/parseMask.ts b/src/components/InputMask/utils/parseMask.ts new file mode 100644 index 0000000..be7eb40 --- /dev/null +++ b/src/components/InputMask/utils/parseMask.ts @@ -0,0 +1,63 @@ +import { defaultCharsRules, defaultMaskChar } from '../constants'; + +export interface MaskOptions { + maskChar: string; + charsRules: Record; + mask: string | null; + prefix: string | null; + lastEditablePos: number | null; + permanents: number[]; +} + +export default function (mask?: string | null, maskChar?: string, charsRules?: Record): MaskOptions { + if (maskChar === undefined) { + maskChar = defaultMaskChar; + } + + if (charsRules == null) { + charsRules = defaultCharsRules; + } + + if (!mask || typeof mask !== 'string') { + return { + maskChar: maskChar, + charsRules: charsRules, + mask: null, + prefix: null, + lastEditablePos: null, + permanents: [] + }; + } + + let str = ''; + let prefix = ''; + const permanents: number[] = []; + let isPermanent = false; + let lastEditablePos: number | null = null; + mask.split('').forEach(function (character) { + if (!isPermanent && character === '\\') { + isPermanent = true; + } else { + if (isPermanent || !charsRules[character]) { + permanents.push(str.length); + + if (str.length === permanents.length - 1) { + prefix += character; + } + } else { + lastEditablePos = str.length + 1; + } + + str += character; + isPermanent = false; + } + }); + return { + maskChar: maskChar, + charsRules: charsRules, + prefix: prefix, + mask: str, + lastEditablePos: lastEditablePos, + permanents: permanents + }; +} \ No newline at end of file diff --git a/src/components/InputMask/utils/string.ts b/src/components/InputMask/utils/string.ts new file mode 100644 index 0000000..290d8fd --- /dev/null +++ b/src/components/InputMask/utils/string.ts @@ -0,0 +1,225 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { type MaskOptions } from './parseMask'; + +export function isPermanentChar(maskOptions: MaskOptions, pos: number): boolean { + return maskOptions.permanents.indexOf(pos) !== -1; +} +export function isAllowedChar(maskOptions: MaskOptions, pos: number, character: string): boolean { + const mask = maskOptions.mask; + const charsRules = maskOptions.charsRules; + + if (!character || !mask) { + return false; + } + + if (isPermanentChar(maskOptions, pos)) { + return mask[pos] === character; + } + + const ruleChar = mask[pos]; + const charRule = charsRules[ruleChar]; + return new RegExp(charRule).test(character); +} +export function isEmpty(maskOptions: MaskOptions, value: string): boolean { + return value.split('').every(function (character: string, i: number) { + return isPermanentChar(maskOptions, i) || !isAllowedChar(maskOptions, i, character); + }); +} +export function getFilledLength(maskOptions: MaskOptions, value: string): number { + const maskChar = maskOptions.maskChar; + const prefix = maskOptions.prefix; + + if (!maskChar) { + while (prefix && value.length > prefix.length && isPermanentChar(maskOptions, value.length - 1)) { + value = value.slice(0, value.length - 1); + } + + return value.length; + } + + let filledLength = prefix?.length || 0; + + for (let i = value.length; i >= (prefix?.length || 0); i--) { + const character = value[i]; + const isEnteredCharacter = !isPermanentChar(maskOptions, i) && isAllowedChar(maskOptions, i, character); + + if (isEnteredCharacter) { + filledLength = i + 1; + break; + } + } + + return filledLength; +} +export function isFilled(maskOptions: MaskOptions, value: string): boolean { + return getFilledLength(maskOptions, value) === (maskOptions.mask?.length || 0); +} +export function formatValue(maskOptions: MaskOptions, value: string): string { + const maskChar = maskOptions.maskChar; + const mask = maskOptions.mask; + const prefix = maskOptions.prefix; + + if (!mask) { + return value; + } + + if (!maskChar) { + value = insertString(maskOptions, '', value, 0); + + if (prefix && value.length < prefix.length) { + value = prefix; + } + + while (value.length < mask.length && isPermanentChar(maskOptions, value.length)) { + value += mask[value.length]; + } + + return value; + } + + if (value) { + // Create empty formatted value without recursion + let emptyValue = ''; + for (let i = 0; i < mask.length; i++) { + if (isPermanentChar(maskOptions, i)) { + emptyValue += mask[i]; + } else { + emptyValue += maskChar; + } + } + return insertString(maskOptions, emptyValue, value, 0); + } + + // Create masked value for empty input + for (let i = 0; i < mask.length; i++) { + if (isPermanentChar(maskOptions, i)) { + value += mask[i]; + } else { + value += maskChar; + } + } + + return value; +} +export function clearRange(maskOptions: MaskOptions, value: string, start: number, len: number): string { + const end = start + len; + const maskChar = maskOptions.maskChar; + const mask = maskOptions.mask; + const prefix = maskOptions.prefix; + const arrayValue = value.split(''); + + if (!maskChar) { + // remove any permanent chars after clear range, they will be added back by formatValue + for (let i = end; i < arrayValue.length; i++) { + if (isPermanentChar(maskOptions, i)) { + arrayValue[i] = ''; + } + } + + start = Math.max(prefix?.length || 0, start); + arrayValue.splice(start, end - start); + value = arrayValue.join(''); + return formatValue(maskOptions, value); + } + + return arrayValue.map(function (character: string, i: number) { + if (i < start || i >= end) { + return character; + } + + if (isPermanentChar(maskOptions, i) && mask) { + return mask[i]; + } + + return maskChar; + }).join(''); +} +export function insertString(maskOptions: MaskOptions, value: string, insertStr: string, insertPos: number): string { + const mask = maskOptions.mask; + const maskChar = maskOptions.maskChar; + const prefix = maskOptions.prefix; + const arrayInsertStr = insertStr.split(''); + const isInputFilled = isFilled(maskOptions, value); + + const isUsablePosition = function isUsablePosition(pos: number, character: string) { + return !isPermanentChar(maskOptions, pos) || (mask && character === mask[pos]); + }; + + const isUsableCharacter = function isUsableCharacter(character: string, pos: number) { + return !maskChar || !isPermanentChar(maskOptions, pos) || character !== maskChar; + }; + + if (!maskChar && mask && insertPos > value.length) { + value += mask.slice(value.length, insertPos); + } + + arrayInsertStr.every(function (insertCharacter: string) { + while (!isUsablePosition(insertPos, insertCharacter)) { + if (insertPos >= value.length && mask) { + value += mask[insertPos]; + } + + if (!isUsableCharacter(insertCharacter, insertPos)) { + return true; + } + + insertPos++; // stop iteration if maximum value length reached + + if (mask && insertPos >= mask.length) { + return false; + } + } + + const isAllowed = isAllowedChar(maskOptions, insertPos, insertCharacter) || insertCharacter === maskChar; + + if (!isAllowed) { + return true; + } + + if (insertPos < value.length) { + if (maskChar || isInputFilled || insertPos < (prefix?.length || 0)) { + value = value.slice(0, insertPos) + insertCharacter + value.slice(insertPos + 1); + } else { + value = value.slice(0, insertPos) + insertCharacter + value.slice(insertPos); + value = formatValue(maskOptions, value); + } + } else if (!maskChar) { + value += insertCharacter; + } + + insertPos++; // stop iteration if maximum value length reached + + return mask ? insertPos < mask.length : false; + }); + return value; +} +export function getInsertStringLength(maskOptions: MaskOptions, insertStr: string, insertPos: number): number { + const mask = maskOptions.mask; + const maskChar = maskOptions.maskChar; + const arrayInsertStr = insertStr.split(''); + const initialInsertPos = insertPos; + + const isUsablePosition = function isUsablePosition(pos: number, character: string) { + return !isPermanentChar(maskOptions, pos) || (mask && character === mask[pos]); + }; + + arrayInsertStr.every(function (insertCharacter: string) { + while (!isUsablePosition(insertPos, insertCharacter)) { + insertPos++; // stop iteration if maximum value length reached + + if (mask && insertPos >= mask.length) { + return false; + } + } + + const isAllowed = isAllowedChar(maskOptions, insertPos, insertCharacter) || insertCharacter === maskChar; + + if (isAllowed) { + insertPos++; + } // stop iteration if maximum value length reached + + + return mask ? insertPos < mask.length : false; + }); + return insertPos - initialInsertPos; +} \ No newline at end of file diff --git a/src/components/accordion-menu/AccordionMenu.tsx b/src/components/accordion-menu/AccordionMenu.tsx index 0abe727..02bb046 100644 --- a/src/components/accordion-menu/AccordionMenu.tsx +++ b/src/components/accordion-menu/AccordionMenu.tsx @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/no-redeclare */ -import React, { HTMLProps } from 'react'; +import React from 'react'; import classNames from 'classnames'; import Section, { SectionProps } from './components/Section'; import Link from './components/Link'; +import './_AccordionMenu.scss'; -interface AccordionMenu extends React.FC> { +interface AccordionMenu extends React.FC> { Section: React.FC; - Link: React.FC>; + Link: React.FC>; } const AccordionMenu: AccordionMenu = ({ className, ...rest }) => ( diff --git a/src/components/accordion-menu/_AccordionMenu.scss b/src/components/accordion-menu/_AccordionMenu.scss index b06fbda..94ac61e 100644 --- a/src/components/accordion-menu/_AccordionMenu.scss +++ b/src/components/accordion-menu/_AccordionMenu.scss @@ -1,6 +1,6 @@ @use '../../styles/variables'; -@use '../../../node_modules/nhsuk-frontend/packages/core/settings/all' as settings; -@import '../../../node_modules/nhsuk-frontend/packages/core/all'; +@use '../../../node_modules/nhsuk-frontend/dist/nhsuk/core/settings'; +@use '../../../node_modules/nhsuk-frontend/dist/nhsuk/core' as *; .nhsuk-accordion-menu { display: block; @@ -75,7 +75,7 @@ } &-text { - @include nhsuk-typography-responsive(22); + @include nhsuk-font-size(22); display: block; position: relative; } @@ -97,7 +97,7 @@ @include nhsuk-responsive-padding(3, 'right'); @include nhsuk-responsive-padding(3, 'left'); - @include nhsuk-typography-responsive(19); + @include nhsuk-font-size(19); display: block; border-bottom: 3px solid transparent; border-top: 3px solid transparent; diff --git a/src/components/accordion-menu/__tests__/AccordionMenu.test.tsx b/src/components/accordion-menu/__tests__/AccordionMenu.test.tsx index bae800a..b30b9a9 100644 --- a/src/components/accordion-menu/__tests__/AccordionMenu.test.tsx +++ b/src/components/accordion-menu/__tests__/AccordionMenu.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { act } from 'react'; import {render} from '@testing-library/react' import AccordionMenu from '..'; @@ -29,10 +29,14 @@ describe('AccordionMenu', () => { expect(component.container.querySelector('#accordion')).toHaveProperty('open',true) - component.getByText("Heading").click() + act(() => { + component.getByText("Heading").click() + }); expect(component.container.querySelector('#accordion')).toHaveProperty('open',false) - component.getByText("Heading").click() + act(() => { + component.getByText("Heading").click() + }); expect(component.container.querySelector('#accordion')).toHaveProperty('open',true) }); @@ -44,7 +48,9 @@ describe('AccordionMenu', () => { expect(component.container.querySelector('#accordion')).toHaveProperty('open',true) - component.getByText("Heading").click() + act(() => { + component.getByText("Heading").click() + }); expect(component.container.querySelector('#accordion')).toHaveProperty('open',true) }); @@ -55,7 +61,9 @@ describe('AccordionMenu', () => { ); expect(component.container.querySelector('#accordion')).toHaveProperty('open',false) - component.getByText("Heading").click() + act(() => { + component.getByText("Heading").click() + }); expect(component.container.querySelector('#accordion')).toHaveProperty('open',false) }); diff --git a/src/components/accordion-menu/components/Link.tsx b/src/components/accordion-menu/components/Link.tsx index 32a01d2..0a0f580 100644 --- a/src/components/accordion-menu/components/Link.tsx +++ b/src/components/accordion-menu/components/Link.tsx @@ -1,7 +1,7 @@ -import React, { HTMLProps } from 'react'; +import React from 'react'; import classNames from 'classnames'; -const Link: React.FC> = ({ className, ...rest }) => ( +const Link: React.FC> = ({ className, ...rest }) => ( diff --git a/src/components/accordion-menu/components/Section.tsx b/src/components/accordion-menu/components/Section.tsx index 2f95bef..c5e04fa 100644 --- a/src/components/accordion-menu/components/Section.tsx +++ b/src/components/accordion-menu/components/Section.tsx @@ -1,8 +1,8 @@ -import React, { HTMLProps, ReactNode, useState, MouseEvent, useEffect } from 'react'; +import React, { useState, MouseEvent, useEffect } from 'react'; import classNames from 'classnames'; -export interface SectionProps extends HTMLProps { - heading: ReactNode; +export interface SectionProps extends React.HTMLProps { + heading: React.ReactNode; defaultOpen?: boolean; } diff --git a/src/components/header-with-logo/HeaderWithLogo.tsx b/src/components/header-with-logo/HeaderWithLogo.tsx index cca5a06..e3843f1 100644 --- a/src/components/header-with-logo/HeaderWithLogo.tsx +++ b/src/components/header-with-logo/HeaderWithLogo.tsx @@ -1,4 +1,4 @@ -import React, { FC, HTMLProps, useContext, useState, useEffect, useMemo } from 'react'; +import React, { useContext, useState, useEffect, useMemo } from 'react'; import classNames from 'classnames'; import NHSLogo, { NHSLogoNavProps } from './components/LocalNHSLogo'; import OrganisationalLogo, { OrganisationalLogoProps } from './components/LocalOrganisationalLogo'; @@ -7,12 +7,13 @@ import Search from './components/LocalSearch'; import Nav from './components/LocalNav'; import NavItem from './components/LocalNavItem'; import NavDropdownMenu from './components/LocalNavDropdownMenu'; -import { Container } from 'nhsuk-react-components'; import Content from './components/LocalContent'; import TransactionalServiceName from './components/LocalTransactionalServiceName'; import HeaderJs from './header'; +import './headerWithLogo.scss' +import { Container } from "nhsuk-react-components"; -const BaseHeaderLogo: FC = (props) => { +const BaseHeaderLogo: React.FC = (props) => { const { orgName } = useContext(HeaderContext); if (orgName) { return ; @@ -20,11 +21,11 @@ const BaseHeaderLogo: FC = (props) => return ; }; -const HeaderContainer: FC> = ({ className, ...rest }) => ( +const HeaderContainer: React.FC> = ({ className, ...rest }) => ( ); -interface HeaderProps extends HTMLProps { +interface HeaderProps extends React.HTMLProps { transactional?: boolean; orgName?: string; orgSplit?: string; diff --git a/src/components/header-with-logo/__tests__/__snapshots__/HeaderWithLogo.test.tsx.snap b/src/components/header-with-logo/__tests__/__snapshots__/HeaderWithLogo.test.tsx.snap index 450a03a..e6fd158 100644 --- a/src/components/header-with-logo/__tests__/__snapshots__/HeaderWithLogo.test.tsx.snap +++ b/src/components/header-with-logo/__tests__/__snapshots__/HeaderWithLogo.test.tsx.snap @@ -77,14 +77,15 @@ exports[`The header component Matches the snapshot 1`] = ` > { +export interface BaseIconSVGProps extends React.HTMLProps { iconType?: string; crossOrigin?: '' | 'anonymous' | 'use-credentials'; } -export const BaseIconSVG: FC = ({ - className, - children, - height = 34, - width = 34, - iconType, - ...rest - }) => ( - - ); - +export const BaseIconSVG: React.FC = ({ + className, + children, + height = 34, + width = 34, + iconType, + ...rest +}) => ( + +); + -export const ChevronDownIcon: FC = (props) => ( +export const ChevronDownIcon: React.FC = (props) => ( diff --git a/src/components/header-with-logo/components/LocalContent.tsx b/src/components/header-with-logo/components/LocalContent.tsx index 1046fc1..6f08475 100644 --- a/src/components/header-with-logo/components/LocalContent.tsx +++ b/src/components/header-with-logo/components/LocalContent.tsx @@ -1,7 +1,7 @@ -import React, { FC, HTMLProps } from 'react'; +import React from 'react'; import classNames from 'classnames'; -const Content: FC> = ({ className, ...rest }) => { +const Content: React.FC> = ({ className, ...rest }) => { return
; }; export default Content; diff --git a/src/components/header-with-logo/components/LocalLinkTypes.tsx b/src/components/header-with-logo/components/LocalLinkTypes.tsx index 5ab683f..8074bf2 100644 --- a/src/components/header-with-logo/components/LocalLinkTypes.tsx +++ b/src/components/header-with-logo/components/LocalLinkTypes.tsx @@ -1,5 +1,5 @@ -import { HTMLProps } from 'react'; -export interface AsElementLink extends HTMLProps { +import React from 'react'; +export interface AsElementLink extends React.HTMLProps { asElement?: React.ElementType; to?: string; } diff --git a/src/components/header-with-logo/components/LocalNHSLogo.tsx b/src/components/header-with-logo/components/LocalNHSLogo.tsx index 53eb8ec..7ddbfbe 100644 --- a/src/components/header-with-logo/components/LocalNHSLogo.tsx +++ b/src/components/header-with-logo/components/LocalNHSLogo.tsx @@ -1,17 +1,11 @@ -import React, { FC, useContext, SVGProps } from 'react'; +import React, { useContext } from 'react'; import classNames from 'classnames'; import HeaderContext, { IHeaderContext } from '../HeaderContext'; import { AsElementLink } from './LocalLinkTypes'; -interface SVGImageWithSrc extends SVGProps { - src: string; -} - export type NHSLogoNavProps = AsElementLink; -const SVGImageWithSrc: FC = (props) => ; - -const NHSLogo: FC = ({ +const NHSLogo: React.FC = ({ className, alt = 'NHS Logo', asElement: Component = 'a', diff --git a/src/components/header-with-logo/components/LocalNav.tsx b/src/components/header-with-logo/components/LocalNav.tsx index c44be38..b5b82ff 100644 --- a/src/components/header-with-logo/components/LocalNav.tsx +++ b/src/components/header-with-logo/components/LocalNav.tsx @@ -1,9 +1,9 @@ -import React, { Children, FC, HTMLProps } from 'react'; +import React, { Children, } from 'react'; import classNames from 'classnames'; -import { childIsOfComponentType } from './LocalTypeGuards'; import NavItem from './LocalNavItem'; +import { childIsOfComponentType } from '../../../utils/react-guards'; -const Nav: FC> = ({ +const Nav: React.FC> = ({ className, children, id = 'header-navigation', diff --git a/src/components/header-with-logo/components/LocalNavDropdownMenu.tsx b/src/components/header-with-logo/components/LocalNavDropdownMenu.tsx index 937a326..b2e2a92 100644 --- a/src/components/header-with-logo/components/LocalNavDropdownMenu.tsx +++ b/src/components/header-with-logo/components/LocalNavDropdownMenu.tsx @@ -1,12 +1,12 @@ -import React, { FC, HTMLProps, useContext, useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import HeaderContext, { IHeaderContext } from '../HeaderContext'; import { ChevronDownIcon } from './LocalChevronDown'; -export interface NavDropdownMenuProps extends HTMLProps { +export interface NavDropdownMenuProps extends React.HTMLProps { type?: 'button' | 'submit' | 'reset'; dropdownText?: string; } -const NavMenuDropdown: FC = ({ onClick, dropdownText = 'More', ...rest }) => { +const NavMenuDropdown: React.FC = ({ onClick, dropdownText = 'More', ...rest }) => { const { setMenuToggle } = useContext(HeaderContext); useEffect(() => { diff --git a/src/components/header-with-logo/components/LocalNavItem.tsx b/src/components/header-with-logo/components/LocalNavItem.tsx index cb52150..e2e8b6f 100644 --- a/src/components/header-with-logo/components/LocalNavItem.tsx +++ b/src/components/header-with-logo/components/LocalNavItem.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React from 'react'; import classNames from 'classnames'; import { AsElementLink } from './LocalLinkTypes'; @@ -7,7 +7,7 @@ export interface NavItemProps extends AsElementLink { home?: boolean; } -const NavItem: FC = ({ +const NavItem: React.FC = ({ home, className, children, diff --git a/src/components/header-with-logo/components/LocalOrganisationalLogo.tsx b/src/components/header-with-logo/components/LocalOrganisationalLogo.tsx index b81ebd9..0dba99f 100644 --- a/src/components/header-with-logo/components/LocalOrganisationalLogo.tsx +++ b/src/components/header-with-logo/components/LocalOrganisationalLogo.tsx @@ -1,4 +1,4 @@ -import React, { FC, useContext } from 'react'; +import React, { useContext } from 'react'; import HeaderContext, { IHeaderContext } from '../HeaderContext'; import { AsElementLink } from './LocalLinkTypes'; @@ -6,7 +6,7 @@ export interface OrganisationalLogoProps extends AsElementLink = ({ +const OrganisationalLogo: React.FC = ({ logoUrl, alt, asElement: Component = 'a', diff --git a/src/components/header-with-logo/components/LocalSearch.tsx b/src/components/header-with-logo/components/LocalSearch.tsx index fe0da07..bc72137 100644 --- a/src/components/header-with-logo/components/LocalSearch.tsx +++ b/src/components/header-with-logo/components/LocalSearch.tsx @@ -1,13 +1,13 @@ -import React, { FC, HTMLProps, useContext, useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import classNames from 'classnames'; -import { SearchIcon } from 'nhsuk-react-components'; import HeaderContext, { IHeaderContext } from '../HeaderContext'; +import { SearchIcon } from "nhsuk-react-components"; -export interface SearchProps extends HTMLProps { +export interface SearchProps extends React.HTMLProps { visuallyHiddenText?: string; } -const Search: FC = ({ +const Search: React.FC = ({ action, method = 'get', type = 'search', diff --git a/src/components/header-with-logo/components/LocalTransactionalServiceName.tsx b/src/components/header-with-logo/components/LocalTransactionalServiceName.tsx index 8db9b6e..c03bcc6 100644 --- a/src/components/header-with-logo/components/LocalTransactionalServiceName.tsx +++ b/src/components/header-with-logo/components/LocalTransactionalServiceName.tsx @@ -1,8 +1,8 @@ -import React, { FC, HTMLProps, useContext, useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import classNames from 'classnames'; import HeaderContext, { IHeaderContext } from '../HeaderContext'; -const TransactionalServiceName: FC> = ({ className, ...rest }) => { +const TransactionalServiceName: React.FC> = ({ className, ...rest }) => { const { setServiceName } = useContext(HeaderContext); useEffect(() => { setServiceName(true); diff --git a/src/components/header-with-logo/headerWithLogo.scss b/src/components/header-with-logo/headerWithLogo.scss new file mode 100644 index 0000000..16e4ce9 --- /dev/null +++ b/src/components/header-with-logo/headerWithLogo.scss @@ -0,0 +1,272 @@ +/* stylelint-disable selector-max-id */ +/* stylelint-disable declaration-no-important */ +/* stylelint-disable selector-max-compound-selectors */ + +/* Logoout screen */ +.masthead-wrapper { + position: sticky; + z-index: 100; + top: 0; + width: 100%; +} + +.nhsuk-skip-link:focus { + z-index: 200; +} + +.masthead-container { + max-width: 100%; + flex-grow: 1; + margin: 0; +} + +.nhsuk-header { + display: flex; + min-height: 46px; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid #4d8ecd; + + @media (min-width: 641px) { + padding-left: 24px; + padding-right: 24px; + } + + @media (min-width: 1025px) { + padding-left: 32px; + padding-right: 32px; + } +} + +.nhsuk-header__logo { + flex-shrink: 0; + + .nhsuk-header__link { + display: block; + width: 50px; + height: 40px; + + .nhsuk-logo { + width: 50px; + height: 40px; + border: 0; + + .nhsuk-logo__background { + fill: #fff; + } + + .nhsuk-logo__text { + fill: #005eb8; + } + } + } +} + +.nhsuk-header__transactional-service-name { + width: max-content; + max-width: 40vw; + flex-shrink: 0; + padding: 0 16px; + margin-right: auto; + float: initial; + + @media (min-width: 641px) { + padding-left: 24px; + padding-right: 24px; + } + + @media (min-width: 1025px) { + padding-left: 32px; + padding-right: 32px; + } + + .nhsuk-header__transactional-service-name--link { + display: inline-block; + margin-top: 3px; + margin-bottom: 3px; + font-size: 16px; + font-weight: 500; + line-height: 1em; + text-decoration: none; + color: #fff; + } +} + +.nhsuk-header__transactional-service-name .nhsuk-header__transactional-service-name--link:hover { + text-decoration: underline; + color: #fff; +} + +.nhsuk-navigation-container { + display: flex; + width: auto; + max-width: 50%; + flex-grow: 1; + flex-shrink: 1; + align-items: center; + justify-content: flex-end; + + @media (min-width: 641px) { + max-width: calc(100% - 340px); + } + + .nhsuk-navigation { + width: 100%; + max-width: 100%; + flex-shrink: 1; + margin-bottom: 0 !important; + } +} + +.icon { + display: block; + width: 18px; + height: 18px; + margin-right: 8px; + margin-left: 8px; +} + +.nhsuk-header__navigation-list { + display: flex; + width: 100%; + align-items: center; + justify-content: flex-end; + padding-left: 0; + border-top: 0; + margin: 0; + gap: 0; + list-style: none; + + .nhsuk-header__navigation-item { + position: relative; + display: flex; + border-top: 0; + margin-bottom: 0; + list-style-type: none; + } + + .nhsuk-header__navigation-link { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + padding: 8px 16px; + border: 0; + border-width: 4px 0; + border-style: solid; + border-color: transparent; + color: #fff; + fill: #fff; + font-weight: 500; + white-space: nowrap; + text-decoration: none; + + .icon, + .nhsuk-icon { + display: block; + width: 18px; + height: 18px; + margin: 0; + } + + .text + .icon, + .text + .nhsuk-icon, + .nhsuk-icon + .text { + margin-left: 10px; + } + + &:hover, + &:visited, + &:active { + border-color: transparent; + background-color: unset; + color: #fff; + text-decoration: underline; + } + + &:focus { + border-color: transparent transparent #212b32; + background-color: #ffeb3b; + color: #212b32; + fill: #212b32; + + .nhsuk-icon, + .nhsuk-icon path { + fill: #212b32; + } + } + + &.nhsuk-header__menu-toggle { + right: 0; + padding-right: 8px; + border-radius: 0; + + // Chevron fixes + .nhsuk-icon { + position: static; + width: 24px; + height: 24px; + margin-left: 6px; + transform: rotate(90deg); + } + + &--expanded { + .nhsuk-icon { + transform: rotate(-90deg); + } + } + + &:focus { + border-width: 4px 0 !important; + border-color: transparent transparent #212b32 !important; + } + } + } + + .nhsuk-mobile-menu-container { + position: relative; + display: none; + + &.nhsuk-mobile-menu-container--visible { + display: block; + } + } + + .nhsuk-header__drop-down { + position: absolute; + z-index: 50; + top: auto; + right: 0; + width: 13rem; + padding: 0; + border: 0; + background-color: #005eb8; + + > *:last-child { + border-bottom: 0; + margin-bottom: 0; + } + + .nhsuk-header__navigation-item { + display: block; + border-bottom: 1px solid rgba(216, 221, 224, 0.4); + + .nhsuk-header__navigation-link { + justify-content: flex-end; + } + } + } + + .nhsuk-header__drop-down--hidden { + display: none; + } +} + +.nhsuk-hero__wrapper { + padding-top: 16px; + padding-bottom: 12px; + + h1 { + font-size: 1.4rem; + } +} diff --git a/src/components/icons/WarningIcon.tsx b/src/components/icons/WarningIcon.tsx index 6156e45..a913ecf 100644 --- a/src/components/icons/WarningIcon.tsx +++ b/src/components/icons/WarningIcon.tsx @@ -1,7 +1,7 @@ /* eslint-disable max-len */ -import React, { HTMLProps } from 'react'; +import React from 'react'; -interface WarningIconProps extends HTMLProps { +interface WarningIconProps extends React.HTMLProps { crossOrigin?: '' | 'anonymous' | 'use-credentials' | undefined; inColour?: Boolean; } @@ -18,18 +18,19 @@ const WarningIcon: React.FC = props => { /> ) : ( - - + + ) } -)}; + ) +}; export default WarningIcon; diff --git a/src/components/masked-input/LocalFieldsetContext.tsx b/src/components/masked-input/LocalFieldsetContext.tsx deleted file mode 100644 index ed2e1b5..0000000 --- a/src/components/masked-input/LocalFieldsetContext.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { createContext } from 'react'; - -export type IFieldsetContext = { - isFieldset: boolean; - passError: (componentId: string, error: boolean) => void; - registerComponent: (componentId: string, deregister?: boolean) => void; -}; - -const FieldsetContext = createContext({ - - isFieldset: false, - passError: () => {}, - registerComponent: () => {}, -}); - -export default FieldsetContext; \ No newline at end of file diff --git a/src/components/masked-input/LocalFormGroup.tsx b/src/components/masked-input/LocalFormGroup.tsx deleted file mode 100644 index bb7bb6b..0000000 --- a/src/components/masked-input/LocalFormGroup.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { ReactNode, useState, useEffect, HTMLProps, useContext } from 'react'; -import classNames from 'classnames'; -import { HintText } from 'nhsuk-react-components'; -import { ErrorMessage } from 'nhsuk-react-components'; -import { generateRandomID } from './LocalRandomID'; -import { Label } from 'nhsuk-react-components' -import { FormElementProps } from './LocalFormTypes'; -import FieldsetContext, {IFieldsetContext} from './LocalFieldsetContext'; -import { useFormContext } from 'nhsuk-react-components'; - -type ExcludedProps = - | 'hint' - | 'label' - | 'labelProps' - | 'hintProps' - | 'errorProps' - | 'inputType' - | 'disableErrorLine'; - -type BaseFormElementRenderProps = HTMLProps< - HTMLInputElement | HTMLDivElement | HTMLSelectElement | HTMLTextAreaElement -> & { - error?: string | boolean; -}; - -type FormElementRenderProps = Omit & { - id: string; - name: string; -}; - -export type FormGroupProps = FormElementProps & { - children: (props: FormElementRenderProps) => ReactNode; - inputType: 'input' | 'radios' | 'select' | 'checkboxes' | 'dateinput' | 'textarea'; -}; - -const FormGroup = (props: FormGroupProps): JSX.Element => { - const { - children, - hint, - label, - id, - labelProps, - error, - hintProps, - errorProps, - formGroupProps, - inputType, - disableErrorLine, - name, - ...rest - } = props; - const [generatedID] = useState(generateRandomID(inputType)); - const { isFieldset, registerComponent, passError } = - useContext(FieldsetContext); - const { disableErrorFromComponents } = useFormContext(); - - const elementID = id || generatedID; - const labelID = `${elementID}--label`; - const errorID = `${elementID}--error-message`; - const hintID = `${elementID}--hint`; - - const ariaDescribedBy = [ - hint ? hintID : undefined, - error ? errorID : undefined, - ].filter(Boolean); - - const childProps = { - 'aria-describedby': ariaDescribedBy.join(' ') || undefined, - error, - name: name || elementID, - id: elementID, - ...rest, - } as FormElementRenderProps; - - useEffect(() => { - if (!isFieldset) return; - passError(elementID, disableErrorFromComponents ? false : Boolean(error)); - return () => passError(elementID, false); - }, [elementID, error, isFieldset]); - - useEffect(() => { - registerComponent(elementID); - return () => registerComponent(elementID, true); - }, []); - - const { className: formGroupClassName, ...formGroupRestProps } = formGroupProps || {}; - - return ( -
- {label ? ( - - ) : null} - {hint ? ( - - {hint} - - ) : null} - {error && typeof error === 'string' ? ( - - {error} - - ) : null} - {children(childProps)} -
- ); -}; - -export default FormGroup; diff --git a/src/components/masked-input/LocalFormTypes.tsx b/src/components/masked-input/LocalFormTypes.tsx deleted file mode 100644 index 27b274c..0000000 --- a/src/components/masked-input/LocalFormTypes.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { HTMLProps } from 'react'; -import type { ComponentProps } from "react" -import { ErrorMessageProps } from 'nhsuk-react-components/dist/esm/components/form-elements/error-message/ErrorMessage'; -import { HintTextProps } from 'nhsuk-react-components/dist/esm/components/form-elements/hint-text/HintText'; - -import { Label } from 'nhsuk-react-components' - -type LabelProps = ComponentProps; -export interface FormElementProps { - label?: string; - labelProps?: LabelProps; - error?: string | boolean; - errorProps?: ErrorMessageProps; - hint?: string; - hintProps?: HintTextProps; - formGroupProps?: HTMLProps; - disableErrorLine?: boolean; - id?: string; - name?: string; -} \ No newline at end of file diff --git a/src/components/masked-input/LocalRandomID.tsx b/src/components/masked-input/LocalRandomID.tsx deleted file mode 100644 index 279ff5e..0000000 --- a/src/components/masked-input/LocalRandomID.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export const getRandomString = (length = 5): string => { - const randomNumber = Math.random() + 1; - return randomNumber.toString(36).substring(2, length + 2); - }; - -export const generateRandomName = (prefix?: string): string => { - const randomString = getRandomString(); - return prefix ? `${prefix}_${randomString}` : randomString; - }; - - export const generateRandomID = generateRandomName; diff --git a/src/components/masked-input/MaskedInput.tsx b/src/components/masked-input/MaskedInput.tsx deleted file mode 100644 index cc2dd2f..0000000 --- a/src/components/masked-input/MaskedInput.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { HTMLProps } from 'react'; - -import { FormElementProps } from './LocalFormTypes'; - -import { InputWidth } from 'nhsuk-react-components/dist/esm/util/types/NHSUKTypes'; - -import FormGroup from './LocalFormGroup'; - -// this needs to use "require" or the tests will fail in the pipeline -// TODO - switch react-input-mask for a package that's updated regularly -const ReactInputMask = require('react-input-mask') - -import classNames from 'classnames'; - -type InputMaskRef = - | string - | ((instance: typeof ReactInputMask | null) => void) - | React.RefObject - | null; - -type MaskedInputProps = HTMLProps & -FormElementProps & { - width?: InputWidth; - mask: string; - maskChar?: string; - formatChars?: { [character: string]: string }; - alwaysShowMask?: boolean; - inputRef?: (instance: HTMLInputElement | null) => any; - ref?: InputMaskRef; -}; - -const MaskedInput: React.FC = props => ( - inputType="input" {...props}> - {({ className, width, error, ref, ...rest }) => ( - - )} - -); - -MaskedInput.defaultProps = { - type: 'text', -}; - -export default MaskedInput; diff --git a/src/components/masked-input/__tests__/LocalFormGroup.test.tsx b/src/components/masked-input/__tests__/LocalFormGroup.test.tsx deleted file mode 100644 index 369473e..0000000 --- a/src/components/masked-input/__tests__/LocalFormGroup.test.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import React, { HTMLProps } from 'react'; -import { render } from '@testing-library/react'; -import FormGroup, { FormGroupProps } from '../LocalFormGroup'; - -type InputProps = HTMLProps & { error?: boolean }; -type Optional = Pick, K> & Omit; - -const renderFormGroupComponent = ({ - children = (props) => , - ...rest -}: Optional, 'children'>) => - render( {...rest}>{children}); - -describe('FormGroup', () => { - it('matches snapshot', () => { - const { container } = renderFormGroupComponent({ inputType: 'input', id: 'testId' }); - - expect(container).toMatchSnapshot(); - }); - - it('generates a random ID for the input', () => { - let renderProps; - renderFormGroupComponent({ - inputType: 'input', - children: (props) => { - renderProps = props; - return ; - }, - }); - - expect(renderProps).not.toBe(null); - expect(renderProps!.id).toHaveLength(11); - expect(renderProps!.id).toContain('input'); - }); - - it('allows passing of custom IDs', () => { - let renderProps; - renderFormGroupComponent({ - inputType: 'input', - id: 'TestID2ElectricBoogaloo', - children: (props) => { - renderProps = props; - return ; - }, - }); - - expect(renderProps).not.toBe(null); - expect(renderProps!.id).toBe('TestID2ElectricBoogaloo'); - }); - - it('passes correct props for hint (generated id)', () => { - let renderProps; - const { container } = renderFormGroupComponent({ - inputType: 'input', - hint: 'This is a test hint', - children: (props) => { - renderProps = props; - return ; - }, - }); - - expect(renderProps).not.toBe(null); - expect(renderProps!.id).toHaveLength(11); - expect(renderProps!.id).toContain('input'); - - expect(container.querySelector('input')?.getAttribute('aria-describedby')).toBe( - `${renderProps!.id}--hint`, - ); - expect(container.querySelector('.nhsuk-hint')?.getAttribute('id')).toBe( - `${renderProps!.id}--hint`, - ); - }); - - it('passes correct props for hint (custom id)', () => { - let renderProps; - const { container } = renderFormGroupComponent({ - inputType: 'input', - hint: 'This is a test hint', - id: 'testID', - children: (props) => { - renderProps = props; - return ; - }, - }); - - expect(renderProps).not.toBe(null); - expect(renderProps!.id).toBe('testID'); - - expect(container.querySelector('input')?.getAttribute('aria-describedby')).toBe('testID--hint'); - expect(container.querySelector('.nhsuk-hint')?.getAttribute('id')).toBe('testID--hint'); - }); - - it('passes correct props for label (generated id)', () => { - let renderProps; - const { container } = renderFormGroupComponent({ - inputType: 'input', - label: 'This is a test label', - children: (props) => { - renderProps = props; - return ; - }, - }); - - expect(renderProps).not.toBe(null); - expect(renderProps!.id).toHaveLength(11); - expect(renderProps!.id).toContain('input'); - - expect(container.querySelector('.nhsuk-label')?.getAttribute('id')).toBe( - `${renderProps!.id}--label`, - ); - expect(container.querySelector('.nhsuk-label')?.getAttribute('for')).toBe(renderProps!.id); - }); - - it('passes correct props for label (custom id)', () => { - let renderProps; - const { container } = renderFormGroupComponent({ - inputType: 'input', - label: 'This is a test label', - labelProps: { title: 'TestTitle' }, - id: 'testID', - children: (props) => { - renderProps = props; - return ; - }, - }); - - expect(renderProps).not.toBe(null); - expect(renderProps!.id).toBe('testID'); - - expect(container.querySelector('.nhsuk-label')?.getAttribute('id')).toBe('testID--label'); - expect(container.querySelector('.nhsuk-label')?.getAttribute('for')).toBe('testID'); - expect(container.querySelector('.nhsuk-label')?.textContent).toBe('This is a test label'); - expect(container.querySelector('.nhsuk-label')?.getAttribute('title')).toBe('TestTitle'); - }); - - it('passes correct props for error (generated id)', () => { - let renderProps; - const { container } = renderFormGroupComponent({ - inputType: 'input', - error: 'This is a test error', - errorProps: { title: 'TestTitle' }, - children: (props) => { - renderProps = props; - return ; - }, - }); - - expect(renderProps).not.toBe(null); - expect(renderProps!.id).toHaveLength(11); - expect(renderProps!.id).toContain('input'); - expect(renderProps!['aria-describedby']).toBe(`${renderProps!.id}--error-message`); - - expect(container.querySelector('.nhsuk-error-message')?.getAttribute('id')).toBe( - `${renderProps!.id}--error-message`, - ); - expect(container.querySelector('.nhsuk-error-message')?.textContent).toBe( - 'Error: This is a test error', - ); - expect(container.querySelector('.nhsuk-error-message')?.getAttribute('title')).toBe( - 'TestTitle', - ); - }); - - it('passes correct props for error (custom id)', () => { - let renderProps; - const { container } = renderFormGroupComponent({ - inputType: 'input', - error: 'This is a test error', - errorProps: { title: 'TestTitle' }, - id: 'testID', - children: (props) => { - renderProps = props; - return ; - }, - }); - - expect(renderProps).not.toBe(null); - expect(renderProps!.id).toBe('testID'); - expect(renderProps!['aria-describedby']).toBe(`testID--error-message`); - - - expect(container.querySelector('.nhsuk-error-message')?.getAttribute('id')).toBe( - 'testID--error-message', - ); - expect(container.querySelector('.nhsuk-error-message')?.textContent).toBe( - 'Error: This is a test error', - ); - expect(container.querySelector('.nhsuk-error-message')?.getAttribute('title')).toBe( - 'TestTitle', - ); - }); - - describe('applies the correct classes when errored', () => { - it('string component', () => { - const { container } = renderFormGroupComponent({ - inputType: 'input', - error: "Oh no there's an error!", - - children: ({ error, ...rest }) => , - }); - - expect(container.querySelector('div.nhsuk-form-group')?.classList).toContain( - 'nhsuk-form-group--error', - ); - expect(container.querySelector('.nhsuk-error-message')).toBeTruthy(); - expect(container.querySelector('.nhsuk-error-message')?.textContent).toBe( - "Error: Oh no there's an error!", - ); - }); - - it('boolean component', () => { - const { container } = renderFormGroupComponent({ - inputType: 'input', - error: true, - - children: ({ error, ...rest }) => , - }); - - expect(container.querySelector('div.nhsuk-form-group')?.classList).toContain( - 'nhsuk-form-group--error', - ); - expect(container.querySelector('.nhsuk-error-message')).toBeFalsy(); - }); - }); - - it('should add hint ID and error ID to the aria-describedby of the input', () => { - const { container } = renderFormGroupComponent({ - inputType: 'input', - id: 'error-and-hint', - error: 'This is an error', - hint: 'This is a hint', - - children: ({ error, ...rest }) => , - }); - - const inputElement = container.querySelector('input'); - expect(inputElement).not.toBeNull(); - expect(inputElement?.getAttribute('aria-describedby')).toBe('error-and-hint--hint error-and-hint--error-message'); - }) - - it('should have no aria-describedby when there is no hint or label', () => { - const { container } = renderFormGroupComponent({ - inputType: 'input', - - children: ({ error, ...rest }) => , - }); - - const inputElement = container.querySelector('input'); - expect(inputElement).not.toBeNull(); - - expect(inputElement?.getAttribute('aria-describedby')).toBe(null); - }); -}); diff --git a/src/components/masked-input/__tests__/MaskedInput.test.tsx b/src/components/masked-input/__tests__/MaskedInput.test.tsx deleted file mode 100644 index de0a9db..0000000 --- a/src/components/masked-input/__tests__/MaskedInput.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react' -import MaskedInput from '../MaskedInput'; - -describe('MaskedInput', () => { - it('matches snapshot', () => { - const component = render( - , - ); - expect(component.container).toMatchSnapshot(); - expect(component.container.textContent).toBe('NHS Number'); - }); -}); diff --git a/src/components/masked-input/__tests__/__snapshots__/LocalFormGroup.test.tsx.snap b/src/components/masked-input/__tests__/__snapshots__/LocalFormGroup.test.tsx.snap deleted file mode 100644 index 50a217e..0000000 --- a/src/components/masked-input/__tests__/__snapshots__/LocalFormGroup.test.tsx.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FormGroup matches snapshot 1`] = ` -
-
- -
-
-`; diff --git a/src/components/masked-input/__tests__/__snapshots__/MaskedInput.test.tsx.snap b/src/components/masked-input/__tests__/__snapshots__/MaskedInput.test.tsx.snap deleted file mode 100644 index 5188f5b..0000000 --- a/src/components/masked-input/__tests__/__snapshots__/MaskedInput.test.tsx.snap +++ /dev/null @@ -1,23 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MaskedInput matches snapshot 1`] = ` -
-
- - -
-
-`; diff --git a/src/components/masked-input/index.ts b/src/components/masked-input/index.ts deleted file mode 100644 index 47b13cf..0000000 --- a/src/components/masked-input/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import MaskedInput from './MaskedInput'; -import FormGroup from './LocalFormGroup'; -import type { FormElementProps } from './LocalFormTypes'; -import { Label } from 'nhsuk-react-components'; -import { getRandomString, generateRandomID, generateRandomName } from './LocalRandomID'; -import FieldsetContext from './LocalFieldsetContext'; - -export default MaskedInput; -export { - FormGroup, - FormElementProps, - Label, - getRandomString, - generateRandomID, - generateRandomName, - FieldsetContext } diff --git a/src/components/ribbon-link/RibbonLink.tsx b/src/components/ribbon-link/RibbonLink.tsx index 51cb1a8..b4bf61b 100644 --- a/src/components/ribbon-link/RibbonLink.tsx +++ b/src/components/ribbon-link/RibbonLink.tsx @@ -1,21 +1,22 @@ -/* eslint-disable @typescript-eslint/no-redeclare */ -import React, { HTMLProps } from 'react'; +import * as React from 'react'; import classNames from 'classnames'; -import { ArrowRightCircleIcon } from 'nhsuk-react-components'; import Bar from './components/Bar'; +import './_RibbonLink.scss'; +import { ArrowRightCircleIcon } from "nhsuk-react-components"; type RibbonFlavours = 'hot' | 'mild' | 'cool'; -interface RibbonProps extends HTMLProps { +// Use public, bundler-safe props types +type RibbonProps = React.ComponentPropsWithoutRef<'button'> & { flavour: RibbonFlavours; - type?: 'button' | 'submit' | 'reset'; -} +}; -interface RibbonLink extends React.FC { - Bar: React.FC>; -} +// Static member typed from the actual import to avoid drift +type RibbonLinkComponent = React.FC & { + Bar: typeof Bar; +}; -const RibbonLink: RibbonLink = ({ children, flavour, className, ...rest }) => ( +const RibbonLink: RibbonLinkComponent = ({ children, flavour, className, ...rest }) => (