From 3484635712fa0f4a573f7ab7c65ad8d67b2e0ff1 Mon Sep 17 00:00:00 2001 From: Alex Sanders Date: Fri, 1 Nov 2024 11:50:31 +0000 Subject: [PATCH] Add `Crossword` component to dev kitchen (#1753) Co-authored-by: oliverabrahams --- libs/@guardian/eslint-config/CHANGELOG.md | 38 +- libs/@guardian/eslint-config/configs/react.js | 1 + libs/@guardian/eslint-config/package.json | 2 +- .../source-development-kitchen/CHANGELOG.md | 6 + .../eslint.config.js | 1 + .../source-development-kitchen/package.json | 2 +- .../crossword/@types/crossword.ts | 45 ++ .../crossword/Crossword.stories.tsx | 50 ++ .../react-components/crossword/Crossword.tsx | 37 ++ .../src/react-components/crossword/README.md | 21 + .../crossword/stories/cryptic.ts | 477 +++++++++++++++ .../crossword/stories/everyman.ts | 427 +++++++++++++ .../crossword/stories/prize.ts | 487 +++++++++++++++ .../crossword/stories/quick-cryptic.ts | 355 +++++++++++ .../crossword/stories/quick.ts | 325 ++++++++++ .../crossword/stories/quiptic.ts | 422 +++++++++++++ .../crossword/stories/special.ts | 450 ++++++++++++++ .../crossword/stories/speedy.ts | 383 ++++++++++++ .../crossword/stories/weekend.ts | 405 +++++++++++++ .../AnagramHelper/AnagramHelper.tsx | 252 ++++++++ .../components/AnagramHelper/ClueDisplay.tsx | 57 ++ .../AnagramHelper/SolutionDisplay.tsx | 139 +++++ .../components/AnagramHelper/WordWheel.tsx | 106 ++++ .../mycrossword/components/Button/Button.tsx | 90 +++ .../mycrossword/components/Clue/Clue.tsx | 151 +++++ .../mycrossword/components/Clues/Clues.tsx | 172 ++++++ .../components/Confirm/Confirm.tsx | 85 +++ .../components/Controls/Controls.tsx | 422 +++++++++++++ .../components/Crossword/Crossword.tsx | 288 +++++++++ .../DropdownButton/DropdownButton.tsx | 184 ++++++ .../mycrossword/components/Grid/Grid.tsx | 562 ++++++++++++++++++ .../components/GridCell/GridCell.tsx | 157 +++++ .../components/GridError/GridError.tsx | 49 ++ .../components/GridInput/GridInput.tsx | 57 ++ .../GridSeparators/GridSeparators.tsx | 99 +++ .../components/MyCrossword/MyCrossword.tsx | 104 ++++ .../components/Spinner/Spinner.tsx | 53 ++ .../components/StickyClue/StickyClue.tsx | 144 +++++ .../vendor/mycrossword/components/index.ts | 20 + .../mycrossword/context/CellsContext.tsx | 144 +++++ .../mycrossword/context/CluesContext.tsx | 186 ++++++ .../mycrossword/context/GameProvider.tsx | 17 + .../vendor/mycrossword/hooks/index.ts | 5 + .../hooks/useBreakpoint/useBreakpoint.ts | 46 ++ .../hooks/useDebounce/useDebounce.ts | 20 + .../hooks/useLocalStorage/useLocalStorage.ts | 26 + .../crossword/vendor/mycrossword/index.tsx | 11 + .../vendor/mycrossword/interfaces/Cell.ts | 12 + .../mycrossword/interfaces/CellChange.ts | 9 + .../mycrossword/interfaces/CellFocus.ts | 8 + .../mycrossword/interfaces/CellPosition.ts | 6 + .../vendor/mycrossword/interfaces/Char.ts | 41 ++ .../vendor/mycrossword/interfaces/Clue.ts | 8 + .../mycrossword/interfaces/Direction.ts | 4 + .../mycrossword/interfaces/GuardianClue.ts | 16 + .../interfaces/GuardianCrossword.ts | 30 + .../mycrossword/interfaces/GuessGrid.ts | 7 + .../interfaces/SeparatorLocations.ts | 4 + .../vendor/mycrossword/interfaces/index.ts | 14 + .../vendor/mycrossword/react-app-env.d.ts | 3 + .../mycrossword/testData/test.incomplete.1.ts | 90 +++ .../mycrossword/testData/test.invalid.1.ts | 90 +++ .../mycrossword/testData/test.invalid.2.ts | 90 +++ .../mycrossword/testData/test.invalid.3.ts | 90 +++ .../mycrossword/testData/test.invalid.4.ts | 102 ++++ .../mycrossword/testData/test.invalid.5.ts | 102 ++++ .../mycrossword/testData/test.invalid.6.ts | 90 +++ .../mycrossword/testData/test.invalid.7.ts | 90 +++ .../mycrossword/testData/test.valid.1.ts | 90 +++ .../vendor/mycrossword/utils/cell.ts | 147 +++++ .../vendor/mycrossword/utils/clue.ts | 85 +++ .../vendor/mycrossword/utils/general.ts | 36 ++ .../vendor/mycrossword/utils/guess.ts | 65 ++ .../vendor/mycrossword/utils/jest.ts | 11 + .../src/react-components/index.test.ts | 2 + .../src/react-components/index.ts | 3 + 76 files changed, 8913 insertions(+), 12 deletions(-) create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/@types/crossword.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/Crossword.stories.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/Crossword.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/README.md create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/cryptic.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/everyman.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/prize.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quick-cryptic.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quick.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quiptic.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/special.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/speedy.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/weekend.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/AnagramHelper.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/ClueDisplay.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/SolutionDisplay.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/WordWheel.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Button/Button.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Clue/Clue.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Clues/Clues.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Confirm/Confirm.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Controls/Controls.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Crossword/Crossword.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/DropdownButton/DropdownButton.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Grid/Grid.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridCell/GridCell.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridError/GridError.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridInput/GridInput.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridSeparators/GridSeparators.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/MyCrossword/MyCrossword.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Spinner/Spinner.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/StickyClue/StickyClue.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/index.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/CellsContext.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/CluesContext.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/GameProvider.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/index.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useBreakpoint/useBreakpoint.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useDebounce/useDebounce.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useLocalStorage/useLocalStorage.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/index.tsx create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Cell.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellChange.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellFocus.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellPosition.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Char.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Clue.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Direction.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuardianClue.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuardianCrossword.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuessGrid.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/SeparatorLocations.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/index.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/react-app-env.d.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.incomplete.1.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.1.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.2.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.3.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.4.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.5.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.6.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.7.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.valid.1.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/cell.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/clue.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/general.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/guess.ts create mode 100644 libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/jest.ts diff --git a/libs/@guardian/eslint-config/CHANGELOG.md b/libs/@guardian/eslint-config/CHANGELOG.md index face38132..b9a48dc9a 100644 --- a/libs/@guardian/eslint-config/CHANGELOG.md +++ b/libs/@guardian/eslint-config/CHANGELOG.md @@ -1,5 +1,13 @@ # @guardian/eslint-config +## 10.0.0-beta.5 + +### Breaking Changes + +`react-hooks/exhaustive-deps` is now an `error` (rather than `warn`). + +This means that if a hook that takes an array of deps is not passed all the deps it needs, linting will fail. + ## 10.0.0-beta.4 ### Patch Changes @@ -43,7 +51,8 @@ It also includes configs for `jest`, `storybook` and `react`. See the [README](README.md) for full details. -> ESLint 9 contains a lot of breaking changes, including a new config format. See their [migration guide](https://eslint.org/docs/latest/use/migrate-to-9.0.0) for more details. +> ESLint 9 contains a lot of breaking changes, including a new config format. See +> their [migration guide](https://eslint.org/docs/latest/use/migrate-to-9.0.0) for more details. > > Note that [ESLint 8 is EOL 2024-10-05](https://eslint.org/version-support/). @@ -62,7 +71,9 @@ See the [README](README.md) for full details. ### Patch Changes -- 0382052: 1. All packages are now ES modules, although they should be compatible with CommonJS environments. 2. Adds entry points for projects that can consume [`package.json#exports`](https://nodejs.org/api/packages.html#exports), alongside `main`. +- 0382052: 1. All packages are now ES modules, although they should be compatible with CommonJS environments. 2. Adds + entry points for projects that can consume [`package.json#exports`](https://nodejs.org/api/packages.html#exports), + alongside `main`. ## 8.0.0 @@ -70,9 +81,11 @@ See the [README](README.md) for full details. - cc7aa7d: Requires curly braces in all circumstances. - This should help reduce noise in diffs, and remove ambiguity about when you should use curly braces (and possibly when a block starts and ends), especially for people unfamiliar with the language. + This should help reduce noise in diffs, and remove ambiguity about when you should use curly braces (and possibly when + a block starts and ends), especially for people unfamiliar with the language. - _Note that this rule is fixable, so running eslint with the `--fix` flag will automatically update your code to comply with the new setting._ + _Note that this rule is fixable, so running eslint with the `--fix` flag will automatically update your code to comply + with the new setting._ ## 7.0.1 @@ -93,7 +106,8 @@ See the [README](README.md) for full details. ### Major Changes -- 9e0cb43: `@typescript-eslint/eslint-plugin` and `@typescript-eslint/parser` dependencies upgraded to next major version (6). +- 9e0cb43: `@typescript-eslint/eslint-plugin` and `@typescript-eslint/parser` dependencies upgraded to next major + version (6). ## 5.0.0 @@ -151,14 +165,17 @@ See the [README](README.md) for full details. We extended `plugin:prettier/recommended` which - 1. used `eslint-config-prettier` to disable any white-space formatting rules that would conflict with our prettier config - 2. used `eslint-plugin-prettier` to lint for formatting errors that did not match our prettier config + 1. used `eslint-config-prettier` to disable any white-space formatting rules that would conflict with our prettier + config + 2. used `eslint-plugin-prettier` to lint for formatting errors that did not match our prettier config - This is quite expensive, and although it means you could use `--fix` to apply prettier, it's not as fast as using prettier directly. + This is quite expensive, and although it means you could use `--fix` to apply prettier, it's not as fast as using + prettier directly. ### After - We still use `eslint-config-prettier` to avoid conflicts with our `prettier` config, but we no longer lint for errors (and therefore also don't fix them). + We still use `eslint-config-prettier` to avoid conflicts with our `prettier` config, but we no longer lint for + errors (and therefore also don't fix them). ### Recommendations @@ -167,7 +184,8 @@ See the [README](README.md) for full details. - via [editor integration](https://prettier.io/docs/en/editors.html) - via a [pre-commit hook](https://prettier.io/docs/en/precommit.html) - If you prefer the way this used to work (applying `prettier` formatting as part of linting), add the [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) manually to your ESLint config. + If you prefer the way this used to work (applying `prettier` formatting as part of linting), add + the [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) manually to your ESLint config. ## 1.0.2 diff --git a/libs/@guardian/eslint-config/configs/react.js b/libs/@guardian/eslint-config/configs/react.js index 0575328e6..b197ae951 100644 --- a/libs/@guardian/eslint-config/configs/react.js +++ b/libs/@guardian/eslint-config/configs/react.js @@ -29,6 +29,7 @@ export default [ }, ], ...hooks.configs.recommended.rules, + 'react-hooks/exhaustive-deps': 'error', }, settings: { react: { diff --git a/libs/@guardian/eslint-config/package.json b/libs/@guardian/eslint-config/package.json index c0b8f2f73..354a92d85 100644 --- a/libs/@guardian/eslint-config/package.json +++ b/libs/@guardian/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@guardian/eslint-config", - "version": "10.0.0-beta.4", + "version": "10.0.0-beta.5", "description": "ESLint config for Guardian JavaScript projects", "type": "module", "main": "index.js", diff --git a/libs/@guardian/source-development-kitchen/CHANGELOG.md b/libs/@guardian/source-development-kitchen/CHANGELOG.md index fed43e179..a9c1207ff 100644 --- a/libs/@guardian/source-development-kitchen/CHANGELOG.md +++ b/libs/@guardian/source-development-kitchen/CHANGELOG.md @@ -1,5 +1,11 @@ # @guardian/source-development-kitchen +## 13.1.0 + +### Minor Changes + +- afe409d: Add WIP `Crossword` component + ## 13.0.0 ### Major Changes diff --git a/libs/@guardian/source-development-kitchen/eslint.config.js b/libs/@guardian/source-development-kitchen/eslint.config.js index e41b98504..683756d89 100644 --- a/libs/@guardian/source-development-kitchen/eslint.config.js +++ b/libs/@guardian/source-development-kitchen/eslint.config.js @@ -7,6 +7,7 @@ export default [ 'jest.dist.*', // depends on build output, so don't lint it '.wireit', 'storybook-static', + 'src/react-components/crossword/vendor', ], }, ...guardian.configs.recommended, diff --git a/libs/@guardian/source-development-kitchen/package.json b/libs/@guardian/source-development-kitchen/package.json index 740e440b0..bc70e301e 100644 --- a/libs/@guardian/source-development-kitchen/package.json +++ b/libs/@guardian/source-development-kitchen/package.json @@ -1,6 +1,6 @@ { "name": "@guardian/source-development-kitchen", - "version": "13.0.0", + "version": "13.1.0", "sideEffects": false, "type": "module", "exports": { diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/@types/crossword.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/@types/crossword.ts new file mode 100644 index 000000000..62e2b9815 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/@types/crossword.ts @@ -0,0 +1,45 @@ +export type CrosswordData = { + creator?: { + name: string; + webUrl: string; + }; + crosswordType: + | 'cryptic' + | 'everyman' + | 'prize' + | 'quick-cryptic' + | 'quick' + | 'quiptic' + | 'special' + | 'speedy' + | 'weekend'; + + date: number; + dateSolutionAvailable?: number; + dimensions: { + cols: number; + rows: number; + }; + entries: Array<{ + clue: string; + direction: 'across' | 'down'; + group: string[]; + humanNumber: string; + id: string; + length: number; + number: number; + position: { x: number; y: number }; + separatorLocations: { + ','?: number[] | undefined; + '-'?: number[] | undefined; + }; + solution?: string; + }>; + id: string; + name: string; + number: number; + pdf?: string; + solutionAvailable: boolean; + webPublicationDate?: number; + instructions?: string; +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/Crossword.stories.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/Crossword.stories.tsx new file mode 100644 index 000000000..4358e15fb --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/Crossword.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryFn } from '@storybook/react'; +import type { CrosswordProps } from './Crossword'; +import { Crossword } from './Crossword'; +import { cryptic } from './stories/cryptic'; +import { everyman } from './stories/everyman'; +import { prize } from './stories/prize'; +import { quick } from './stories/quick'; +import { quickCryptic } from './stories/quick-cryptic'; +import { quiptic } from './stories/quiptic'; +import { special } from './stories/special'; +import { speedy } from './stories/speedy'; +import { weekend } from './stories/weekend'; + +const meta: Meta = { + component: Crossword, + title: 'React Components/Crossword', +}; + +export default meta; + +const Template: StoryFn = (args: CrosswordProps) => { + return ; +}; + +export const Cryptic: StoryFn = Template.bind({}); +Cryptic.args = { data: cryptic }; + +export const Everyman: StoryFn = Template.bind({}); +Everyman.args = { data: everyman }; + +export const Prize: StoryFn = Template.bind({}); +Prize.args = { data: prize }; + +export const Quick: StoryFn = Template.bind({}); +Quick.args = { data: quick }; + +export const QuickCryptic: StoryFn = Template.bind({}); +QuickCryptic.args = { data: quickCryptic }; + +export const Quiptic: StoryFn = Template.bind({}); +Quiptic.args = { data: quiptic }; + +export const Special: StoryFn = Template.bind({}); +Special.args = { data: special }; + +export const Speedy: StoryFn = Template.bind({}); +Speedy.args = { data: speedy }; + +export const Weekend: StoryFn = Template.bind({}); +Weekend.args = { data: weekend }; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/Crossword.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/Crossword.tsx new file mode 100644 index 000000000..3bc611c95 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/Crossword.tsx @@ -0,0 +1,37 @@ +import { css } from '@emotion/react'; +import { palette } from '@guardian/source/foundations'; +import type { CrosswordData } from './@types/crossword'; +import { CrosswordPlayer } from './vendor/mycrossword'; + +export type CrosswordProps = { + data: CrosswordData; + theme?: { + background?: string; + grid?: string; + }; +}; + +const defaultTheme: CrosswordProps['theme'] = { + background: palette.neutral[100], + grid: palette.neutral[7], +}; + +export const Crossword = ({ + theme: userTheme, + data, + ...props +}: CrosswordProps) => { + const theme = { ...defaultTheme, ...userTheme }; + + return ( +
+ +
+ ); +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/README.md b/libs/@guardian/source-development-kitchen/src/react-components/crossword/README.md new file mode 100644 index 000000000..ce15cd957 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/README.md @@ -0,0 +1,21 @@ +# `Crossword` + +A standalone component for rendering crosswords. + +## Install + +```sh +$ pnpm add @guardian/source-development-kitchen +``` + +or + +```sh +$ npm i @guardian/source-development-kitchen +``` + +## Use + +### API + +See [storybook](https://guardian.github.io/storybooks/?path=/docs/source-development-kitchen_react-components-crossword--docs) diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/cryptic.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/cryptic.ts new file mode 100644 index 000000000..88434643b --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/cryptic.ts @@ -0,0 +1,477 @@ +import type { CrosswordData } from '../@types/crossword'; + +export const cryptic: CrosswordData = { + id: 'crosswords/cryptic/29528', + number: 29528, + name: 'Cryptic crossword No 29,528', + creator: { + name: 'Brockwell', + webUrl: 'https://www.theguardian.com/profile/brockwell', + }, + date: 1730332800000, + webPublicationDate: 1730332802000, + entries: [ + { + id: '7-across', + number: 7, + humanNumber: '7', + clue: 'Optimistic, like male of 17ac? (7)', + direction: 'across', + length: 7, + group: ['7-across'], + position: { + x: 0, + y: 1, + }, + separatorLocations: {}, + solution: 'BULLISH', + }, + { + id: '8-across', + number: 8, + humanNumber: '8', + clue: 'Writer in retreat welcomed by fat cat (7)', + direction: 'across', + length: 7, + group: ['8-across'], + position: { + x: 8, + y: 1, + }, + separatorLocations: {}, + solution: 'LEOPARD', + }, + { + id: '9-across', + number: 9, + humanNumber: '9', + clue: 'Eagle not the last for Gary Player? (4)', + direction: 'across', + length: 4, + group: ['9-across'], + position: { + x: 0, + y: 3, + }, + separatorLocations: {}, + solution: 'HARP', + }, + { + id: '10-across', + number: 10, + humanNumber: '10', + clue: 'Heavy defeat at home by City, as per usual (9)', + direction: 'across', + length: 9, + group: ['10-across'], + position: { + x: 5, + y: 3, + }, + separatorLocations: {}, + solution: 'ROUTINELY', + }, + { + id: '12-across', + number: 12, + humanNumber: '12', + clue: 'Ridiculous record by 17ac (5)', + direction: 'across', + length: 5, + group: ['12-across'], + position: { + x: 1, + y: 5, + }, + separatorLocations: {}, + solution: 'CRAZY', + }, + { + id: '13-across', + number: 13, + humanNumber: '13', + clue: 'Life’s in a mess for 17ac (8)', + direction: 'across', + length: 8, + group: ['13-across'], + position: { + x: 7, + y: 5, + }, + separatorLocations: {}, + solution: 'FINALISE', + }, + { + id: '15-across', + number: 15, + humanNumber: '15', + clue: 'Bed broken by adult film (4)', + direction: 'across', + length: 4, + group: ['15-across'], + position: { + x: 0, + y: 7, + }, + separatorLocations: {}, + solution: 'COAT', + }, + { + id: '16-across', + number: 16, + humanNumber: '16', + clue: 'Peer tackling drip in toilet (5)', + direction: 'across', + length: 5, + group: ['16-across'], + position: { + x: 5, + y: 7, + }, + separatorLocations: {}, + solution: 'PRIVY', + }, + { + id: '17-across', + number: 17, + humanNumber: '17', + clue: 'Swimmer occasionally ill at ease doing backstroke? (4)', + direction: 'across', + length: 4, + group: ['17-across'], + position: { + x: 11, + y: 7, + }, + separatorLocations: {}, + solution: 'SEAL', + }, + { + id: '18-across', + number: 18, + humanNumber: '18', + clue: 'Unlimited plums to take on again (2-6)', + direction: 'across', + length: 8, + group: ['18-across'], + position: { + x: 0, + y: 9, + }, + separatorLocations: { + '-': [2], + }, + solution: 'REENGAGE', + }, + { + id: '20-across', + number: 20, + humanNumber: '20', + clue: 'Gang member bored by opening of Magic Flute (5)', + direction: 'across', + length: 5, + group: ['20-across'], + position: { + x: 9, + y: 9, + }, + separatorLocations: {}, + solution: 'CRIMP', + }, + { + id: '21-across', + number: 21, + humanNumber: '21', + clue: 'Cabaret dancing queen is 17ac (4-5)', + direction: 'across', + length: 9, + group: ['21-across'], + position: { + x: 1, + y: 11, + }, + separatorLocations: { + '-': [4], + }, + solution: 'CRABEATER', + }, + { + id: '22-across', + number: 22, + humanNumber: '22', + clue: 'Brother working within revolutionary unit (4)', + direction: 'across', + length: 4, + group: ['22-across'], + position: { + x: 11, + y: 11, + }, + separatorLocations: {}, + solution: 'MONK', + }, + { + id: '24-across', + number: 24, + humanNumber: '24', + clue: 'British artist coming back in time to entertain (7)', + direction: 'across', + length: 7, + group: ['24-across'], + position: { + x: 0, + y: 13, + }, + separatorLocations: {}, + solution: 'HARBOUR', + }, + { + id: '25-across', + number: 25, + humanNumber: '25', + clue: 'Female virtue cracked secret (7)', + direction: 'across', + length: 7, + group: ['25-across'], + position: { + x: 8, + y: 13, + }, + separatorLocations: {}, + solution: 'FURTIVE', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Young adult in passive stage (4)', + direction: 'down', + length: 4, + group: ['1-down'], + position: { + x: 1, + y: 0, + }, + separatorLocations: {}, + solution: 'PUPA', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Footballer on the up – hard worker and big beast! (8)', + direction: 'down', + length: 8, + group: ['2-down'], + position: { + x: 3, + y: 0, + }, + separatorLocations: {}, + solution: 'ELEPHANT', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'Husband leaving smoking pot in a lost state (6)', + direction: 'down', + length: 6, + group: ['3-down'], + position: { + x: 5, + y: 0, + }, + separatorLocations: {}, + solution: 'ASTRAY', + }, + { + id: '4-down', + number: 4, + humanNumber: '4', + clue: 'Communist country’s borders admitting drug tolerance (8)', + direction: 'down', + length: 8, + group: ['4-down'], + position: { + x: 9, + y: 0, + }, + separatorLocations: {}, + solution: 'LENIENCY', + }, + { + id: '5-down', + number: 5, + humanNumber: '5', + clue: '“Howzat” call from Lyon saving Australia (6)', + direction: 'down', + length: 6, + group: ['5-down'], + position: { + x: 11, + y: 0, + }, + separatorLocations: {}, + solution: 'APPEAL', + }, + { + id: '6-down', + number: 6, + humanNumber: '6', + clue: 'Old nag (4)', + direction: 'down', + length: 4, + group: ['6-down'], + position: { + x: 13, + y: 0, + }, + separatorLocations: {}, + solution: 'GREY', + }, + { + id: '11-down', + number: 11, + humanNumber: '11', + clue: 'Most partisan United fan whipped up anger on street (9)', + direction: 'down', + length: 9, + group: ['11-down'], + position: { + x: 7, + y: 3, + }, + separatorLocations: {}, + solution: 'UNFAIREST', + }, + { + id: '12-down', + number: 12, + humanNumber: '12', + clue: 'Charlie to get away from 17ac (5)', + direction: 'down', + length: 5, + group: ['12-down'], + position: { + x: 1, + y: 5, + }, + separatorLocations: {}, + solution: 'CLOSE', + }, + { + id: '14-down', + number: 14, + humanNumber: '14', + clue: 'Mark regularly using satnav maps (5)', + direction: 'down', + length: 5, + group: ['14-down'], + position: { + x: 13, + y: 5, + }, + separatorLocations: {}, + solution: 'STAMP', + }, + { + id: '16-down', + number: 16, + humanNumber: '16', + clue: 'Sleepy dictator’s gun loaded (8)', + direction: 'down', + length: 8, + group: ['16-down'], + position: { + x: 5, + y: 7, + }, + separatorLocations: {}, + solution: 'PEACEFUL', + }, + { + id: '17-down', + number: 17, + humanNumber: '17', + clue: 'Square couple holding joint for fellow traveller (8)', + direction: 'down', + length: 8, + group: ['17-down'], + position: { + x: 11, + y: 7, + }, + separatorLocations: {}, + solution: 'SHIPMATE', + }, + { + id: '19-down', + number: 19, + humanNumber: '19', + clue: 'Barney Rubble is 12dn (6)', + direction: 'down', + length: 6, + group: ['19-down'], + position: { + x: 3, + y: 9, + }, + separatorLocations: {}, + solution: 'NEARBY', + }, + { + id: '20-down', + number: 20, + humanNumber: '20', + clue: 'Show around a lost American (6)', + direction: 'down', + length: 6, + group: ['20-down'], + position: { + x: 9, + y: 9, + }, + separatorLocations: {}, + solution: 'CIRCUS', + }, + { + id: '21-down', + number: 21, + humanNumber: '21', + clue: 'Crack and heroin smuggled by 17ac (4)', + direction: 'down', + length: 4, + group: ['21-down'], + position: { + x: 1, + y: 11, + }, + separatorLocations: {}, + solution: 'CHAP', + }, + { + id: '23-down', + number: 23, + humanNumber: '23', + clue: 'Blue, like stilton supplied by Spooner (4)', + direction: 'down', + length: 4, + group: ['23-down'], + position: { + x: 13, + y: 11, + }, + separatorLocations: {}, + solution: 'NAVY', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1730332800000, + dimensions: { + cols: 15, + rows: 15, + }, + crosswordType: 'cryptic', + pdf: 'https://crosswords-static.guim.co.uk/gdn.cryptic.20241031.pdf', +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/everyman.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/everyman.ts new file mode 100644 index 000000000..f08240bb5 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/everyman.ts @@ -0,0 +1,427 @@ +import type { CrosswordData } from '../@types/crossword'; + +export const everyman: CrosswordData = { + id: 'crosswords/everyman/4071', + number: 4071, + name: 'Everyman crossword No 4,071', + date: 1729987200000, + webPublicationDate: 1729983645000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Old Testament prophet seen in tam-o’-shanter (4)', + direction: 'across', + length: 4, + group: ['1-across'], + position: { + x: 3, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Intensity regularly dropped in avian protection (4)', + direction: 'across', + length: 4, + group: ['4-across'], + position: { + x: 8, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '8-across', + number: 8, + humanNumber: '8', + clue: '‘How eerie!’ ‘Why, sure!’ Uneasy sentiment when abroad (4,3,4,4)', + direction: 'across', + length: 15, + group: ['8-across'], + position: { + x: 0, + y: 2, + }, + separatorLocations: { + ',': [4, 7, 11], + }, + }, + { + id: '11-across', + number: 11, + humanNumber: '11', + clue: 'Chose, after imbibing drop of Laphroaig, to become drunk (7)', + direction: 'across', + length: 7, + group: ['11-across'], + position: { + x: 0, + y: 4, + }, + separatorLocations: {}, + }, + { + id: '12-across', + number: 12, + humanNumber: '12', + clue: 'Moves heavily wanting sleep after cycling (7)', + direction: 'across', + length: 7, + group: ['12-across'], + position: { + x: 8, + y: 4, + }, + separatorLocations: {}, + }, + { + id: '13-across', + number: 13, + humanNumber: '13', + clue: 'With buff back muscles, doing a flip, they’re resolute (9)', + direction: 'across', + length: 9, + group: ['13-across'], + position: { + x: 0, + y: 6, + }, + separatorLocations: {}, + }, + { + id: '14-across', + number: 14, + humanNumber: '14', + clue: 'Tax channel (5)', + direction: 'across', + length: 5, + group: ['14-across'], + position: { + x: 10, + y: 6, + }, + separatorLocations: {}, + }, + { + id: '15-across', + number: 15, + humanNumber: '15', + clue: 'Wristwatch, say: not so exciting but a becoming one (5)', + direction: 'across', + length: 5, + group: ['15-across'], + position: { + x: 0, + y: 8, + }, + separatorLocations: {}, + }, + { + id: '16-across', + number: 16, + humanNumber: '16', + clue: 'Like a competition involving multiple setters? (3-3-3)', + direction: 'across', + length: 9, + group: ['16-across'], + position: { + x: 6, + y: 8, + }, + separatorLocations: { + '-': [3, 6], + }, + }, + { + id: '19-across', + number: 19, + humanNumber: '19', + clue: 'Ghastly duck penetrating – we need camouflage! (7)', + direction: 'across', + length: 7, + group: ['19-across'], + position: { + x: 0, + y: 10, + }, + separatorLocations: {}, + }, + { + id: '21-across', + number: 21, + humanNumber: '21', + clue: 'Erstwhile capital offence: agent provocateur finally taken, 7 beheaded (7)', + direction: 'across', + length: 7, + group: ['21-across'], + position: { + x: 8, + y: 10, + }, + separatorLocations: {}, + }, + { + id: '22-across', + number: 22, + humanNumber: '22', + clue: 'Liberally salt the roast, say? Who does that any more? (5,2,4,4)', + direction: 'across', + length: 15, + group: ['22-across'], + position: { + x: 0, + y: 12, + }, + separatorLocations: { + ',': [5, 7, 11], + }, + }, + { + id: '23-across', + number: 23, + humanNumber: '23', + clue: 'Everyman gripped by American science fiction? Hardly (2,2)', + direction: 'across', + length: 4, + group: ['23-across'], + position: { + x: 3, + y: 14, + }, + separatorLocations: { + ',': [2], + }, + }, + { + id: '24-across', + number: 24, + humanNumber: '24', + clue: 'What pothead may get from – or do to – his habit (4)', + direction: 'across', + length: 4, + group: ['24-across'], + position: { + x: 8, + y: 14, + }, + separatorLocations: {}, + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Historic ship showing might on river (9)', + direction: 'down', + length: 9, + group: ['2-down'], + position: { + x: 4, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'Quiet location for milk … shake (7)', + direction: 'down', + length: 7, + group: ['3-down'], + position: { + x: 6, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '4-down', + number: 4, + humanNumber: '4', + clue: 'Annoys the French (a primary requirement)? (7)', + direction: 'down', + length: 7, + group: ['4-down'], + position: { + x: 8, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '5-down', + number: 5, + humanNumber: '5', + clue: 'Energy shown by Second XI? (5)', + direction: 'down', + length: 5, + group: ['5-down'], + position: { + x: 10, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '6-down', + number: 6, + humanNumber: '6', + clue: 'Pinches small tissues (6)', + direction: 'down', + length: 6, + group: ['6-down'], + position: { + x: 0, + y: 1, + }, + separatorLocations: {}, + }, + { + id: '7-down', + number: 7, + humanNumber: '7', + clue: 'Shaking tail end, watch a lad fall in Vermont, for example? (6)', + direction: 'down', + length: 6, + group: ['7-down'], + position: { + x: 14, + y: 1, + }, + separatorLocations: {}, + }, + { + id: '9-down', + number: 9, + humanNumber: '9', + clue: 'TikTok etc, therefore claim idea is deranged (6,5)', + direction: 'down', + length: 11, + group: ['9-down'], + position: { + x: 2, + y: 2, + }, + separatorLocations: { + ',': [6], + }, + }, + { + id: '10-down', + number: 10, + humanNumber: '10', + clue: 'On the phone, Ms Raducanu will call Ireland (7,4)', + direction: 'down', + length: 11, + group: ['10-down'], + position: { + x: 12, + y: 2, + }, + separatorLocations: { + ',': [7], + }, + }, + { + id: '14-down', + number: 14, + humanNumber: '14', + clue: 'Using reason, conciliated freely? That’s not on! (9)', + direction: 'down', + length: 9, + group: ['14-down'], + position: { + x: 10, + y: 6, + }, + separatorLocations: {}, + }, + { + id: '15-down', + number: 15, + humanNumber: '15', + clue: 'Turncoats abandoning HMS inhabited this island, primarily? (6)', + direction: 'down', + length: 6, + group: ['15-down'], + position: { + x: 0, + y: 8, + }, + separatorLocations: {}, + }, + { + id: '16-down', + number: 16, + humanNumber: '16', + clue: 'Scribble, leave hurriedly (4,3)', + direction: 'down', + length: 7, + group: ['16-down'], + position: { + x: 6, + y: 8, + }, + separatorLocations: { + ',': [4], + }, + }, + { + id: '17-down', + number: 17, + humanNumber: '17', + clue: 'Understand second Beatles hit (3,4)', + direction: 'down', + length: 7, + group: ['17-down'], + position: { + x: 8, + y: 8, + }, + separatorLocations: { + ',': [3], + }, + }, + { + id: '18-down', + number: 18, + humanNumber: '18', + clue: 'They’re beyond help? King George succeeded in the protection of one (6)', + direction: 'down', + length: 6, + group: ['18-down'], + position: { + x: 14, + y: 8, + }, + separatorLocations: {}, + }, + { + id: '20-down', + number: 20, + humanNumber: '20', + clue: 'Where to get dates and a drink for ‘90s rockers (5)', + direction: 'down', + length: 5, + group: ['20-down'], + position: { + x: 4, + y: 10, + }, + separatorLocations: {}, + }, + ], + solutionAvailable: false, + dateSolutionAvailable: 1730592000000, + dimensions: { + cols: 15, + rows: 15, + }, + crosswordType: 'everyman', + pdf: 'https://crosswords-static.guim.co.uk/obs.everyman.20241027.pdf', +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/prize.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/prize.ts new file mode 100644 index 000000000..31139c99d --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/prize.ts @@ -0,0 +1,487 @@ +import type { CrosswordData } from '../@types/crossword'; + +export const prize: CrosswordData = { + id: 'crosswords/prize/29524', + number: 29524, + name: 'Prize crossword No 29,524', + creator: { + name: 'Brummie', + webUrl: 'https://www.theguardian.com/profile/brummie', + }, + date: 1729900800000, + webPublicationDate: 1729897217000, + entries: [ + { + id: '9-across', + number: 9, + humanNumber: '9', + clue: 'Rod’s rude attention-seeker (5)', + direction: 'across', + length: 5, + group: ['9-across'], + position: { + x: 0, + y: 1, + }, + separatorLocations: {}, + }, + { + id: '10-across', + number: 10, + humanNumber: '10', + clue: 'Sally crosses stage, as a form of casting (9)', + direction: 'across', + length: 9, + group: ['10-across'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + }, + { + id: '11-across', + number: 11, + humanNumber: '11', + clue: 'Heading action needed by the one in possession? (5,4)', + direction: 'across', + length: 9, + group: ['11-across'], + position: { + x: 0, + y: 3, + }, + separatorLocations: { + ',': [5], + }, + }, + { + id: '12-across', + number: 12, + humanNumber: '12', + clue: 'Recruit turned Lone Ranger’s head (5)', + direction: 'across', + length: 5, + group: ['12-across'], + position: { + x: 10, + y: 3, + }, + separatorLocations: {}, + }, + { + id: '13-across', + number: 13, + humanNumber: '13', + clue: 'African republic imprisons a church prophet (7)', + direction: 'across', + length: 7, + group: ['13-across'], + position: { + x: 0, + y: 5, + }, + separatorLocations: {}, + }, + { + id: '15-across', + number: 15, + humanNumber: '15', + clue: 'Guardian pursuing lost sheep arrives at old city (7)', + direction: 'across', + length: 7, + group: ['15-across'], + position: { + x: 8, + y: 5, + }, + separatorLocations: {}, + }, + { + id: '17-across', + number: 17, + humanNumber: '17', + clue: 'US city’s on about ‘optical device’ (5)', + direction: 'across', + length: 5, + group: ['17-across'], + position: { + x: 0, + y: 7, + }, + separatorLocations: {}, + }, + { + id: '18-across', + number: 18, + humanNumber: '18', + clue: 'Briefly against disposing of a machine separating fibre (3)', + direction: 'across', + length: 3, + group: ['18-across'], + position: { + x: 6, + y: 7, + }, + separatorLocations: {}, + }, + { + id: '20-across', + number: 20, + humanNumber: '20', + clue: 'Oscar winner’s debut lied about? (5)', + direction: 'across', + length: 5, + group: ['20-across'], + position: { + x: 10, + y: 7, + }, + separatorLocations: {}, + }, + { + id: '22-across', + number: 22, + humanNumber: '22', + clue: 'Right to leave a free-for-all sale (7)', + direction: 'across', + length: 7, + group: ['22-across'], + position: { + x: 0, + y: 9, + }, + separatorLocations: {}, + }, + { + id: '25-across', + number: 25, + humanNumber: '25', + clue: 'Pressure one’s briefly put at centre of ancient monument (7)', + direction: 'across', + length: 7, + group: ['25-across'], + position: { + x: 8, + y: 9, + }, + separatorLocations: {}, + }, + { + id: '26-across', + number: 26, + humanNumber: '26', + clue: 'Liverpudlian ejecting college drunkard (5)', + direction: 'across', + length: 5, + group: ['26-across'], + position: { + x: 0, + y: 11, + }, + separatorLocations: {}, + }, + { + id: '27-across', + number: 27, + humanNumber: '27', + clue: 'Sparkler? Patience! (9)', + direction: 'across', + length: 9, + group: ['27-across'], + position: { + x: 6, + y: 11, + }, + separatorLocations: {}, + }, + { + id: '30-across', + number: 30, + humanNumber: '30', + clue: 'Drama about oil spill trapping large protected mammal (9)', + direction: 'across', + length: 9, + group: ['30-across'], + position: { + x: 0, + y: 13, + }, + separatorLocations: {}, + }, + { + id: '31-across', + number: 31, + humanNumber: '31', + clue: 'Beloved pasta sauce’s back (5)', + direction: 'across', + length: 5, + group: ['31-across'], + position: { + x: 10, + y: 13, + }, + separatorLocations: {}, + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Ends up as Chesil Beach, say (4)', + direction: 'down', + length: 4, + group: ['1-down'], + position: { + x: 0, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Burlesque? Let’s change game (8)', + direction: 'down', + length: 8, + group: ['2-down'], + position: { + x: 2, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'Charge round roof entrance on the house (4)', + direction: 'down', + length: 4, + group: ['3-down'], + position: { + x: 4, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '4-down', + number: 4, + humanNumber: '4', + clue: 'Conducting upbeat intro with female band (8)', + direction: 'down', + length: 8, + group: ['4-down'], + position: { + x: 6, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '5-down', + number: 5, + humanNumber: '5', + clue: 'Join lamb’s rump and hog’s back (6)', + direction: 'down', + length: 6, + group: ['5-down'], + position: { + x: 8, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '6-down', + number: 6, + humanNumber: '6', + clue: 'Ex-president one almost wishes would get thrashed in early Republican primaries (10)', + direction: 'down', + length: 10, + group: ['6-down'], + position: { + x: 10, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '7-down', + number: 7, + humanNumber: '7', + clue: 'Tries to take in top of tight suit (6)', + direction: 'down', + length: 6, + group: ['7-down'], + position: { + x: 12, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '8-down', + number: 8, + humanNumber: '8', + clue: 'Sounds like market in which to spend time (4)', + direction: 'down', + length: 4, + group: ['8-down'], + position: { + x: 14, + y: 0, + }, + separatorLocations: {}, + }, + { + id: '13-down', + number: 13, + humanNumber: '13', + clue: 'Spirit associated with a Mediterranean island (5)', + direction: 'down', + length: 5, + group: ['13-down'], + position: { + x: 0, + y: 5, + }, + separatorLocations: {}, + }, + { + id: '14-down', + number: 14, + humanNumber: '14', + clue: 'Dynamic recording has a number of errors to be fixed (10)', + direction: 'down', + length: 10, + group: ['14-down'], + position: { + x: 4, + y: 5, + }, + separatorLocations: {}, + }, + { + id: '16-down', + number: 16, + humanNumber: '16', + clue: 'Sun made water race (5)', + direction: 'down', + length: 5, + group: ['16-down'], + position: { + x: 14, + y: 5, + }, + separatorLocations: {}, + }, + { + id: '19-down', + number: 19, + humanNumber: '19', + clue: 'Kip has ball with Trotsky, military commander (8)', + direction: 'down', + length: 8, + group: ['19-down'], + position: { + x: 8, + y: 7, + }, + separatorLocations: {}, + }, + { + id: '21-down', + number: 21, + humanNumber: '21', + clue: 'Doctor mingles with male unthinking followers (8)', + direction: 'down', + length: 8, + group: ['21-down'], + position: { + x: 12, + y: 7, + }, + separatorLocations: {}, + }, + { + id: '23-down', + number: 23, + humanNumber: '23', + clue: 'Cold drink? Well, that’s pretty bad (6)', + direction: 'down', + length: 6, + group: ['23-down'], + position: { + x: 2, + y: 9, + }, + separatorLocations: {}, + }, + { + id: '24-down', + number: 24, + humanNumber: '24', + clue: 'Finest leasehold houses to be in sheltered position (6)', + direction: 'down', + length: 6, + group: ['24-down'], + position: { + x: 6, + y: 9, + }, + separatorLocations: {}, + }, + { + id: '26-down', + number: 26, + humanNumber: '26', + clue: 'I have an exact match for that photo (4)', + direction: 'down', + length: 4, + group: ['26-down'], + position: { + x: 0, + y: 11, + }, + separatorLocations: {}, + }, + { + id: '28-down', + number: 28, + humanNumber: '28', + clue: 'Silly talk needs to be quiet (4)', + direction: 'down', + length: 4, + group: ['28-down'], + position: { + x: 10, + y: 11, + }, + separatorLocations: {}, + }, + { + id: '29-down', + number: 29, + humanNumber: '29', + clue: 'Pound, the last to be deposited in time (4)', + direction: 'down', + length: 4, + group: ['29-down'], + position: { + x: 14, + y: 11, + }, + separatorLocations: {}, + }, + ], + solutionAvailable: false, + dateSolutionAvailable: 1730505600000, + dimensions: { + cols: 15, + rows: 15, + }, + crosswordType: 'prize', + pdf: 'https://crosswords-static.guim.co.uk/gdn.cryptic.20241026.pdf', +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quick-cryptic.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quick-cryptic.ts new file mode 100644 index 000000000..5bc533c24 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quick-cryptic.ts @@ -0,0 +1,355 @@ +import type { CrosswordData } from '../@types/crossword'; + +export const quickCryptic: CrosswordData = { + id: 'crosswords/quick-cryptic/30', + number: 30, + name: 'Quick cryptic crossword No 30', + creator: { + name: 'Maskarade', + webUrl: 'https://www.theguardian.com/profile/maskarade', + }, + date: 1729900800000, + webPublicationDate: 1729897217000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Multi-talented relatives worked out (9)', + direction: 'across', + length: 9, + group: ['1-across'], + position: { + x: 0, + y: 0, + }, + separatorLocations: {}, + solution: 'VERSATILE', + }, + { + id: '7-across', + number: 7, + humanNumber: '7', + clue: 'Yields to Wimbledon favourites, we’re told (5)', + direction: 'across', + length: 5, + group: ['7-across'], + position: { + x: 0, + y: 2, + }, + separatorLocations: {}, + solution: 'CEDES', + }, + { + id: '8-across', + number: 8, + humanNumber: '8', + clue: 'Awfully eager to acquiesce (5)', + direction: 'across', + length: 5, + group: ['8-across'], + position: { + x: 6, + y: 2, + }, + separatorLocations: {}, + solution: 'AGREE', + }, + { + id: '9-across', + number: 9, + humanNumber: '9', + clue: 'Deafening noise of item of sports equipment heard (6)', + direction: 'across', + length: 6, + group: ['9-across'], + position: { + x: 0, + y: 4, + }, + separatorLocations: {}, + solution: 'RACKET', + }, + { + id: '10-across', + number: 10, + humanNumber: '10', + clue: 'Drop leaves on outhouse (4)', + direction: 'across', + length: 4, + group: ['10-across'], + position: { + x: 7, + y: 4, + }, + separatorLocations: {}, + solution: 'SHED', + }, + { + id: '13-across', + number: 13, + humanNumber: '13', + clue: 'Whitish bucket is suggested (4)', + direction: 'across', + length: 4, + group: ['13-across'], + position: { + x: 0, + y: 6, + }, + separatorLocations: {}, + solution: 'PALE', + }, + { + id: '14-across', + number: 14, + humanNumber: '14', + clue: 'Mum’s got small bed charm (6)', + direction: 'across', + length: 6, + group: ['14-across'], + position: { + x: 5, + y: 6, + }, + separatorLocations: {}, + solution: 'MASCOT', + }, + { + id: '17-across', + number: 17, + humanNumber: '17', + clue: 'Bird with the French name (5)', + direction: 'across', + length: 5, + group: ['17-across'], + position: { + x: 0, + y: 8, + }, + separatorLocations: {}, + solution: 'TITLE', + }, + { + id: '19-across', + number: 19, + humanNumber: '19', + clue: 'Precedes to Yorkshire city, according to reports (5)', + direction: 'across', + length: 5, + group: ['19-across'], + position: { + x: 6, + y: 8, + }, + separatorLocations: {}, + solution: 'LEADS', + }, + { + id: '20-across', + number: 20, + humanNumber: '20', + clue: 'Fatigue attacked dissenter (9)', + direction: 'across', + length: 9, + group: ['20-across'], + position: { + x: 2, + y: 10, + }, + separatorLocations: {}, + solution: 'TIREDNESS', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Parish priest and little girl by vehicle (5)', + direction: 'down', + length: 5, + group: ['1-down'], + position: { + x: 0, + y: 0, + }, + separatorLocations: {}, + solution: 'VICAR', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Extremist’s excellent in slang (7)', + direction: 'down', + length: 7, + group: ['2-down'], + position: { + x: 2, + y: 0, + }, + separatorLocations: {}, + solution: 'RADICAL', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'The reply is “Warne’s out!” (6)', + direction: 'down', + length: 6, + group: ['3-down'], + position: { + x: 4, + y: 0, + }, + separatorLocations: {}, + solution: 'ANSWER', + }, + { + id: '4-down', + number: 4, + humanNumber: '4', + clue: 'One hastened to Persia nowadays (4)', + direction: 'down', + length: 4, + group: ['4-down'], + position: { + x: 6, + y: 0, + }, + separatorLocations: {}, + solution: 'IRAN', + }, + { + id: '5-down', + number: 5, + humanNumber: '5', + clue: 'Attention – the Cockney’s present (3)', + direction: 'down', + length: 3, + group: ['5-down'], + position: { + x: 8, + y: 0, + }, + separatorLocations: {}, + solution: 'EAR', + }, + { + id: '6-down', + number: 6, + humanNumber: '6', + clue: 'It’s said that work with dough is a must-have (4)', + direction: 'down', + length: 4, + group: ['6-down'], + position: { + x: 10, + y: 1, + }, + separatorLocations: {}, + solution: 'NEED', + }, + { + id: '11-down', + number: 11, + humanNumber: '11', + clue: 'It has replaced the acre! (7)', + direction: 'down', + length: 7, + group: ['11-down'], + position: { + x: 8, + y: 4, + }, + separatorLocations: {}, + solution: 'HECTARE', + }, + { + id: '12-down', + number: 12, + humanNumber: '12', + clue: 'Young boy came first and served out the soup (6)', + direction: 'down', + length: 6, + group: ['12-down'], + position: { + x: 6, + y: 5, + }, + separatorLocations: {}, + solution: 'LADLED', + }, + { + id: '13-down', + number: 13, + humanNumber: '13', + clue: 'Darlings step out (4)', + direction: 'down', + length: 4, + group: ['13-down'], + position: { + x: 0, + y: 6, + }, + separatorLocations: {}, + solution: 'PETS', + }, + { + id: '15-down', + number: 15, + humanNumber: '15', + clue: 'Lab work for international games (5)', + direction: 'down', + length: 5, + group: ['15-down'], + position: { + x: 10, + y: 6, + }, + separatorLocations: {}, + solution: 'TESTS', + }, + { + id: '16-down', + number: 16, + humanNumber: '16', + clue: 'Squint at member of the House of Lords (4)', + direction: 'down', + length: 4, + group: ['16-down'], + position: { + x: 4, + y: 7, + }, + separatorLocations: {}, + solution: 'PEER', + }, + { + id: '18-down', + number: 18, + humanNumber: '18', + clue: 'Rubbish work on a shuttle (3)', + direction: 'down', + length: 3, + group: ['18-down'], + position: { + x: 2, + y: 8, + }, + separatorLocations: {}, + solution: 'TAT', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1729897200000, + dimensions: { + cols: 11, + rows: 11, + }, + crosswordType: 'quick-cryptic', + pdf: 'https://crosswords-static.guim.co.uk/gdn.quick-cryptic.20241026.pdf', + instructions: + 'TODAY’S TRICKS Clues begin or end with a definition of the answer. The rest is one of these:\nAnagram\nAn anagram of the answer and a hint that there’s an anagram\n‘Senator arranged crime (7)’ gives TREASON\nCharade\nA combination of synonyms\n‘Qualify to get drink for ID (8)’ gives PASSPORT (pass + port)\nDouble definition\nBoth halves are definitions!\n‘Search scrub (5)’ gives SCOUR\nSoundalike\nSomething that sounds like the answer\n‘Excited as Oscar’s announced (4)’ gives WILD', +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quick.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quick.ts new file mode 100644 index 000000000..246a5d5e2 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quick.ts @@ -0,0 +1,325 @@ +import type { CrosswordData } from '../@types/crossword'; + +export const quick: CrosswordData = { + id: 'crosswords/quick/17001', + number: 17001, + name: 'Quick crossword No 17,001', + date: 1730332800000, + webPublicationDate: 1730332803000, + entries: [ + { + id: '5-across', + number: 5, + humanNumber: '5', + clue: 'Bane (4,5)', + direction: 'across', + length: 9, + group: ['5-across'], + position: { + x: 2, + y: 1, + }, + separatorLocations: { + ',': [4], + }, + solution: 'BETENOIRE', + }, + { + id: '8-across', + number: 8, + humanNumber: '8', + clue: 'Fish-hook’s business end (4)', + direction: 'across', + length: 4, + group: ['8-across'], + position: { + x: 0, + y: 3, + }, + separatorLocations: {}, + solution: 'BARB', + }, + { + id: '9-across', + number: 9, + humanNumber: '9', + clue: 'Line created by rising and falling water (8)', + direction: 'across', + length: 8, + group: ['9-across'], + position: { + x: 5, + y: 3, + }, + separatorLocations: {}, + solution: 'TIDEMARK', + }, + { + id: '10-across', + number: 10, + humanNumber: '10', + clue: 'Bold (6)', + direction: 'across', + length: 6, + group: ['10-across'], + position: { + x: 0, + y: 5, + }, + separatorLocations: {}, + solution: 'CHEEKY', + }, + { + id: '11-across', + number: 11, + humanNumber: '11', + clue: 'Contraption (6)', + direction: 'across', + length: 6, + group: ['11-across'], + position: { + x: 7, + y: 5, + }, + separatorLocations: {}, + solution: 'GADGET', + }, + { + id: '13-across', + number: 13, + humanNumber: '13', + clue: 'Go quickly (up?) (6)', + direction: 'across', + length: 6, + group: ['13-across'], + position: { + x: 0, + y: 7, + }, + separatorLocations: {}, + solution: 'ROCKET', + }, + { + id: '15-across', + number: 15, + humanNumber: '15', + clue: 'Gusto (6)', + direction: 'across', + length: 6, + group: ['15-across'], + position: { + x: 7, + y: 7, + }, + separatorLocations: {}, + solution: 'VIGOUR', + }, + { + id: '16-across', + number: 16, + humanNumber: '16', + clue: '1,000,000,000,000 (8)', + direction: 'across', + length: 8, + group: ['16-across'], + position: { + x: 0, + y: 9, + }, + separatorLocations: {}, + solution: 'TRILLION', + }, + { + id: '18-across', + number: 18, + humanNumber: '18', + clue: 'Swain (4)', + direction: 'across', + length: 4, + group: ['18-across'], + position: { + x: 9, + y: 9, + }, + separatorLocations: {}, + solution: 'BEAU', + }, + { + id: '19-across', + number: 19, + humanNumber: '19', + clue: 'Procession of vehicles carrying politicians, e.g. (9)', + direction: 'across', + length: 9, + group: ['19-across'], + position: { + x: 2, + y: 11, + }, + separatorLocations: {}, + solution: 'MOTORCADE', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Time for a quick cuppa (3,5)', + direction: 'down', + length: 8, + group: ['1-down'], + position: { + x: 3, + y: 0, + }, + separatorLocations: { + ',': [3], + }, + solution: 'TEABREAK', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Room for clergy’s robes (6)', + direction: 'down', + length: 6, + group: ['2-down'], + position: { + x: 5, + y: 0, + }, + separatorLocations: {}, + solution: 'VESTRY', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'Meaty snack sometimes served with mustard (3,3)', + direction: 'down', + length: 6, + group: ['3-down'], + position: { + x: 7, + y: 0, + }, + separatorLocations: { + ',': [3], + }, + solution: 'HOTDOG', + }, + { + id: '4-down', + number: 4, + humanNumber: '4', + clue: 'Fill (4)', + direction: 'down', + length: 4, + group: ['4-down'], + position: { + x: 9, + y: 0, + }, + separatorLocations: {}, + solution: 'CRAM', + }, + { + id: '6-down', + number: 6, + humanNumber: '6', + clue: 'Where speedo found (9)', + direction: 'down', + length: 9, + group: ['6-down'], + position: { + x: 1, + y: 2, + }, + separatorLocations: {}, + solution: 'DASHBOARD', + }, + { + id: '7-down', + number: 7, + humanNumber: '7', + clue: 'Fluctuating (9)', + direction: 'down', + length: 9, + group: ['7-down'], + position: { + x: 11, + y: 2, + }, + separatorLocations: {}, + solution: 'IRREGULAR', + }, + { + id: '12-down', + number: 12, + humanNumber: '12', + clue: 'Drudge (8)', + direction: 'down', + length: 8, + group: ['12-down'], + position: { + x: 9, + y: 5, + }, + separatorLocations: {}, + solution: 'DOGSBODY', + }, + { + id: '14-down', + number: 14, + humanNumber: '14', + clue: 'Camera holder (6)', + direction: 'down', + length: 6, + group: ['14-down'], + position: { + x: 5, + y: 7, + }, + separatorLocations: {}, + solution: 'TRIPOD', + }, + { + id: '15-down', + number: 15, + humanNumber: '15', + clue: 'Italian city with cultural festival La Biennale (6)', + direction: 'down', + length: 6, + group: ['15-down'], + position: { + x: 7, + y: 7, + }, + separatorLocations: {}, + solution: 'VENICE', + }, + { + id: '17-down', + number: 17, + humanNumber: '17', + clue: 'Big cat (4)', + direction: 'down', + length: 4, + group: ['17-down'], + position: { + x: 3, + y: 9, + }, + separatorLocations: {}, + solution: 'LION', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1730332800000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'quick', + pdf: 'https://crosswords-static.guim.co.uk/gdn.quick.20241031.pdf', +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quiptic.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quiptic.ts new file mode 100644 index 000000000..93a7d192a --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/quiptic.ts @@ -0,0 +1,422 @@ +import type { CrosswordData } from '../@types/crossword'; + +export const quiptic: CrosswordData = { + id: 'crosswords/quiptic/1301', + number: 1301, + name: 'Quiptic crossword No 1,301', + creator: { + name: 'Picaroon', + webUrl: 'https://www.theguardian.com/profile/picaroon', + }, + date: 1729987200000, + webPublicationDate: 1729983645000, + entries: [ + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'American friend had to convert founder of a religion (6)', + direction: 'across', + length: 6, + group: ['4-across'], + position: { + x: 0, + y: 1, + }, + separatorLocations: {}, + solution: 'BUDDHA', + }, + { + id: '6-across', + number: 6, + humanNumber: '6', + clue: 'How steak may be cooked that’s good for you! (4,4)', + direction: 'across', + length: 8, + group: ['6-across'], + position: { + x: 7, + y: 1, + }, + separatorLocations: { + ',': [4], + }, + solution: 'WELLDONE', + }, + { + id: '9-across', + number: 9, + humanNumber: '9', + clue: 'Ultimately endure conclusions of Royal Family (6)', + direction: 'across', + length: 6, + group: ['9-across'], + position: { + x: 0, + y: 3, + }, + separatorLocations: {}, + solution: 'LASTLY', + }, + { + id: '10-across', + number: 10, + humanNumber: '10', + clue: 'Faulty hearing – Mr Ali’s troubled with it (8)', + direction: 'across', + length: 8, + group: ['10-across'], + position: { + x: 7, + y: 3, + }, + separatorLocations: {}, + solution: 'MISTRIAL', + }, + { + id: '11-across', + number: 11, + humanNumber: '11', + clue: 'Liam and Noel playing with toy in a way that arouses feelings (11)', + direction: 'across', + length: 11, + group: ['11-across'], + position: { + x: 0, + y: 5, + }, + separatorLocations: {}, + solution: 'EMOTIONALLY', + }, + { + id: '15-across', + number: 15, + humanNumber: '15', + clue: 'In text that’s rewritten, entertaining chapter vanished (7)', + direction: 'across', + length: 7, + group: ['15-across'], + position: { + x: 0, + y: 7, + }, + separatorLocations: {}, + solution: 'EXTINCT', + }, + { + id: '17-across', + number: 17, + humanNumber: '17', + clue: 'Perhaps May in that place by South Africa’s capital (7)', + direction: 'across', + length: 7, + group: ['17-across'], + position: { + x: 8, + y: 7, + }, + separatorLocations: {}, + solution: 'THERESA', + }, + { + id: '18-across', + number: 18, + humanNumber: '18', + clue: 'Stage make-up isn’t put on musical lead in Phantom (6,5)', + direction: 'across', + length: 11, + group: ['18-across'], + position: { + x: 4, + y: 9, + }, + separatorLocations: { + ',': [6], + }, + solution: 'GREASEPAINT', + }, + { + id: '22-across', + number: 22, + humanNumber: '22', + clue: 'MP has not drunk spirits (8)', + direction: 'across', + length: 8, + group: ['22-across'], + position: { + x: 0, + y: 11, + }, + separatorLocations: {}, + solution: 'PHANTOMS', + }, + { + id: '23-across', + number: 23, + humanNumber: '23', + clue: 'Laud unclothed couple’s childminder (2,4)', + direction: 'across', + length: 6, + group: ['23-across'], + position: { + x: 9, + y: 11, + }, + separatorLocations: { + ',': [2], + }, + solution: 'AUPAIR', + }, + { + id: '24-across', + number: 24, + humanNumber: '24', + clue: 'Leo stole crackers, getting release from captivity (3,5)', + direction: 'across', + length: 8, + group: ['24-across'], + position: { + x: 0, + y: 13, + }, + separatorLocations: { + ',': [3], + }, + solution: 'LETLOOSE', + }, + { + id: '25-across', + number: 25, + humanNumber: '25', + clue: 'Saw blade’s edges grasped (6)', + direction: 'across', + length: 6, + group: ['25-across'], + position: { + x: 9, + y: 13, + }, + separatorLocations: {}, + solution: 'BEHELD', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Condiment that’s hot in cold starter from Indian (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { + x: 4, + y: 0, + }, + separatorLocations: {}, + solution: 'CHILLI', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Member one criticises makes laws (10)', + direction: 'down', + length: 10, + group: ['2-down'], + position: { + x: 8, + y: 0, + }, + separatorLocations: {}, + solution: 'LEGISLATES', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'Problem for driver in boring old Phoenician city (4,4)', + direction: 'down', + length: 8, + group: ['3-down'], + position: { + x: 10, + y: 0, + }, + separatorLocations: { + ',': [4], + }, + solution: 'FLATTYRE', + }, + { + id: '4-down', + number: 4, + humanNumber: '4', + clue: 'Bishop always conceals untruth for person with faith (8)', + direction: 'down', + length: 8, + group: ['4-down'], + position: { + x: 0, + y: 1, + }, + separatorLocations: {}, + solution: 'BELIEVER', + }, + { + id: '5-down', + number: 5, + humanNumber: '5', + clue: 'Perverts or some antibourgeois Trots idling around (8)', + direction: 'down', + length: 8, + group: ['5-down'], + position: { + x: 2, + y: 1, + }, + separatorLocations: {}, + solution: 'DISTORTS', + }, + { + id: '7-down', + number: 7, + humanNumber: '7', + clue: 'Exclude from old American university (4)', + direction: 'down', + length: 4, + group: ['7-down'], + position: { + x: 12, + y: 1, + }, + separatorLocations: {}, + solution: 'OMIT', + }, + { + id: '8-down', + number: 8, + humanNumber: '8', + clue: 'Snake-like fish in small shelter, turning around (4)', + direction: 'down', + length: 4, + group: ['8-down'], + position: { + x: 14, + y: 1, + }, + separatorLocations: {}, + solution: 'EELS', + }, + { + id: '12-down', + number: 12, + humanNumber: '12', + clue: 'Contents of fine thermos that’s nearest the bottom (10)', + direction: 'down', + length: 10, + group: ['12-down'], + position: { + x: 6, + y: 5, + }, + separatorLocations: {}, + solution: 'NETHERMOST', + }, + { + id: '13-down', + number: 13, + humanNumber: '13', + clue: 'Ms Blanchett admitting bitterness about having no partner (8)', + direction: 'down', + length: 8, + group: ['13-down'], + position: { + x: 12, + y: 6, + }, + separatorLocations: {}, + solution: 'CELIBATE', + }, + { + id: '14-down', + number: 14, + humanNumber: '14', + clue: 'Beaten cricketer with England gutted (8)', + direction: 'down', + length: 8, + group: ['14-down'], + position: { + x: 14, + y: 6, + }, + separatorLocations: {}, + solution: 'BATTERED', + }, + { + id: '16-down', + number: 16, + humanNumber: '16', + clue: 'Denial, for example, when entering country (8)', + direction: 'down', + length: 8, + group: ['16-down'], + position: { + x: 4, + y: 7, + }, + separatorLocations: {}, + solution: 'NEGATION', + }, + { + id: '19-down', + number: 19, + humanNumber: '19', + clue: 'Someone who cuts dried fruit, right? (6)', + direction: 'down', + length: 6, + group: ['19-down'], + position: { + x: 10, + y: 9, + }, + separatorLocations: {}, + solution: 'PRUNER', + }, + { + id: '20-down', + number: 20, + humanNumber: '20', + clue: 'Precious stone in ring put on friend (4)', + direction: 'down', + length: 4, + group: ['20-down'], + position: { + x: 0, + y: 10, + }, + separatorLocations: {}, + solution: 'OPAL', + }, + { + id: '21-down', + number: 21, + humanNumber: '21', + clue: 'Don’t eat the kind of food served at McDonald’s (4)', + direction: 'down', + length: 4, + group: ['21-down'], + position: { + x: 2, + y: 10, + }, + separatorLocations: {}, + solution: 'FAST', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1729983600000, + dimensions: { + cols: 15, + rows: 15, + }, + crosswordType: 'quiptic', +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/special.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/special.ts new file mode 100644 index 000000000..7909f3168 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/special.ts @@ -0,0 +1,450 @@ +import type { CrosswordData } from '../@types/crossword'; + +export const special: CrosswordData = { + id: 'crosswords/special/1', + number: 1, + name: 'Special crossword No 1', + creator: { + name: 'Sphinx', + webUrl: 'https://www.theguardian.com/profile/sphinx', + }, + date: 1729296000000, + webPublicationDate: 1729292402000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'After substitution, go use ball on one Hungarian player (4,6)', + direction: 'across', + length: 10, + group: ['1-across'], + position: { + x: 0, + y: 0, + }, + separatorLocations: { + ',': [4], + }, + solution: 'BELALUGOSI', + }, + { + id: '6-across', + number: 6, + humanNumber: '6', + clue: 'No oriental regulations about libel (4)', + direction: 'across', + length: 4, + group: ['6-across'], + position: { + x: 11, + y: 0, + }, + separatorLocations: {}, + solution: 'SLUR', + }, + { + id: '9-across', + number: 9, + humanNumber: '9', + clue: 'Colour plasmas for the aristocracy! (10)', + direction: 'across', + length: 10, + group: ['9-across'], + position: { + x: 0, + y: 2, + }, + separatorLocations: {}, + solution: 'BLUEBLOODS', + }, + { + id: '10-across', + number: 10, + humanNumber: '10', + clue: 'Catastrophe is the return of humour (4)', + direction: 'across', + length: 4, + group: ['10-across'], + position: { + x: 11, + y: 2, + }, + separatorLocations: {}, + solution: 'DOOM', + }, + { + id: '12-across', + number: 12, + humanNumber: '12', + clue: 'Unscrupulous doctor deployed tanner’s knife (12)', + direction: 'across', + length: 12, + group: ['12-across'], + position: { + x: 3, + y: 4, + }, + separatorLocations: {}, + solution: 'FRANKENSTEIN', + }, + { + id: '15-across', + number: 15, + humanNumber: '15', + clue: 'Exposes footloose trainee in Norfolk town (9)', + direction: 'across', + length: 9, + group: ['15-across'], + position: { + x: 0, + y: 6, + }, + separatorLocations: {}, + solution: 'DISINTERS', + }, + { + id: '17-across', + number: 17, + humanNumber: '17', + clue: 'Ratty’s ship follows crow non-stop (5)', + direction: 'across', + length: 5, + group: ['17-across'], + position: { + x: 10, + y: 6, + }, + separatorLocations: {}, + solution: 'CROSS', + }, + { + id: '18-across', + number: 18, + humanNumber: '18', + clue: 'Sponge rear of foot round about first sign of carbuncles (5)', + direction: 'across', + length: 5, + group: ['18-across'], + position: { + x: 0, + y: 8, + }, + separatorLocations: {}, + solution: 'LEECH', + }, + { + id: '19-across', + number: 19, + humanNumber: '19', + clue: 'Criticise actors before mid-point entrance (9)', + direction: 'across', + length: 9, + group: ['19-across'], + position: { + x: 6, + y: 8, + }, + separatorLocations: {}, + solution: 'CASTIGATE', + }, + { + id: '20-across', + number: 20, + humanNumber: '20', + clue: 'Drain life force from nauseating ex-criminal (12)', + direction: 'across', + length: 12, + group: ['20-across'], + position: { + x: 0, + y: 10, + }, + separatorLocations: {}, + solution: 'EXSANGUINATE', + }, + { + id: '24-across', + number: 24, + humanNumber: '24', + clue: 'Mountain dweller to be found in 9 (4)', + direction: 'across', + length: 4, + group: ['24-across'], + position: { + x: 0, + y: 12, + }, + separatorLocations: {}, + solution: 'IBEX', + }, + { + id: '25-across', + number: 25, + humanNumber: '25', + clue: 'Four competing in political party before sharing the spoils (8,2)', + direction: 'across', + length: 10, + group: ['25-across'], + position: { + x: 5, + y: 12, + }, + separatorLocations: { + ',': [8], + }, + solution: 'DIVVYINGUP', + }, + { + id: '26-across', + number: 26, + humanNumber: '26', + clue: 'Eats bananas – gorge! (4)', + direction: 'across', + length: 4, + group: ['26-across'], + position: { + x: 0, + y: 14, + }, + separatorLocations: {}, + solution: 'SATE', + }, + { + id: '27-across', + number: 27, + humanNumber: '27', + clue: '‘Early closing’ relative pronounces, ‘makes more room’ (10)', + direction: 'across', + length: 10, + group: ['27-across'], + position: { + x: 5, + y: 14, + }, + separatorLocations: {}, + solution: 'UNCLUTTERS', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Wee one has thumb tip in mouth (4)', + direction: 'down', + length: 4, + group: ['1-down'], + position: { + x: 0, + y: 0, + }, + separatorLocations: {}, + solution: 'BABY', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Powerful backing-duo opening for Lulu (4)', + direction: 'down', + length: 4, + group: ['2-down'], + position: { + x: 2, + y: 0, + }, + separatorLocations: {}, + solution: 'LOUD', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'Twisting ends of tidal flora, in the briny, all at sea (12)', + direction: 'down', + length: 12, + group: ['3-down'], + position: { + x: 4, + y: 0, + }, + separatorLocations: {}, + solution: 'LABYRINTHINE', + }, + { + id: '4-down', + number: 4, + humanNumber: '4', + clue: 'Matriarch imbibes fourth of Famous Grouse (5)', + direction: 'down', + length: 5, + group: ['4-down'], + position: { + x: 6, + y: 0, + }, + separatorLocations: {}, + solution: 'GROAN', + }, + { + id: '5-down', + number: 5, + humanNumber: '5', + clue: 'The German recalled small city on the Ruhr reversing sorrows (9)', + direction: 'down', + length: 9, + group: ['5-down'], + position: { + x: 8, + y: 0, + }, + separatorLocations: {}, + solution: 'SADNESSES', + }, + { + id: '7-down', + number: 7, + humanNumber: '7', + clue: 'She’s easy, ladies, to attach to old chap! (5,5)', + direction: 'down', + length: 10, + group: ['7-down'], + position: { + x: 12, + y: 0, + }, + separatorLocations: { + ',': [5], + }, + solution: 'LOOSEWOMAN', + }, + { + id: '8-down', + number: 8, + humanNumber: '8', + clue: 'Reviewed vehicles with lead chassis in plant (10)', + direction: 'down', + length: 10, + group: ['8-down'], + position: { + x: 14, + y: 0, + }, + separatorLocations: {}, + solution: 'REMINISCED', + }, + { + id: '11-down', + number: 11, + humanNumber: '11', + clue: 'Does doctor pry at his tics? (12)', + direction: 'down', + length: 12, + group: ['11-down'], + position: { + x: 10, + y: 3, + }, + separatorLocations: {}, + solution: 'PSYCHIATRIST', + }, + { + id: '13-down', + number: 13, + humanNumber: '13', + clue: 'Men and women gather round lake for infidelities (10)', + direction: 'down', + length: 10, + group: ['13-down'], + position: { + x: 0, + y: 5, + }, + separatorLocations: {}, + solution: 'ADULTERIES', + }, + { + id: '14-down', + number: 14, + humanNumber: '14', + clue: 'Fools Saturday workers, on time for appraisal (10)', + direction: 'down', + length: 10, + group: ['14-down'], + position: { + x: 2, + y: 5, + }, + separatorLocations: {}, + solution: 'ASSESSMENT', + }, + { + id: '16-down', + number: 16, + humanNumber: '16', + clue: 'One is devastated about pointless clues in team elimination (9)', + direction: 'down', + length: 9, + group: ['16-down'], + position: { + x: 6, + y: 6, + }, + separatorLocations: {}, + solution: 'EXCLUSION', + }, + { + id: '21-down', + number: 21, + humanNumber: '21', + clue: 'New Strangelove story? (5)', + direction: 'down', + length: 5, + group: ['21-down'], + position: { + x: 8, + y: 10, + }, + separatorLocations: {}, + solution: 'NOVEL', + }, + { + id: '22-down', + number: 22, + humanNumber: '22', + clue: 'Every other conger eel is a monster (4)', + direction: 'down', + length: 4, + group: ['22-down'], + position: { + x: 12, + y: 11, + }, + separatorLocations: {}, + solution: 'OGRE', + }, + { + id: '23-down', + number: 23, + humanNumber: '23', + clue: 'Musical work sounds like catcalling! (4)', + direction: 'down', + length: 4, + group: ['23-down'], + position: { + x: 14, + y: 11, + }, + separatorLocations: {}, + solution: 'OPUS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1729292400000, + dimensions: { + cols: 15, + rows: 15, + }, + crosswordType: 'special', + instructions: + 'This is the puzzle seen onscreen in the BBC’s 2020 production of Dracula, compiled by Steve Pemberton.', +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/speedy.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/speedy.ts new file mode 100644 index 000000000..e2ab7c82e --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/speedy.ts @@ -0,0 +1,383 @@ +import type { CrosswordData } from '../@types/crossword'; + +export const speedy: CrosswordData = { + id: 'crosswords/speedy/1516', + number: 1516, + name: 'Speedy crossword No 1,516', + date: 1729987200000, + webPublicationDate: 1729983644000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Tangible (8)', + direction: 'across', + length: 8, + group: ['1-across'], + position: { + x: 0, + y: 0, + }, + separatorLocations: {}, + solution: 'PALPABLE', + }, + { + id: '5-across', + number: 5, + humanNumber: '5', + clue: 'Shortage (4)', + direction: 'across', + length: 4, + group: ['5-across'], + position: { + x: 9, + y: 0, + }, + separatorLocations: {}, + solution: 'LACK', + }, + { + id: '9-across', + number: 9, + humanNumber: '9', + clue: 'Stand-offish (5)', + direction: 'across', + length: 5, + group: ['9-across'], + position: { + x: 0, + y: 2, + }, + separatorLocations: {}, + solution: 'ALOOF', + }, + { + id: '10-across', + number: 10, + humanNumber: '10', + clue: 'Precarious (7)', + direction: 'across', + length: 7, + group: ['10-across'], + position: { + x: 6, + y: 2, + }, + separatorLocations: {}, + solution: 'PARLOUS', + }, + { + id: '11-across', + number: 11, + humanNumber: '11', + clue: 'Causing cancer (12)', + direction: 'across', + length: 12, + group: ['11-across'], + position: { + x: 1, + y: 4, + }, + separatorLocations: {}, + solution: 'CARCINOGENIC', + }, + { + id: '13-across', + number: 13, + humanNumber: '13', + clue: 'Confer holy orders (6)', + direction: 'across', + length: 6, + group: ['13-across'], + position: { + x: 0, + y: 6, + }, + separatorLocations: {}, + solution: 'ORDAIN', + }, + { + id: '14-across', + number: 14, + humanNumber: '14', + clue: 'Wild ass (6)', + direction: 'across', + length: 6, + group: ['14-across'], + position: { + x: 7, + y: 6, + }, + separatorLocations: {}, + solution: 'ONAGER', + }, + { + id: '17-across', + number: 17, + humanNumber: '17', + clue: 'Place in brackets (12)', + direction: 'across', + length: 12, + group: ['17-across'], + position: { + x: 0, + y: 8, + }, + separatorLocations: {}, + solution: 'PARENTHESIZE', + }, + { + id: '20-across', + number: 20, + humanNumber: '20', + clue: 'Driving force (7)', + direction: 'across', + length: 7, + group: ['20-across'], + position: { + x: 0, + y: 10, + }, + separatorLocations: {}, + solution: 'IMPETUS', + }, + { + id: '21-across', + number: 21, + humanNumber: '21', + clue: 'Legally bar or preclude (5)', + direction: 'across', + length: 5, + group: ['21-across'], + position: { + x: 8, + y: 10, + }, + separatorLocations: {}, + solution: 'ESTOP', + }, + { + id: '22-across', + number: 22, + humanNumber: '22', + clue: 'Small whirlpool (4)', + direction: 'across', + length: 4, + group: ['22-across'], + position: { + x: 0, + y: 12, + }, + separatorLocations: {}, + solution: 'EDDY', + }, + { + id: '23-across', + number: 23, + humanNumber: '23', + clue: 'Insincere praise (8)', + direction: 'across', + length: 8, + group: ['23-across'], + position: { + x: 5, + y: 12, + }, + separatorLocations: {}, + solution: 'FLATTERY', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Summit (4)', + direction: 'down', + length: 4, + group: ['1-down'], + position: { + x: 0, + y: 0, + }, + separatorLocations: {}, + solution: 'PEAK', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Garment for working out (7)', + direction: 'down', + length: 7, + group: ['2-down'], + position: { + x: 2, + y: 0, + }, + separatorLocations: {}, + solution: 'LEOTARD', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'Loving (12)', + direction: 'down', + length: 12, + group: ['3-down'], + position: { + x: 4, + y: 0, + }, + separatorLocations: {}, + solution: 'AFFECTIONATE', + }, + { + id: '4-down', + number: 4, + humanNumber: '4', + clue: 'Relating to a wolf (6)', + direction: 'down', + length: 6, + group: ['4-down'], + position: { + x: 6, + y: 0, + }, + separatorLocations: {}, + solution: 'LUPINE', + }, + { + id: '6-down', + number: 6, + humanNumber: '6', + clue: 'Fruit of the oak (5)', + direction: 'down', + length: 5, + group: ['6-down'], + position: { + x: 10, + y: 0, + }, + separatorLocations: {}, + solution: 'ACORN', + }, + { + id: '7-down', + number: 7, + humanNumber: '7', + clue: 'Lock of hair curving onto the face (4,4)', + direction: 'down', + length: 8, + group: ['7-down'], + position: { + x: 12, + y: 0, + }, + separatorLocations: { + ',': [4], + }, + solution: 'KISSCURL', + }, + { + id: '8-down', + number: 8, + humanNumber: '8', + clue: 'Be deliberately slow to act (4,4,4)', + direction: 'down', + length: 12, + group: ['8-down'], + position: { + x: 8, + y: 1, + }, + separatorLocations: { + ',': [4, 8], + }, + solution: 'DRAGONESFEET', + }, + { + id: '12-down', + number: 12, + humanNumber: '12', + clue: 'Medical drug derived from opium (8)', + direction: 'down', + length: 8, + group: ['12-down'], + position: { + x: 0, + y: 5, + }, + separatorLocations: {}, + solution: 'MORPHINE', + }, + { + id: '15-down', + number: 15, + humanNumber: '15', + clue: 'Journal, newspaper (7)', + direction: 'down', + length: 7, + group: ['15-down'], + position: { + x: 10, + y: 6, + }, + separatorLocations: {}, + solution: 'GAZETTE', + }, + { + id: '16-down', + number: 16, + humanNumber: '16', + clue: 'Cheat or swindle (6)', + direction: 'down', + length: 6, + group: ['16-down'], + position: { + x: 6, + y: 7, + }, + separatorLocations: {}, + solution: 'CHISEL', + }, + { + id: '18-down', + number: 18, + humanNumber: '18', + clue: 'Speedy (5)', + direction: 'down', + length: 5, + group: ['18-down'], + position: { + x: 2, + y: 8, + }, + separatorLocations: {}, + solution: 'RAPID', + }, + { + id: '19-down', + number: 19, + humanNumber: '19', + clue: 'Sterilize a female animal (4)', + direction: 'down', + length: 4, + group: ['19-down'], + position: { + x: 12, + y: 9, + }, + separatorLocations: {}, + solution: 'SPAY', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1729983600000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'speedy', + pdf: 'https://crosswords-static.guim.co.uk/obs.speedy.20241027.pdf', +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/weekend.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/weekend.ts new file mode 100644 index 000000000..531b741f3 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/stories/weekend.ts @@ -0,0 +1,405 @@ +import type { CrosswordData } from '../@types/crossword'; + +export const weekend: CrosswordData = { + id: 'crosswords/weekend/720', + number: 720, + name: 'Weekend crossword No 720', + creator: { + name: 'Sy', + webUrl: 'https://www.theguardian.com/profile/sy', + }, + date: 1729900800000, + webPublicationDate: 1729897217000, + entries: [ + { + id: '7-across', + number: 7, + humanNumber: '7', + clue: 'Tree cultivated especially in China and Japan for its yellow-orange fruit (6)', + direction: 'across', + length: 6, + group: ['7-across'], + position: { + x: 0, + y: 1, + }, + separatorLocations: {}, + solution: 'LOQUAT', + }, + { + id: '8-across', + number: 8, + humanNumber: '8', + clue: 'The Lebanese capital (6)', + direction: 'across', + length: 6, + group: ['8-across'], + position: { + x: 7, + y: 1, + }, + separatorLocations: {}, + solution: 'BEIRUT', + }, + { + id: '9-across', + number: 9, + humanNumber: '9', + clue: 'Yiddish-derived word for the kind of audacity shown by 2/3? (8)', + direction: 'across', + length: 8, + group: ['9-across'], + position: { + x: 0, + y: 3, + }, + separatorLocations: {}, + solution: 'CHUTZPAH', + }, + { + id: '10-across', + number: 10, + humanNumber: '10', + clue: 'The employer of, amongst others, Harry Bosch and Kate Lockley? (1,1,1,1)', + direction: 'across', + length: 4, + group: ['10-across'], + position: { + x: 9, + y: 3, + }, + separatorLocations: { + ',': [1, 2, 3], + }, + solution: 'LAPD', + }, + { + id: '11-across', + number: 11, + humanNumber: '11', + clue: 'The __; Constable painting used by 2/3 activists in a protest action (3,4)', + direction: 'across', + length: 7, + group: ['11-across'], + position: { + x: 0, + y: 5, + }, + separatorLocations: { + ',': [3], + }, + solution: 'HAYWAIN', + }, + { + id: '13-across', + number: 13, + humanNumber: '13', + clue: 'Astrological sign represented by the scales of justice (5)', + direction: 'across', + length: 5, + group: ['13-across'], + position: { + x: 8, + y: 5, + }, + separatorLocations: {}, + solution: 'LIBRA', + }, + { + id: '15-across', + number: 15, + humanNumber: '15', + clue: 'Ethnic group from the Niger delta (5)', + direction: 'across', + length: 5, + group: ['15-across'], + position: { + x: 0, + y: 7, + }, + separatorLocations: {}, + solution: 'OGONI', + }, + { + id: '17-across', + number: 17, + humanNumber: '17', + clue: 'City on the Tigris (7)', + direction: 'across', + length: 7, + group: ['17-across'], + position: { + x: 6, + y: 7, + }, + separatorLocations: {}, + solution: 'BAGHDAD', + }, + { + id: '20-across', + number: 20, + humanNumber: '20, 6', + clue: 'The __, Leonardo painting used by 2/3 activists in a protest action (4,6)', + direction: 'across', + length: 4, + group: ['20-across', '6-down'], + position: { + x: 0, + y: 9, + }, + separatorLocations: { + ',': [4], + }, + solution: 'LAST', + }, + { + id: '21-across', + number: 21, + humanNumber: '21', + clue: 'A colourful parrot (8)', + direction: 'across', + length: 8, + group: ['21-across'], + position: { + x: 5, + y: 9, + }, + separatorLocations: {}, + solution: 'LORIKEET', + }, + { + id: '23-across', + number: 23, + humanNumber: '23', + clue: 'Polymath known for his 1959 lecture, The Two Cultures (1,1,4)', + direction: 'across', + length: 6, + group: ['23-across'], + position: { + x: 0, + y: 11, + }, + separatorLocations: { + ',': [1, 2], + }, + solution: 'CPSNOW', + }, + { + id: '24-across', + number: 24, + humanNumber: '24', + clue: 'A town in Nottinghamshire but a city in New Jersey? (6)', + direction: 'across', + length: 6, + group: ['24-across'], + position: { + x: 7, + y: 11, + }, + separatorLocations: {}, + solution: 'NEWARK', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'The sixth book of the Old Testament (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { + x: 1, + y: 0, + }, + separatorLocations: {}, + solution: 'JOSHUA', + }, + { + id: '2-down', + number: 2, + humanNumber: '2, 3', + clue: 'Campaign group seeking to end fossil fuel extraction by 2030 (4,4,3)', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 3, + y: 0, + }, + separatorLocations: { + ',': [4], + }, + solution: 'JUST', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 7, + group: ['2-down', '3-down'], + position: { + x: 5, + y: 0, + }, + separatorLocations: { + ',': [4], + }, + solution: 'STOPOIL', + }, + { + id: '4-down', + number: 4, + humanNumber: '4', + clue: 'The Berlin subway system (1-4)', + direction: 'down', + length: 5, + group: ['4-down'], + position: { + x: 7, + y: 0, + }, + separatorLocations: { + '-': [1], + }, + solution: 'UBAHN', + }, + { + id: '5-down', + number: 5, + humanNumber: '5, 18, 14', + clue: 'Vermeer painting used by 2/3 activists in a protest action (4,4,1,5,7)', + direction: 'down', + length: 8, + group: ['5-down', '18-down', '14-down'], + position: { + x: 9, + y: 0, + }, + separatorLocations: { + ',': [4, 8], + }, + solution: 'GIRLWITH', + }, + { + id: '6-down', + number: 6, + humanNumber: '6', + clue: 'See 20 Across', + direction: 'down', + length: 6, + group: ['20-across', '6-down'], + position: { + x: 11, + y: 0, + }, + separatorLocations: { + ',': [], + }, + solution: 'SUPPER', + }, + { + id: '12-down', + number: 12, + humanNumber: '12', + clue: 'Ray __, actor whose films include Sexy Beast and Black Widow (8)', + direction: 'down', + length: 8, + group: ['12-down'], + position: { + x: 3, + y: 5, + }, + separatorLocations: {}, + solution: 'WINSTONE', + }, + { + id: '14-down', + number: 14, + humanNumber: '14', + clue: 'See 5', + direction: 'down', + length: 7, + group: ['5-down', '18-down', '14-down'], + position: { + x: 7, + y: 6, + }, + separatorLocations: { + ',': [], + }, + solution: 'EARRING', + }, + { + id: '16-down', + number: 16, + humanNumber: '16', + clue: 'Pomace brandy from Italy (6)', + direction: 'down', + length: 6, + group: ['16-down'], + position: { + x: 1, + y: 7, + }, + separatorLocations: {}, + solution: 'GRAPPA', + }, + { + id: '18-down', + number: 18, + humanNumber: '18', + clue: 'See 5', + direction: 'down', + length: 6, + group: ['5-down', '18-down', '14-down'], + position: { + x: 11, + y: 7, + }, + separatorLocations: { + ',': [1, 6], + }, + solution: 'APEARL', + }, + { + id: '19-down', + number: 19, + humanNumber: '19', + clue: 'The profession of Krusty, Bozo and Joseph Grimaldi? (5)', + direction: 'down', + length: 5, + group: ['19-down'], + position: { + x: 5, + y: 8, + }, + separatorLocations: {}, + solution: 'CLOWN', + }, + { + id: '22-down', + number: 22, + humanNumber: '22', + clue: 'Fruit – or person – from New Zealand (4)', + direction: 'down', + length: 4, + group: ['22-down'], + position: { + x: 9, + y: 9, + }, + separatorLocations: {}, + solution: 'KIWI', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1729897200000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'weekend', + pdf: 'https://crosswords-static.guim.co.uk/gdn.weekend.20241026.pdf', +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/AnagramHelper.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/AnagramHelper.tsx new file mode 100644 index 000000000..1f74adfa7 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/AnagramHelper.tsx @@ -0,0 +1,252 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; +import { Button } from '../index'; +import { Cell, Clue, SeparatorLocations } from '../../interfaces'; +import ClueDisplay from './ClueDisplay'; +import SolutionDisplay from './SolutionDisplay'; +import WordWheel from './WordWheel'; + +interface CloseIconProps { + className?: string; +} + +function CloseIcon({ className }: CloseIconProps) { + return ( + + + + + + ); +} + +interface AnagramHelperProps { + clue: Clue; + groupCells: Cell[]; + groupSeparators: SeparatorLocations; + onClose: () => void; + style?: React.CSSProperties; +} + +export default function AnagramHelper({ + clue, + groupCells, + groupSeparators, + onClose, + style, +}: AnagramHelperProps) { + const inputRef = React.useRef(null); + const buttonRef = React.useRef(null); + const [letters, setLetters] = React.useState(''); + const [shuffling, setShuffling] = React.useState(false); + const enableButtons = letters !== '' || shuffling; + const solutionLength = groupCells.length; + + React.useEffect(() => { + if (!shuffling) { + inputRef.current?.focus({ preventScroll: true }); + } + }, [shuffling]); + + const reset = () => { + setLetters(''); + setShuffling(false); + }; + + const shuffle = () => { + if (letters !== '') { + const shuffledLetters = letters + .split('') + .sort(() => 0.5 - Math.random()) + .join(''); + setLetters(shuffledLetters); + setShuffling(true); + buttonRef.current?.focus({ preventScroll: true }); + } + }; + + const appendWord = (word: string) => { + const newLetters = letters + word; + setLetters(newLetters.substr(0, solutionLength)); + inputRef.current?.focus({ preventScroll: true }); + }; + + React.useEffect(() => { + reset(); + }, [clue.id]); + + return ( +
+ +
+ {shuffling ? ( + cell.guess).join('')} + /> + ) : ( + <> + setLetters(event.target.value)} + onKeyDown={(event) => { + if (['Enter', 'NumpadEnter'].includes(event.code)) { + event.preventDefault(); + shuffle(); + } else if (event.code === 'Escape') { + if (letters === '') { + onClose(); + } else { + reset(); + } + } + }} + placeholder="Enter letters..." + ref={inputRef} + spellCheck="false" + value={letters} + /> + + {letters.length}/{solutionLength} + + + )} +
+ +
+
+ + +
+

+ {`${clue.number} ${clue.direction}`} + appendWord(word)} + splitWords={!shuffling} + /> +

+ +
+
+ ); +} + +/** Styles **/ + +const anagramHelperStyle = css` + position: relative; + display: flex; + flex-direction: column; + padding: 20px; + background-color: rgba(0, 0, 0, 0.02); + box-sizing: border-box; + border: 1px solid rgba(0, 0, 0, 0.23); + text-align: center; + overflow: hidden; + // Breakpoint for xs screen + @media (max-width: 576px) { + width: auto !important; + min-width: auto !important; + } +`; + +const topSectionStyle = css` + display: flex; + flex: 50%; + flex-direction: column; + align-items: center; + justify-content: center; +`; + +const inputStyle = css` + font-size: 24px; + background: none; + border: 1px solid rgba(0, 0, 0, 0.87); + padding: 10px 5px; + margin: 5px 0; + text-align: center; + border-radius: 2px; + max-width: 100%; + + &::placeholder { + color: rgba(0, 0, 0, 0.23); + } +`; + +const closeButtonStyle = css` + position: absolute; + top: 10px; + right: 10px; + display: flex; + align-items: center; + justify-content: center; + width: 35px; + height: 35px; + padding: 0; + z-index: 1; +`; + +const closeButtonIconStyle = css` + width: 30px; + height: 30px; +`; + +const bottomSectionStyle = css` + flex: 50%; +`; + +const buttonsContainerStyle = css` + display: flex; + align-items: center; + justify-content: center; +`; + +const clueStyle = css` + line-height: 22px; +`; + +const clueNumStyle = css` + font-weight: bold; + margin-right: 10px; + text-transform: capitalize; +`; + +const clickableWordStyle = css` + cursor: pointer; + user-select: none; + + &:hover { + text-decoration: underline; + } +`; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/ClueDisplay.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/ClueDisplay.tsx new file mode 100644 index 000000000..c3a7d403e --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/ClueDisplay.tsx @@ -0,0 +1,57 @@ +// @ts-nocheck + +import { decodeHtmlEntities } from '../../utils/general'; + +interface ClueDisplayProps { + className?: string; + clue: string; + onClick: (word: string) => void; + splitWords?: boolean; +} + +export default function ClueDisplay({ + className, + clue, + onClick, + splitWords = false, +}: ClueDisplayProps) { + if (!splitWords) { + return ( + + ); + } + + // regex split on word boundaries + const words = decodeHtmlEntities(clue).split(/\b(\w+)\b/); + + return ( + <> + {words.map((word, i) => { + if (i % 2 === 1) { + return ( + onClick(word)} + onKeyPress={(event) => { + if (event.key === 'Enter') { + onClick(word); + } + }} + role="button" + tabIndex={0} + > + {word} + + ); + } + + return {word}; + })} + + ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/SolutionDisplay.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/SolutionDisplay.tsx new file mode 100644 index 000000000..1fc37a217 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/SolutionDisplay.tsx @@ -0,0 +1,139 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import type { Cell, SeparatorLocations } from '../../interfaces'; + +const solutionDisplayStyle = css` + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; +`; + +const letterStyle = css` + color: #000; + border: 1px solid #ccc; + padding: 4px; // vars.$grid-size * 0.4 + margin-top: 4px; // vars.$grid-size * 0.4 + margin-right: 2px; // vars.$grid-size * 0.2 + min-width: 15px; // vars.$grid-size * 1.5 + height: 15px; // vars.$grid-size * 1.5 + text-transform: uppercase; + user-select: none; + box-sizing: content-box; +`; + +const populatedStyle = css` + background-color: rgba(0, 0, 0, 0.08); // vars.$faint-grey3 + border-color: #000; // vars.$grid-color +`; + +const missingStyle = css` + color: red; + border-color: red; + background-color: rgba(255, 0, 0, 0.1); +`; + +const hasSpaceStyle = css` + margin-right: 15px; // vars.$grid-size * 1.5 +`; + +const hasHyphenStyle = css` + position: relative; + + &::after { + position: absolute; + content: '—'; + top: 5px; // vars.$grid-size * 0.5 + left: 20px; // vars.$grid-size * 2 + font-size: 10px; + font-weight: bold; + } +`; + +// Utility function to determine separator style +function getSeparatorStyle( + separators: SeparatorLocations, + letterIndex: number, +) { + const includesLetter = (seps: number[]) => seps.includes(letterIndex); + + const spaces = separators[',']; + if (includesLetter(spaces)) { + return hasSpaceStyle; + } + + const hyphens = separators['-']; + if (includesLetter(hyphens)) { + return hasHyphenStyle; + } + + return undefined; +} + +function filterLetters(letters: string, blacklist: string) { + let filteredLetters = letters; + + blacklist.split('').forEach((badLetter) => { + filteredLetters = filteredLetters.replace(badLetter, ''); + }); + + return filteredLetters; +} + +interface SolutionDisplayProps { + cells: Cell[]; + letters?: string; + separators: SeparatorLocations; + shuffling: boolean; +} + +export default function SolutionDisplay({ + cells, + letters, + separators, + shuffling, +}: SolutionDisplayProps) { + const flatCells = cells.map((cell) => cell.guess).join(''); + const filteredLetters = + letters !== undefined + ? filterLetters(letters?.toUpperCase(), flatCells) + : undefined; + let upperLetters = letters?.toUpperCase(); + let j = 0; + + return ( +
+ {cells.map((cell, i) => { + const inLetters = + cell.guess !== undefined && upperLetters?.includes(cell.guess); + if (inLetters) { + upperLetters = upperLetters?.replace(cell.guess!, ''); + } + + return ( + + {cell.guess ?? + (shuffling && + filteredLetters !== undefined && + filteredLetters[j] !== undefined + ? filteredLetters[j++] + : null)} + + ); + })} +
+ ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/WordWheel.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/WordWheel.tsx new file mode 100644 index 000000000..05c4dc5d2 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/AnagramHelper/WordWheel.tsx @@ -0,0 +1,106 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; + +const round = (val: number) => Math.round(val * 100) / 100; + +const getPosition = (diameter: number, angle: number, i: number) => { + const theta = ((angle * Math.PI) / 180) * i; + + return { + left: `${diameter + round(diameter * Math.sin(theta))}%`, + top: `${diameter + round(diameter * Math.cos(theta))}%`, + }; +}; + +const getCentralPosition = (diameter: number) => ({ + left: `${diameter - 1}%`, + top: `${diameter - 2}%`, +}); + +const getAngle = (letters: string, minForCentral: number = 5) => { + if (letters.length === 0) { + return 0; + } + + if (letters.length < minForCentral) { + return 360 / letters.length; + } + + return 360 / (letters.length - 1); +}; + +// Define styles +const wordWheelStyle = css` + position: relative; + top: 0; + left: 3.5%; + width: 50%; + height: 100%; + margin: 0 auto; + min-width: 200px; // 20 * grid-size (10px) +`; + +const letterStyle = css` + position: absolute; + text-transform: uppercase; + font-size: 16px; // font-size-large (1.6 * 10px) + user-select: none; +`; + +const centralLetterStyle = css` + font-size: 24px; // font-size-xlarge (2.4 * 10px) + min-width: 20px; // grid-size * 2 (2 * 10px) +`; + +const populatedLetterStyle = css` + color: #ccc; // light-grey +`; + +interface WordWheelProps { + letters: string; + populatedLetters: string; +} + +export default function WordWheel({ + letters, + populatedLetters, +}: WordWheelProps) { + const angle = getAngle(letters); + const diameter = 40; + let populated = populatedLetters.toUpperCase(); + + return ( +
+ {letters + .toUpperCase() + .split('') + .map((letter, i) => { + const isPopulated = populated.includes(letter); + if (isPopulated) { + populated = populated.replace(letter, ''); + } + + return ( + 4) && + centralLetterStyle, + isPopulated && populatedLetterStyle, + ]} + style={ + i === 0 && (letters.length === 1 || letters.length > 4) + ? getCentralPosition(diameter) + : getPosition(diameter, angle, i) + } + key={`${letter}-${i}`} + > + {letter} + + ); + })} +
+ ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Button/Button.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Button/Button.tsx new file mode 100644 index 000000000..c51728b2f --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Button/Button.tsx @@ -0,0 +1,90 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; + +interface ButtonProps { + ariaLabel?: string; + children: React.ReactNode; + className?: string; + disabled?: boolean; + id?: string; + onClick: (event: React.MouseEvent) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + variant?: 'filled' | 'outlined'; +} + +const getBackgroundColor = (variant: 'filled' | 'outlined') => { + switch (variant) { + case 'filled': + return '#1976d2'; + case 'outlined': + return 'rgba(0, 0, 0, 0.04)'; + default: + return 'transparent'; + } +}; + +const getBackgroundColorHover = (variant: 'filled' | 'outlined') => { + switch (variant) { + case 'filled': + return '#115293'; + case 'outlined': + return 'rgba(0, 0, 0, 0.08)'; + default: + return 'transparent'; + } +}; + +const Button = React.forwardRef( + ( + { + ariaLabel, + children, + className, + disabled, + id, + onClick, + onKeyDown, + variant = 'filled', + }, + ref, + ) => ( + + ), +); + +export default Button; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Clue/Clue.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Clue/Clue.tsx new file mode 100644 index 000000000..ae065d45e --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Clue/Clue.tsx @@ -0,0 +1,151 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; +import { useCellsContext } from '../../context/CellsContext'; +import { useCluesContext } from '../../context/CluesContext'; +import { decodeHtmlEntities, isInPerimeterRect } from '../../utils/general'; +import { CellFocus, CellPosition } from '../../interfaces'; + +// Define styles +const clueStyle = css` + cursor: pointer; + display: flex; + line-height: 22px; + color: #000; + padding: 4px 12px 4px 7px; + user-select: none; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } +`; + +const answeredStyle = css` + opacity: 0.6; +`; + +const highlightedStyle = css` + background-color: #bbdefb; + + &:hover { + background-color: #bbdefb; + } +`; + +const clueNumStyle = css` + flex: 0 0 20px; + font-weight: bold; + margin-right: 20px; +`; + +const clueTextStyle = css` + &::before { + display: block; + content: attr(data-text); + font-weight: bold; + height: 0; + overflow: hidden; + visibility: hidden; + } +`; + +interface ClueProps { + answered: boolean; + breakpoint: string; + col: number; + containerRef?: React.RefObject; + id: string; + inputRef?: React.RefObject; + isHighlighted: boolean; + num: string; + onCellFocus?: (cellFocus: CellFocus) => void; + row: number; + scrollTo?: boolean; + text: string; +} + +function Clue({ + answered, + breakpoint, + col, + containerRef, + id, + inputRef, + isHighlighted, + num, + onCellFocus, + row, + scrollTo, + text, +}: ClueProps) { + const ref = React.useRef(null); + const { select: cellsActionSelect } = useCellsContext(); + const { select: cluesActionSelect } = useCluesContext(); + + React.useEffect(() => { + if ( + scrollTo && + ref.current !== null && + containerRef !== undefined && + containerRef.current !== null + ) { + const rect = ref.current.getBoundingClientRect(); + const perimeterRect = containerRef.current.getBoundingClientRect(); + const inView = isInPerimeterRect(rect, perimeterRect); + + if (!inView) { + ref.current.scrollIntoView({ + behavior: 'auto', + block: 'nearest', + inline: 'nearest', + }); + } + } + }, [scrollTo]); + + const cellFocus = (pos: CellPosition, clueId: string) => { + onCellFocus?.({ + pos, + clueId, + }); + }; + + const updateSelectedClue = React.useCallback(() => { + const pos = { col, row }; + cluesActionSelect(id); + cellsActionSelect(pos); + cellFocus(pos, id); + inputRef?.current?.focus({ preventScroll: true }); + }, [breakpoint, inputRef]); + + return ( +
{ + if (event.key === 'Enter') { + updateSelectedClue(); + } + }} + role="button" + ref={ref} + tabIndex={0} + > + {num} + +
+ ); +} + +export default React.memo(Clue); diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Clues/Clues.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Clues/Clues.tsx new file mode 100644 index 000000000..f890a5884 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Clues/Clues.tsx @@ -0,0 +1,172 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; +import { Clue } from '../index'; +import { CellFocus, Clue as ClueInterface } from '../../interfaces'; + +const gridUnit = 10; + +const cluesContainerStyle = css` + display: flex; + flex-grow: 1; + + @media (min-width: 768px) and (max-width: 992px) { + flex-direction: column; + overflow-y: auto; + border-bottom: 1px dotted #ccc; + margin: 0 ${gridUnit}px; + scrollbar-width: thin; + } + + @media (max-width: 576px) { + flex-direction: column; + margin-top: ${gridUnit}px; + } + + @media (min-width: 993px) { + flex-direction: row; + } +`; + +const cluesListStyle = css` + display: flex; + flex-direction: column; + margin: 0 ${gridUnit}px; + width: 50%; + + @media (min-width: 768px) and (max-width: 992px) { + width: 100%; + margin: 0; + } + + @media (max-width: 576px) { + width: 100%; + } +`; + +const cluesListBodyStyle = css` + overflow-y: auto; + border-bottom: 1px dotted #ccc; + scrollbar-width: thin; + flex-grow: 1; +`; + +const listDownMarginTop = css` + margin-top: ${2 * gridUnit}px; +`; + +const cluesListHeaderStyle = css` + border-bottom: 1px dotted #ccc; + margin-top: 0; + padding-bottom: ${gridUnit * 0.4}px; + margin-bottom: 0; + position: sticky; + top: 0; + background: #fff; + z-index: 1; + + @media (min-width: 768px) and (max-width: 992px) { + top: -1px; + } +`; + +interface CluesProps { + allowedHtmlTags: string[]; + breakpoint: string; + entries: ClueInterface[]; + inputRef?: React.RefObject; + onCellFocus?: (cellFocus: CellFocus) => void; + selectedClueId?: string; + style?: React.CSSProperties; +} + +export default function Clues({ + allowedHtmlTags, + breakpoint, + entries, + inputRef, + onCellFocus, + selectedClueId, + style, +}: CluesProps) { + const cluesContainerRef = React.useRef(null); + const acrossContainerRef = React.useRef(null); + const downContainerRef = React.useRef(null); + + const across = entries + .filter((entry) => entry.direction === 'across') + .sort((a, b) => a.number - b.number); + const down = entries + .filter((entry) => entry.direction === 'down') + .sort((a, b) => a.number - b.number); + + const isHighlighted = (thisEntry: ClueInterface) => { + if (selectedClueId === undefined) { + return false; + } + const selectedClue = entries.find((entry) => entry.id === selectedClueId); + return selectedClue?.group.includes(thisEntry.id) ?? false; + }; + + const scrollTo = (entry: ClueInterface) => + ['md', 'lg', 'xl', 'xxl'].includes(breakpoint) && + selectedClueId !== undefined && + entry.group.includes(selectedClueId) && + entry.id === entry.group[0]; + + return ( +
+
+

Across

+
+ {across.map((entry) => ( + + ))} +
+
+
+

Down

+
+ {down.map((entry) => ( + + ))} +
+
+
+ ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Confirm/Confirm.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Confirm/Confirm.tsx new file mode 100644 index 000000000..669882f79 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Confirm/Confirm.tsx @@ -0,0 +1,85 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; +import { Button } from '../index'; + +const gridSize = 10; + +const confirmContainerStyle = css` + display: flex; + flex-direction: column; + margin: 0 ${gridSize * 0.5}px; +`; + +const buttonContainerStyle = css` + display: inline-flex; + margin: 0 ${gridSize * -0.5}px; +`; + +const confirmButtonStyle = css` + background-color: #1976d2; // Confirm button background color + color: #fff; // Confirm button text color + + &:not(:disabled):hover { + background-color: #115293; // Confirm button hover color + } +`; + +const timeoutStyle = css` + font-size: ${gridSize * 1.2}px; // Font size caption + font-weight: 400; + letter-spacing: ${gridSize * 0.04}px; + margin-top: ${gridSize}px; +`; + +interface ConfirmProps { + buttonText: string; + onCancel: () => void; + onConfirm: () => void; + timeout?: number; +} + +export const defaultTimeout = 10; + +export default function Confirm({ + buttonText, + onCancel, + onConfirm, + timeout = defaultTimeout, +}: ConfirmProps) { + if (timeout <= 0) { + throw new Error('Confirm should have a timeout greater than zero'); + } + const [seconds, setSeconds] = React.useState(timeout); + + React.useEffect(() => { + const timer = setTimeout(() => { + if (seconds <= 1) { + onCancel(); + } else { + setSeconds((secs) => secs - 1); + } + }, 1000); + + return function cleanup() { + clearTimeout(timer); + }; + }, [seconds]); + + return ( +
+
+ + +
+ + This will automatically cancel in {seconds} + +
+ ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Controls/Controls.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Controls/Controls.tsx new file mode 100644 index 000000000..8e941be32 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Controls/Controls.tsx @@ -0,0 +1,422 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; +import { Button, Confirm, DropdownButton } from '../index'; +import { useCellsContext } from '../../context/CellsContext'; +import { useCluesContext } from '../../context/CluesContext'; +import { blankNeighbours, mergeCell } from '../../utils/cell'; +import { + getCrossingClueIds, + getGroupCells, + isCluePopulated, +} from '../../utils/clue'; +import { getGuessGrid } from '../../utils/guess'; +import { Cell, CellChange, Char, Clue, GuessGrid } from '../../interfaces'; + +const gridUnit = 10; + +const controlsContainerStyle = css` + display: flex; + margin-left: ${-0.5 * gridUnit}px; + margin-right: ${-0.5 * gridUnit}px; + padding: ${0.6 * gridUnit}px 0; + flex-wrap: wrap; + min-height: ${7 * gridUnit}px; +`; + +interface ControlsProps { + breakpoint: string; + cells: Cell[]; + clues: Clue[]; + gridCols: number; + gridRows: number; + onAnagramHelperClick: () => void; + onCellChange?: (cellChange: CellChange) => void; + setGuessGrid: (value: GuessGrid | ((val: GuessGrid) => GuessGrid)) => void; + solutionsAvailable: boolean; +} + +export default function Controls({ + breakpoint, + cells, + clues, + gridCols, + gridRows, + onAnagramHelperClick, + onCellChange, + setGuessGrid, + solutionsAvailable, +}: ControlsProps) { + const { + updateGrid: cellsActionUpdateGrid, + revealGrid: cellsActionRevealGrid, + clearGrid: cellsActionClearGrid, + } = useCellsContext(); + const { + answerGrid: cluesActionAnswerGrid, + unanswerGrid: cluesActionUnanswerGrid, + answerOne: cluesActionAnswerOne, + unanswerOne: cluesActionUnanswerOne, + } = useCluesContext(); + const selectedCell = cells.find((cell) => cell.selected); + const selectedClue = clues.find((clue) => clue.selected); + const [showCheckGridConfirm, setShowCheckGridConfirm] = React.useState(false); + const [showRevealGridConfirm, setShowRevealGridConfirm] = + React.useState(false); + const [showClearGridConfirm, setShowClearGridConfirm] = React.useState(false); + + const updateGuessGrid = (updatedCells: Cell[]) => { + setGuessGrid(getGuessGrid(gridCols, gridRows, updatedCells)); + }; + + const updateAnsweredForCrossingClues = (clue: Clue, updatedCells: Cell[]) => { + const clueIds = getCrossingClueIds(clue, updatedCells); + clueIds.forEach((clueId) => { + const crossingClue = clues.find((c) => c.id === clueId); + + if (crossingClue) { + if (isCluePopulated(crossingClue, updatedCells)) { + cluesActionAnswerOne(crossingClue.group); + } else { + cluesActionUnanswerOne(crossingClue.group); + } + } + }); + }; + + const cellChange = (cell: Cell, newGuess: Char | undefined) => { + if (onCellChange !== undefined && cell.guess !== newGuess) { + onCellChange({ + pos: cell.pos, + guess: newGuess, + previousGuess: cell.guess, + }); + } + }; + + const checkMenu = [ + { + disabled: selectedCell === undefined, + onClick: () => { + if (selectedCell === undefined) { + return; + } + + if (selectedCell.guess !== selectedCell.val) { + cellChange(selectedCell, undefined); + + // merge in selectedCell with its letter cleared + const updatedCells = mergeCell( + { ...selectedCell, guess: undefined }, + cells, + ); + + cellsActionUpdateGrid(updatedCells); + + // mark across and/or down clue as unanswered + cluesActionUnanswerOne(selectedCell.clueIds); + + // update guesses in local storage + updateGuessGrid(updatedCells); + } + }, + text: 'Check letter', + }, + { + disabled: selectedClue === undefined, + onClick: () => { + if (selectedClue !== undefined) { + // handle cell changes + if (onCellChange !== undefined) { + const groupCells = getGroupCells(selectedClue.group, cells); + groupCells.forEach((cell) => { + if (cell.guess !== undefined && cell.val !== cell.guess) { + cellChange(cell, undefined); + } + }); + } + + const updatedCells = cells.map((cell) => { + const intersection = selectedClue.group.filter((clueId) => + cell.clueIds.includes(clueId), + ); + + if (intersection.length > 0) { + return { + ...cell, + guess: cell.guess === cell.val ? cell.val : undefined, + }; + } + + return cell; + }); + + cellsActionUpdateGrid(updatedCells); + updateAnsweredForCrossingClues(selectedClue, updatedCells); + + // update guesses in local storage + updateGuessGrid(updatedCells); + } + }, + text: 'Check word', + }, + { onClick: () => setShowCheckGridConfirm(true), text: 'Check grid' }, + ]; + + const revealMenu = [ + { + disabled: selectedCell === undefined, + onClick: () => { + if ( + selectedCell === undefined || + selectedCell.guess === selectedCell.val + ) { + return; + } + + cellChange(selectedCell, selectedCell.val); + + // merge in selectedCell with its letter revealed + const updatedCells = mergeCell( + { ...selectedCell, guess: selectedCell.val }, + cells, + ); + + cellsActionUpdateGrid(updatedCells); + + // if all cells are populated, mark clue as answered + selectedCell.clueIds.forEach((clueId) => { + const clue = clues.find((c) => c.id === clueId)!; + const populated = isCluePopulated(clue, updatedCells); + + if (populated) { + cluesActionAnswerOne(clue.group); + } + }); + + // update guesses in local storage + updateGuessGrid(updatedCells); + }, + text: 'Reveal letter', + }, + { + disabled: selectedClue === undefined, + onClick: () => { + if (selectedClue === undefined) { + return; + } + + // handle cell changes + if (onCellChange !== undefined) { + const groupCells = getGroupCells(selectedClue.group, cells); + groupCells.forEach((cell) => { + if (cell.val !== cell.guess) { + cellChange(cell, cell.val); + } + }); + } + + const updatedCells = cells.map((cell) => { + const intersection = selectedClue.group.filter((clueId) => + cell.clueIds.includes(clueId), + ); + + if (intersection.length > 0) { + return { + ...cell, + guess: cell.val, + }; + } + + return cell; + }); + + cellsActionUpdateGrid(updatedCells); + + updateAnsweredForCrossingClues(selectedClue, updatedCells); + + // update guesses in local storage + updateGuessGrid(updatedCells); + }, + text: 'Reveal word', + }, + { onClick: () => setShowRevealGridConfirm(true), text: 'Reveal grid' }, + ]; + + const clearMenu = [ + { + disabled: selectedClue === undefined, + onClick: () => { + if (selectedClue !== undefined) { + const updatedCells = cells.map((cell) => { + const intersection = selectedClue.group.filter((clueId) => + cell.clueIds.includes(clueId), + ); + + if (intersection.length > 0) { + if (cell.clueIds.length === 1) { + cellChange(cell, undefined); + + // only one direction, can safely clear the cell + return { + ...cell, + guess: undefined, + }; + } + + // more than one direction, check if neighbouring letters are blank + const clueId = intersection[0]; + if (clueId) { + const across = clueId.includes('across'); + if (blankNeighbours(cells, cell, across)) { + cellChange(cell, undefined); + + return { + ...cell, + guess: undefined, + }; + } + } + } + + return cell; + }); + + cellsActionUpdateGrid(updatedCells); + + // mark clue (and others in its group) as unanswered + cluesActionUnanswerOne(selectedClue.group); + + // update guesses in local storage + updateGuessGrid(updatedCells); + } + }, + text: 'Clear word', + }, + { onClick: () => setShowClearGridConfirm(true), text: 'Clear grid' }, + ]; + + if (showCheckGridConfirm) { + return ( +
+ setShowCheckGridConfirm(false)} + onConfirm={() => { + // handle cell changes + if (onCellChange !== undefined) { + cells.forEach((cell) => { + if (cell.guess !== undefined && cell.val !== cell.guess) { + cellChange(cell, undefined); + } + }); + } + + const updatedCells = cells.map((cell) => ({ + ...cell, + guess: cell.guess === cell.val ? cell.val : undefined, + })); + + cellsActionUpdateGrid(updatedCells); + + // check all clues to see if they need to be marked as unanswered + clues.forEach((clue) => { + if (isCluePopulated(clue, updatedCells)) { + cluesActionAnswerOne(clue.group); + } else { + cluesActionUnanswerOne(clue.group); + } + }); + + setShowCheckGridConfirm(false); + + // update guesses in local storage + updateGuessGrid(updatedCells); + }} + /> +
+ ); + } + + if (showRevealGridConfirm) { + return ( +
+ setShowRevealGridConfirm(false)} + onConfirm={() => { + // handle cell changes + if (onCellChange !== undefined) { + cells.forEach((cell) => { + if (cell.val !== cell.guess) { + cellChange(cell, cell.val); + } + }); + } + + cellsActionRevealGrid(); + cluesActionAnswerGrid(); + setShowRevealGridConfirm(false); + + // update guesses in local storage + const updatedCells = cells.map((cell) => ({ + ...cell, + guess: cell.val, + })); + updateGuessGrid(updatedCells); + }} + /> +
+ ); + } + + if (showClearGridConfirm) { + return ( +
+ setShowClearGridConfirm(false)} + onConfirm={() => { + // handle cell changes + if (onCellChange !== undefined) { + cells.forEach((cell) => { + if (cell.guess !== undefined) { + cellChange(cell, undefined); + } + }); + } + + cellsActionClearGrid(); + cluesActionUnanswerGrid(); + setShowClearGridConfirm(false); + + // clear guesses in local storage + updateGuessGrid([]); + }} + /> +
+ ); + } + + return ( +
+ {solutionsAvailable ? ( + <> + + + + ) : null} + +
+ +
+
+ ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Crossword/Crossword.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Crossword/Crossword.tsx new file mode 100644 index 000000000..edf77009a --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Crossword/Crossword.tsx @@ -0,0 +1,288 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; +import { + AnagramHelper, + cellSize, + Clues, + Controls, + Grid, + GridError, + StickyClue, +} from '../index'; +import { useCellsContext } from '../../context/CellsContext'; +import { useCluesContext } from '../../context/CluesContext'; +import { initialiseCells } from '../../utils/cell'; +import { + getGroupCells, + getGroupSeparators, + initialiseClues, +} from '../../utils/clue'; +import { initialiseGuessGrid, validateGuessGrid } from '../../utils/guess'; +import { useBreakpoint, useLocalStorage } from '../../hooks'; +import type { + GuardianCrossword, + GuessGrid, + CellChange, + CellFocus, + CellPosition, +} from '../../interfaces'; + +// Define base sizes +const breakpointXS = 576; +const breakpointSM = 768; + +// Emotion CSS styles +const crosswordStyle = css` + display: flex; + + @media (max-width: ${breakpointXS}px) { + flex-direction: column; + } + + @media (min-width: ${breakpointXS}px) and (max-width: ${breakpointSM}px) { + flex-direction: column; + } +`; + +const gridAndControlsStyle = css` + flex-shrink: 0; + + @media (min-width: ${breakpointXS}px) and (max-width: ${breakpointSM}px) { + margin: 0 auto; + } +`; + +interface CrosswordProps { + allowedHtmlTags: string[]; + allowMissingSolutions: boolean; + cellMatcher: RegExp; + data: GuardianCrossword; + id: string; + loadGrid?: GuessGrid; + onCellChange?: (cellChange: CellChange) => void; + onCellFocus?: (cellFocus: CellFocus) => void; + saveGrid?: (value: GuessGrid | ((val: GuessGrid) => GuessGrid)) => void; + stickyClue: 'always' | 'never' | 'auto'; +} + +export default function Crossword({ + allowedHtmlTags, + allowMissingSolutions, + cellMatcher, + data, + id, + loadGrid, + onCellChange, + onCellFocus, + saveGrid, + stickyClue, +}: CrosswordProps) { + const breakpoint = useBreakpoint(); + const [guessGrid, setGuessGrid] = useLocalStorage( + `crosswords.${id}`, + initialiseGuessGrid(data.dimensions.cols, data.dimensions.rows), + ); + const { + state: cellsState, + updateGrid: cellsActionUpdateGrid, + select: cellsActionSelect, + } = useCellsContext(); + const { + state: cluesState, + updateGrid: cluesActionUpdateGrid, + select: cluesActionSelect, + } = useCluesContext(); + + // Access cells and clues from the respective state + const cells = cellsState.cells; + const clues = cluesState.clues; + const selectedClue = clues.find((clue) => clue.selected); + const parentClue = + selectedClue?.group.length === 1 + ? selectedClue + : clues.find((clue) => clue.id === selectedClue?.group[0]); + const [gridErrorMessage, setGridErrorMessage] = React.useState(); + const [isAnagramHelperOpen, setIsAnagramHelperOpen] = React.useState(false); + const gridHeight = data.dimensions.rows * cellSize + data.dimensions.rows + 1; + const gridWidth = data.dimensions.cols * cellSize + data.dimensions.cols + 1; + const inputRef = React.useRef(null); + + // validate overriding guess grid if defined + if ( + loadGrid !== undefined && + !validateGuessGrid( + loadGrid, + data.dimensions.cols, + data.dimensions.rows, + cellMatcher, + ) + ) { + return ( +
+ +
+ ); + } + + React.useEffect(() => { + try { + // initialise cells + const initCells = initialiseCells({ + cols: data.dimensions.cols, + rows: data.dimensions.rows, + entries: data.entries, + guessGrid: loadGrid ?? guessGrid, + allowMissingSolutions, + }); + cellsActionUpdateGrid(initCells); + + // initialise clues + const initClues = initialiseClues( + data.entries, + initCells, + window.location.hash.replace('#', ''), + ); + cluesActionUpdateGrid(initClues); + + setGridErrorMessage(undefined); + } catch (error: unknown) { + if (error instanceof Error) { + setGridErrorMessage(error.message); + } else { + throw error; + } + } + }, [data]); + + if (gridErrorMessage !== undefined) { + return ( +
+ +
+ ); + } + + const cellFocus = (pos: CellPosition, clueId: string) => { + if (onCellFocus !== undefined) { + onCellFocus({ + pos, + clueId, + }); + } + }; + + const moveToNextClue = (forwards: boolean) => { + const index = clues.findIndex((clue) => clue.selected); + let nextIndex = 0; + + if (forwards) { + nextIndex = index < clues.length - 1 ? index + 1 : 0; + } else { + nextIndex = index > 0 ? index - 1 : clues.length - 1; + } + + const nextClue = clues[nextIndex]; + if (nextClue) { + const nextCluePos = { + col: nextClue.position.x, + row: nextClue.position.y, + }; + + cluesActionSelect(nextClue.id); + cellsActionSelect(nextCluePos); + + cellFocus(nextCluePos, nextClue.id); + } + inputRef?.current?.focus({ preventScroll: true }); + }; + + return ( +
+
+
+ {isAnagramHelperOpen && parentClue !== undefined ? ( + setIsAnagramHelperOpen(false)} + style={{ + height: gridHeight, + maxHeight: gridHeight, + width: gridWidth, + maxWidth: gridWidth, + }} + /> + ) : ( + <> + {stickyClue === 'always' || + (stickyClue === 'auto' && + breakpoint !== undefined && + ['xs', 'sm'].includes(breakpoint)) ? ( + moveToNextClue(true)} + onMovePrev={() => moveToNextClue(false)} + text={parentClue?.clue} + /> + ) : null} + + + )} +
+ setIsAnagramHelperOpen((val) => !val)} + onCellChange={onCellChange} + setGuessGrid={saveGrid ?? setGuessGrid} + solutionsAvailable={data.solutionAvailable} + /> +
+ +
+ ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/DropdownButton/DropdownButton.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/DropdownButton/DropdownButton.tsx new file mode 100644 index 000000000..e4a403e3b --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/DropdownButton/DropdownButton.tsx @@ -0,0 +1,184 @@ +// @ts-nocheck + +/** @jsxImportSource @emotion/react */ +import { css } from '@emotion/react'; +import * as React from 'react'; +import { isInViewport } from '../../utils/general'; + +const CaretDownIcon = () => ( + + + + + +); + +export interface DropdownMenuItem { + disabled?: boolean; + onClick: () => void; + text: string; +} + +interface DropdownButtonProps { + id?: string; + menu: DropdownMenuItem[]; + text: string; +} + +// Styles +const dropdownButtonStyle = css` + position: relative; + user-select: none; +`; + +const buttonStyle = css` + background-color: #1976d2; + color: #fff; + padding: 8px 12px; + margin: 5px; + font-weight: bold; + border: none; + cursor: pointer; + white-space: nowrap; + border-radius: 2px; + + &:hover { + background-color: #115293; + } +`; + +const expandedButtonStyle = css` + background-color: #115293; +`; + +const dropdownIconStyle = css` + fill: #fff; +`; + +const menuStyle = css` + visibility: hidden; + display: flex; + flex-direction: column; + overflow: hidden; + position: absolute; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.15); + z-index: 2; + margin: 0 5px; + border-radius: 2px; + min-width: calc(100% - 10px); + padding: 0; + list-style: none; +`; + +const visibleMenuStyle = css` + visibility: visible; +`; + +const menuItemStyle = css` + display: flex; + background-color: transparent; + border: none; + padding: 8px 12px; + white-space: nowrap; + text-overflow: ellipsis; + width: 100%; + + &:not(:disabled):hover { + background-color: rgba(0, 0, 0, 0.08); + cursor: pointer; + } +`; + +function DropdownButton({ id, menu, text }: DropdownButtonProps) { + if (menu.length < 2) { + throw new Error('DropdownButton should have at least 2 menu items'); + } + + const componentRef = React.useRef(null); + const buttonRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuExpanded, setMenuExpanded] = React.useState(false); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + menuExpanded && + componentRef.current && + !componentRef.current.contains(event.target as Node) + ) { + setMenuExpanded(false); + } + }; + + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + }, [menuExpanded]); + + const toggleMenuExpanded = () => { + if (menuRef.current && buttonRef.current) { + menuRef.current.style.marginTop = ''; + + if (!menuExpanded) { + const menuRect = menuRef.current.getBoundingClientRect(); + const inView = isInViewport(menuRect); + + if (!inView) { + const height = menuRect.height + buttonRef.current.clientHeight + 10; + menuRef.current.style.marginTop = `-${height}px`; + } + } + } + setMenuExpanded((prev) => !prev); + }; + + return ( +
+ +
    + {menu.map((item) => ( +
  • + +
  • + ))} +
+
+ ); +} + +export default React.memo(DropdownButton); diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Grid/Grid.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Grid/Grid.tsx new file mode 100644 index 000000000..f1d3c7cf9 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Grid/Grid.tsx @@ -0,0 +1,562 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; +import { cellSize, GridCell, GridSeparators } from '../index'; +import { useCellsContext } from '../../context/CellsContext'; +import { useCluesContext } from '../../context/CluesContext'; +import { getDimensions } from '../GridCell/GridCell'; +import GridInput from '../GridInput/GridInput'; +import Spinner from '../Spinner/Spinner'; +import { useDebounce } from '../../hooks'; +import type { + Cell, + CellPosition, + Char, + Clue, + GuardianClue, + GuessGrid, + CellChange, + CellFocus, +} from '../../interfaces'; +import { mergeCell } from '../../utils/cell'; +import { isCluePopulated } from '../../utils/clue'; +import { isValidChar } from '../../utils/general'; +import { getGuessGrid } from '../../utils/guess'; + +// Base sizes and breakpoints +const gridBackground = '#000'; // vars.$grid-background +const gridSize = 10; +const breakpointXS = 576; +const breakpointSM = 768; + +// Emotion CSS styles +const gridStyle = css` + position: relative; + + &:focus { + outline: none; + } +`; + +const loadingStyle = css` + display: flex; + align-items: center; + justify-content: center; + background-color: ${gridBackground}; +`; + +const backgroundStyle = css` + fill: ${gridBackground}; +`; + +const inputContainerStyle = css` + pointer-events: none; + position: absolute; +`; + +// Responsive styling +const responsiveStyle = css` + @media (max-width: ${breakpointXS}px) { + width: auto !important; + height: auto !important; + min-width: auto !important; + min-height: auto !important; + scroll-margin-top: ${gridSize * 5}px; + } + + @media (min-width: ${breakpointXS}px) and (max-width: ${breakpointSM}px) { + scroll-margin-top: ${gridSize * 5}px; + } +`; + +const cellPositionMatches = ( + cellPosA: CellPosition, + cellPosB?: CellPosition, +) => { + if (cellPosB === undefined) { + return false; + } + return cellPosA.col === cellPosB.col && cellPosA.row === cellPosB.row; +}; + +interface GridProps { + cellMatcher: RegExp; + cells: Cell[]; + clues: Clue[]; + cols: number; + guessGrid: GuessGrid; + inputRef?: React.RefObject; + isLoading?: boolean; + onCellChange?: (cellChange: CellChange) => void; + onCellFocus?: (cellFocus: CellFocus) => void; + rawClues: GuardianClue[]; + rows: number; + setGuessGrid: (value: GuessGrid | ((val: GuessGrid) => GuessGrid)) => void; +} + +export default function Grid({ + cellMatcher, + cells, + clues, + cols, + guessGrid, + inputRef, + isLoading = false, + onCellChange, + onCellFocus, + rawClues, + rows, + setGuessGrid, +}: GridProps) { + const { updateGrid: cellsActionUpdateGrid, select: cellsActionSelect } = + useCellsContext(); + const { + answerOne: cluesActionAnswerOne, + select: cluesActionSelect, + unanswerOne: cluesActionUnanswerOne, + } = useCluesContext(); + + const selectedCell = cells.find((cell) => cell.selected); + const selectedClue = clues.find((clue) => clue.selected); + const width = cols * cellSize + cols + 1; + const height = rows * cellSize + rows + 1; + const [guesses, setGuesses] = React.useState(guessGrid); + const debouncedGuesses: GuessGrid = useDebounce(guesses, 1000); + const svgRef = React.useRef(null); + const [viewBoxScale, setViewBoxScale] = React.useState(1); + + const updateViewBoxScale = React.useCallback(() => { + if (svgRef.current !== null) { + const svgWidth = svgRef.current.clientWidth; + const svgHeight = svgRef.current.clientHeight; + const scaleX = svgWidth / width; + const scaleY = svgHeight / height; + const minScale = Math.min(scaleX, scaleY); + + setViewBoxScale(minScale); + } + }, [svgRef.current]); + + React.useEffect(() => { + window.addEventListener('resize', updateViewBoxScale); + updateViewBoxScale(); + + return function cleanup() { + window.removeEventListener('resize', updateViewBoxScale); + }; + }, [updateViewBoxScale]); + + React.useEffect(() => { + // only update local storage after debounce delay + setGuessGrid(debouncedGuesses); + }, [debouncedGuesses]); + + const cellChange = (cell: Cell, newGuess: Char | undefined) => { + if (onCellChange !== undefined && cell.guess !== newGuess) { + onCellChange({ + pos: cell.pos, + guess: newGuess, + previousGuess: cell.guess, + }); + } + }; + + const cellFocus = (pos: CellPosition, clueId: string) => { + if (onCellFocus !== undefined) { + onCellFocus({ + pos, + clueId, + }); + } + }; + + const updateGuesses = (updatedCells: Cell[]) => { + setGuesses(getGuessGrid(cols, rows, updatedCells)); + }; + + const movePrev = () => { + if (selectedClue === undefined || selectedCell === undefined) { + return; + } + + const atTheStart = + (selectedClue.direction === 'across' && + selectedCell.pos.col === selectedClue.position.x) || + (selectedClue.direction === 'down' && + selectedCell.pos.row === selectedClue.position.y); + + if (atTheStart) { + // if we're at the start of the clue, try to move to the previous + // one in the group if it exists + const groupIndex = selectedClue.group.indexOf(selectedClue.id); + if (groupIndex > 0) { + const prevClueId = selectedClue.group[groupIndex - 1]; + const prevClue = clues.find((clue) => clue.id === prevClueId); + + if (prevClue !== undefined && prevClueId) { + const prevCluePos = { + col: + prevClue.position.x + + (prevClue.direction === 'across' ? prevClue.length - 1 : 0), + row: + prevClue.position.y + + (prevClue.direction === 'down' ? prevClue.length - 1 : 0), + }; + + cluesActionSelect(prevClueId); + cellsActionSelect(prevCluePos); + + cellFocus(prevCluePos, prevClueId); + } + } + } else { + // move to the previous cell in the clue + const cellPos: CellPosition = + selectedClue.direction === 'across' + ? { col: selectedCell.pos.col - 1, row: selectedCell.pos.row } + : { col: selectedCell.pos.col, row: selectedCell.pos.row - 1 }; + cellsActionSelect(cellPos); + + cellFocus(cellPos, selectedClue.id); + } + }; + + const moveNext = () => { + if (selectedClue === undefined || selectedCell === undefined) { + return; + } + + const atTheEnd = + (selectedClue.direction === 'across' && + selectedCell.pos.col === + selectedClue.position.x + selectedClue.length - 1) || + (selectedClue.direction === 'down' && + selectedCell.pos.row === + selectedClue.position.y + selectedClue.length - 1); + + if (atTheEnd) { + // if we're at the end of the clue, try to move onto the next + // one in the group if it exists + const groupIndex = selectedClue.group.indexOf(selectedClue.id); + if (selectedClue.group.length - 1 > groupIndex) { + const nextClueId = selectedClue.group[groupIndex + 1]; + const nextClue = clues.find((clue) => clue.id === nextClueId); + + if (nextClue !== undefined && nextClueId) { + const nextCluePos = { + col: nextClue.position.x, + row: nextClue.position.y, + }; + + cluesActionSelect(nextClueId); + cellsActionSelect(nextCluePos); + + cellFocus(nextCluePos, nextClueId); + } + } + } else { + // move onto the next cell in the clue + const cellPos: CellPosition = + selectedClue.direction === 'across' + ? { col: selectedCell.pos.col + 1, row: selectedCell.pos.row } + : { col: selectedCell.pos.col, row: selectedCell.pos.row + 1 }; + cellsActionSelect(cellPos); + + cellFocus(cellPos, selectedClue.id); + } + }; + + /** + * Find the next cell on the current row/column (wrap on grid overflow) + * @param {number} colDelta - Horizontal delta (-1, 0, 1) + * @param {number} rowDelta - Vertical delta (-1, 0, 1) + */ + const findNextCell = (colDelta: number, rowDelta: number) => { + const nextPos = (i: number, amount: number, max: number) => { + const j = i + amount; + + if (j === -1) { + return max - 1; + } + if (j === max) { + return 0; + } + + return j; + }; + + let { col, row } = selectedCell?.pos!; + + // loop won't be infinite as it will always wrap and find the selected cell on the same row/col + // eslint-disable-next-line no-constant-condition + while (true) { + if (colDelta === 1 || colDelta === -1) { + col = nextPos(col, colDelta, cols); + } else if (rowDelta === 1 || rowDelta === -1) { + row = nextPos(row, rowDelta, rows); + } + + const tempCell = cells.find( + (cell) => cell.pos.col === col && cell.pos.row === row, + ); + + if (tempCell !== undefined) { + return tempCell; + } + } + }; + + const moveDirection = (direction: string) => { + if (selectedClue === undefined || selectedCell === undefined) { + return; + } + let nextCell: Cell | undefined; + + switch (direction) { + case 'Up': + nextCell = findNextCell(0, -1); + break; + case 'Down': + nextCell = findNextCell(0, 1); + break; + case 'Left': + nextCell = findNextCell(-1, 0); + break; + case 'Right': + nextCell = findNextCell(1, 0); + break; + default: + nextCell = undefined; + } + + if (nextCell !== undefined) { + cellsActionSelect(nextCell.pos); + + // update the selected clue + if (!nextCell.clueIds.includes(selectedClue.id) && nextCell.clueIds[0]) { + cluesActionSelect(nextCell.clueIds[0]); + + cellFocus(nextCell.pos, nextCell.clueIds[0]); + } else { + cellFocus(nextCell.pos, selectedClue.id); + } + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (selectedClue === undefined || selectedCell === undefined) { + return; + } + + // whitelist keys + if ( + ![ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Backspace', + 'Delete', + 'Tab', + ].includes(event.key) + ) { + return; + } + + // prevent keys scrolling page + event.preventDefault(); + + // prevent arrow keys propagating to window + event.stopPropagation(); + + if ( + ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key) + ) { + // move to the next cell + moveDirection(event.key.replace('Arrow', '')); + } else if (['Backspace', 'Delete'].includes(event.key)) { + cellChange(selectedCell, undefined); + + // clear the cell's value + const updatedCell: Cell = { + ...selectedCell, + guess: undefined, + }; + + const updatedCells = mergeCell(updatedCell, cells); + cellsActionUpdateGrid(updatedCells); + + // mark clue(s) as unanswered (ones in group and crossing) + selectedCell.clueIds.forEach((clueId) => { + const clue = clues.find((c) => c.id === clueId); + + if (clue) { + if (isCluePopulated(clue, updatedCells)) { + cluesActionAnswerOne(clue.group); + } else { + cluesActionUnanswerOne(clue.group); + } + } + }); + + if (event.key === 'Backspace') { + movePrev(); + } + + updateGuesses(updatedCells); + } else if (event.key === 'Tab') { + // cycle through the clues + const index = clues.findIndex((clue) => clue.selected); + let nextIndex = 0; + + // forwards or backwards + if (event.shiftKey) { + nextIndex = index > 0 ? index - 1 : clues.length - 1; + } else { + nextIndex = index < clues.length - 1 ? index + 1 : 0; + } + const nextClue = clues[nextIndex]; + if (nextClue) { + const nextCluePos = { + col: nextClue.position.x, + row: nextClue.position.y, + }; + + cluesActionSelect(nextClue.id); + cellsActionSelect(nextCluePos); + + cellFocus(nextCluePos, nextClue.id); + } + } + }; + + const handleChange = (event: React.ChangeEvent) => { + if (selectedClue === undefined || selectedCell === undefined) { + return; + } + + const key = event.target.value.toUpperCase(); + + if (isValidChar(key, cellMatcher)) { + cellChange(selectedCell, key as Char); + + const updatedCell: Cell = { + ...selectedCell, + guess: key as Char, + }; + + const updatedCells = mergeCell(updatedCell, cells); + + // overwrite the cell's value + cellsActionUpdateGrid(updatedCells); + + // if all cells are populated, mark clue as answered + selectedCell.clueIds.forEach((clueId) => { + const clue = clues.find((c) => c.id === clueId)!; + const populated = isCluePopulated(clue, updatedCells); + + if (populated) { + cluesActionAnswerOne(clue.group); + } + }); + + moveNext(); + + updateGuesses(updatedCells); + } else { + // prevent keys scrolling page + event.preventDefault(); + } + }; + + const dimensions = + selectedCell !== undefined ? getDimensions(selectedCell?.pos) : undefined; + + return ( +
+ {isLoading ? ( + + ) : ( + <> + + { + event.preventDefault(); + document.querySelectorAll('.Grid')?.[0]?.blur(); + }} + width={width} + height={height} + x="0" + y="0" + /> + {cells.map(({ clueIds, guess, num, pos }) => { + const isSelected = cellPositionMatches(pos, selectedCell?.pos); + const isHighlighted = clueIds.some((clueId) => + selectedClue?.group.includes(clueId), + ); + + const selectedClueIndex = + selectedClue !== undefined + ? clueIds.indexOf(selectedClue.id) + : -1; + + return ( + + ); + })} + + +
+ +
+ + )} +
+ ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridCell/GridCell.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridCell/GridCell.tsx new file mode 100644 index 000000000..746cd45b6 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridCell/GridCell.tsx @@ -0,0 +1,157 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; +import { useCellsContext } from '../../context/CellsContext'; +import { useCluesContext } from '../../context/CluesContext'; +import type { CellFocus, CellPosition, Char } from '../../interfaces'; + +export const cellSize = 31; + +export const getDimensions = (cellPos: CellPosition) => { + const xRect = 1 + (cellSize + 1) * cellPos.col; + const yRect = 1 + (cellSize + 1) * cellPos.row; + const xNum = xRect + 1; + const yNum = yRect + 9; + const xText = xRect + cellSize * 0.5; + const yText = yRect + cellSize * 0.675; + + return { xRect, yRect, xNum, yNum, xText, yText }; +}; + +// Define base colors and styles +const gridColor = '#000'; +const gridForeground = '#fff'; +const fontSizeSmall = '12px'; +const fontSizeLarge = '16px'; +const themeBlue100 = '#bbdefb'; +const themeBlue700 = '#1976d2'; + +// Emotion styles +const baseCellStyle = css` + fill: ${gridColor}; + user-select: none; +`; + +const rectBaseStyle = css` + cursor: pointer; + fill: ${gridForeground}; +`; + +const textBaseStyle = css` + font-size: ${fontSizeLarge}; + cursor: text; + text-anchor: middle; +`; + +const numberBaseStyle = css` + font-size: ${fontSizeSmall}; + cursor: pointer; +`; + +const selectedTextStyle = css` + font-weight: bold; + fill: ${gridForeground}; +`; + +const highlightedStyle = css` + fill: ${themeBlue100}; +`; + +const selectedStyle = css` + fill: ${themeBlue700}; +`; + +interface GridCellProps { + clueIds: string[]; + guess?: Char; + inputRef?: React.RefObject; + isHighlighted: boolean; + isSelected: boolean; + num?: number; + onCellFocus?: (cellFocus: CellFocus) => void; + pos: CellPosition; + selectedClueIndex: number; +} + +function GridCell({ + clueIds, + guess, + inputRef, + isHighlighted, + isSelected, + num, + onCellFocus, + pos, + selectedClueIndex, +}: GridCellProps) { + if (clueIds.length !== 1 && clueIds.length !== 2) { + throw new Error( + 'Crossword data error: cell does not have 1 or 2 directions', + ); + } + + const { select: cellsActionSelect } = useCellsContext(); + const { select: cluesActionSelect } = useCluesContext(); + const { xRect, yRect, xNum, yNum, xText, yText } = getDimensions(pos); + + const cellFocus = (cellPos: CellPosition, clueId: string) => { + onCellFocus?.({ pos: cellPos, clueId }); + }; + + const updateSelectedCell = () => { + let index = selectedClueIndex === -1 ? 0 : selectedClueIndex; + if (clueIds.length === 2 && isSelected) { + index = selectedClueIndex === 0 ? 1 : 0; + } + + const clueId = clueIds[index]; + if (clueId) { + cluesActionSelect(clueId); + + if (!isSelected) { + cellsActionSelect(pos); + } + + if (!isSelected || clueIds.length === 2) { + cellFocus(pos, clueId); + } + } + + inputRef?.current?.focus({ preventScroll: true }); + }; + + return ( + + + {num && ( + + {num} + + )} + + {guess} + + + ); +} + +export default React.memo(GridCell); diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridError/GridError.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridError/GridError.tsx new file mode 100644 index 000000000..e325a8eb0 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridError/GridError.tsx @@ -0,0 +1,49 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; + +interface GridErrorProps { + message: string; +} + +// Define styles +const gridSize = 10; +const gridBackground = '#000'; +const gridForeground = '#fff'; + +const gridErrorStyle = css` + display: flex; + align-items: center; + justify-content: center; + width: 481px; + height: 481px; + padding: ${gridSize * 2}px; + background-color: ${gridBackground}; + color: ${gridForeground}; + text-align: center; + + @media (max-width: 576px) { + // Mobile breakpoint as per $breakpoint-xs + width: auto; + } +`; + +const titleStyle = css` + font-weight: normal; + margin: 0; +`; + +const subTitleStyle = css` + font-family: monospace; +`; + +export default function GridError({ message }: GridErrorProps) { + return ( +
+
+

Something went wrong

+

{message}

+
+
+ ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridInput/GridInput.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridInput/GridInput.tsx new file mode 100644 index 000000000..f263c39b1 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridInput/GridInput.tsx @@ -0,0 +1,57 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import * as React from 'react'; + +interface GridInputProps { + onChange: (event: React.ChangeEvent) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + visible: boolean; +} + +const fontSizeLarge = 16; + +const gridInputStyle = css` + width: 100%; + height: 100%; + background-color: transparent; + border: 0; + padding: 0; + text-align: center; + font-size: ${fontSizeLarge * 1.3}px; + overflow: hidden; + caret-color: #fff; +`; + +const inclusivelyHiddenStyle = css` + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +`; + +const GridInput = React.forwardRef( + ({ onChange, onKeyDown, visible }, ref) => { + return ( + + ); + }, +); + +export default GridInput; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridSeparators/GridSeparators.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridSeparators/GridSeparators.tsx new file mode 100644 index 000000000..b24c6187c --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/GridSeparators/GridSeparators.tsx @@ -0,0 +1,99 @@ +// @ts-nocheck + +import * as React from 'react'; +import { cellSize } from '../index'; +import { Direction, GuardianClue } from '../../interfaces'; + +function getPos(val: number) { + return val * (cellSize + 1) + 1; +} + +interface GridSeparatorProps { + char: ',' | '-'; + col: number; + row: number; + direction: Direction; +} + +function GridSeparator({ char, col, row, direction }: GridSeparatorProps) { + const top = getPos(row); + const left = getPos(col); + const across = direction === 'across'; + + if (char === ',') { + const width = across ? 1 : cellSize; + const height = across ? cellSize : 1; + const x = across ? left - 2 : left; + const y = across ? top : top - 2; + + return ; + } + + if (char === '-') { + const width = across ? cellSize * 0.25 : 1; + const height = across ? 1 : cellSize * 0.25; + const x = across + ? left - 0.5 - width * 0.5 + : left + cellSize * 0.5 + width * 0.5; + const y = across + ? top + cellSize * 0.5 + height * 0.5 + : top - 0.5 - height * 0.5; + + return ; + } + + return <>; +} + +function getGridSeparator(char: ',' | '-', pos: number, clue: GuardianClue) { + // don't show separators between split words i.e. in a group + if (pos <= 0 || pos >= clue.length) { + return null; + } + + const x = clue.position.x + (clue.direction === 'across' ? pos : 0); + const y = clue.position.y + (clue.direction === 'across' ? 0 : pos); + return ( + + ); +} + +interface GridSeparatorsProps { + clues: GuardianClue[]; +} + +function GridSeparators({ clues }: GridSeparatorsProps) { + return ( + + {clues + .filter((clue) => Object.keys(clue.separatorLocations).length > 0) + .map((clue) => { + const separators = []; + const commas = clue.separatorLocations[',']; + const hyphens = clue.separatorLocations['-']; + + if (commas !== undefined) { + separators.push( + commas.map((pos) => getGridSeparator(',', pos, clue)), + ); + } + + if (hyphens !== undefined) { + separators.push( + hyphens.map((pos) => getGridSeparator('-', pos, clue)), + ); + } + + return separators; + })} + + ); +} + +export default React.memo(GridSeparators); diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/MyCrossword/MyCrossword.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/MyCrossword/MyCrossword.tsx new file mode 100644 index 000000000..d3110f50e --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/MyCrossword/MyCrossword.tsx @@ -0,0 +1,104 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; +import type { FC } from 'react'; +import { Crossword } from '../index'; +import { GameProvider } from '../../context/GameProvider'; +import { DEFAULT_CELL_MATCHER, DEFAULT_HTML_TAGS } from '../../utils/general'; +import type { + CellChange, + CellFocus, + GuardianCrossword, + GuessGrid, +} from '../../interfaces'; + +export interface CrosswordPlayerProps { + allowedHtmlTags?: string[]; + allowMissingSolutions?: boolean; + cellMatcher?: RegExp; + className?: string; + data: GuardianCrossword; + id: string; + loadGrid?: GuessGrid; + onCellChange?: (cellChange: CellChange) => void; + onCellFocus?: (cellFocus: CellFocus) => void; + saveGrid?: (value: GuessGrid | ((val: GuessGrid) => GuessGrid)) => void; + stickyClue?: 'always' | 'never' | 'auto'; +} + +// Define the Emotion CSS styles +const gridSize = 10; +const fontFamily = `'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif`; +const fontSize = 14; +const scrollbarTrackBackground = '#f1f1f1'; +const scrollbarThumbBackground = '#c1c1c1'; +const scrollbarThumbHover = '#a8a8a8'; + +const myCrosswordStyle = css` + font-family: ${fontFamily}; + font-size: ${fontSize}px; + -webkit-font-smoothing: subpixel-antialiased; + box-sizing: content-box; + + sup, + sub { + vertical-align: baseline; + position: relative; + top: -0.4em; + } + + sub { + top: 0.4em; + } + + ::-webkit-scrollbar { + width: ${gridSize * 0.9}px; + } + + ::-webkit-scrollbar-track { + background: ${scrollbarTrackBackground}; + } + + ::-webkit-scrollbar-thumb { + background-color: ${scrollbarThumbBackground}; + + &:hover { + background-color: ${scrollbarThumbHover}; + } + } +`; + +export const MyCrossword: FC = ({ + allowedHtmlTags = DEFAULT_HTML_TAGS, + allowMissingSolutions = false, + cellMatcher = DEFAULT_CELL_MATCHER, + className, + data, + id, + loadGrid, + onCellChange, + onCellFocus, + saveGrid, + stickyClue = 'auto', +}: CrosswordPlayerProps) => { + return ( + <> + +
+ +
+
+ + ); +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Spinner/Spinner.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Spinner/Spinner.tsx new file mode 100644 index 000000000..ced1efcbb --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/Spinner/Spinner.tsx @@ -0,0 +1,53 @@ +// @ts-nocheck + +import { css, keyframes } from '@emotion/react'; + +interface SpinnerProps { + size: 'small' | 'standard' | 'large'; +} + +const gridSize = 10; + +const spinAnimation = keyframes` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +`; + +// Emotion CSS styles +const baseSpinnerStyle = css` + border: ${gridSize * 0.4}px solid rgba(92, 112, 128, 0.2); + border-radius: 50%; + border-top: ${gridSize * 0.4}px solid rgba(92, 112, 128, 0.8); + animation: ${spinAnimation} 0.5s linear infinite; + box-sizing: border-box; +`; + +// Size variants +const sizeStyles = { + small: css` + width: ${gridSize * 2}px; + height: ${gridSize * 2}px; + min-width: ${gridSize * 2}px; + min-height: ${gridSize * 2}px; + `, + standard: css` + width: ${gridSize * 5}px; + height: ${gridSize * 5}px; + min-width: ${gridSize * 5}px; + min-height: ${gridSize * 5}px; + `, + large: css` + width: ${gridSize * 10}px; + height: ${gridSize * 10}px; + min-width: ${gridSize * 10}px; + min-height: ${gridSize * 10}px; + `, +}; + +export default function Spinner({ size }: SpinnerProps) { + return
; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/StickyClue/StickyClue.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/StickyClue/StickyClue.tsx new file mode 100644 index 000000000..a028eb4b0 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/StickyClue/StickyClue.tsx @@ -0,0 +1,144 @@ +// @ts-nocheck + +import { css } from '@emotion/react'; + +// Define styling constants +const gridSize = 10; +const pageBackground = '#fff'; +const gridBackground = '#000'; +const borderRadius = 2; + +// Emotion styles +const stickyClueStyle = css` + display: flex; + align-items: center; + position: sticky; + top: 0; + padding: ${gridSize}px 0; + background-color: ${pageBackground}; + border-bottom: 1px solid ${gridBackground}; + height: ${gridSize * 3}px; + max-height: ${gridSize * 3}px; + line-height: 1.3; + z-index: 1; +`; + +const innerStyle = css` + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + flex-grow: 1; + padding: 0 ${gridSize * 0.5}px; +`; + +const numStyle = css` + font-weight: bold; + margin-right: ${gridSize}px; +`; + +const buttonStyle = css` + display: flex; + align-items: center; + justify-content: center; + height: 100%; + background: none; + border: none; + cursor: pointer; + margin: 0; + padding: 0 ${gridSize * 0.5}px; + border-radius: ${borderRadius}px; + + &:hover { + background: rgba(0, 0, 0, 0.08); + } +`; + +interface ChevronIconProps { + className?: string; +} + +function ChevronLeftIcon({ className }: ChevronIconProps) { + return ( + + + + ); +} + +function ChevronRightIcon({ className }: ChevronIconProps) { + return ( + + + + ); +} + +interface StickyClueProps { + allowedHtmlTags: string[]; + num?: string; + onMoveNext: () => void; + onMovePrev: () => void; + text?: string; +} + +export default function StickyClue({ + allowedHtmlTags, + num, + onMoveNext, + onMovePrev, + text, +}: StickyClueProps) { + return ( +
+ {text !== undefined && num !== undefined ? ( + <> + +
+ + {num} + + +
+ + + ) : null} +
+ ); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/index.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/index.ts new file mode 100644 index 000000000..122d97426 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/components/index.ts @@ -0,0 +1,20 @@ +// @ts-nocheck + +export { default as AnagramHelper } from './AnagramHelper/AnagramHelper'; +export { default as Button } from './Button/Button'; +export { default as Clue } from './Clue/Clue'; +export { default as Clues } from './Clues/Clues'; +export { default as Confirm } from './Confirm/Confirm'; +export { default as Controls } from './Controls/Controls'; +export { default as Crossword } from './Crossword/Crossword'; +export { MyCrossword } from './MyCrossword/MyCrossword'; +export { default as DropdownButton } from './DropdownButton/DropdownButton'; +export { default as Grid } from './Grid/Grid'; +export { default as GridCell } from './GridCell/GridCell'; +export { default as GridError } from './GridError/GridError'; +export { default as GridSeparators } from './GridSeparators/GridSeparators'; +export { default as Spinner } from './Spinner/Spinner'; +export { default as StickyClue } from './StickyClue/StickyClue'; +export * from './GridCell/GridCell'; + +export type { CrosswordPlayerProps } from './MyCrossword/MyCrossword'; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/CellsContext.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/CellsContext.tsx new file mode 100644 index 000000000..aa1fc7244 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/CellsContext.tsx @@ -0,0 +1,144 @@ +// @ts-nocheck + +import React, { + createContext, + type ReactElement, + useCallback, + useContext, + useMemo, + useReducer, +} from 'react'; +import { Cell, CellPosition } from '../interfaces'; + +// Define the initial state +const initialState = { + cells: [], +}; + +// Define the CellsState type +export interface CellsState { + cells: Cell[]; +} + +type CellsAction = + | { + type: 'CLEAR_GRID'; + } + | { + type: 'REVEAL_GRID'; + } + | { + type: 'SELECT_CELL'; + payload: CellPosition; + } + | { + type: 'UPDATE_GRID'; + payload: Cell[]; + }; +// Define action types +const CLEAR_GRID = 'CLEAR_GRID'; +const REVEAL_GRID = 'REVEAL_GRID'; +const SELECT_CELL = 'SELECT_CELL'; +const UPDATE_GRID = 'UPDATE_GRID'; + +// Reducer function +function cellsReducer(state: CellsState, action: CellsAction): CellsState { + switch (action.type) { + case CLEAR_GRID: + return { + ...state, + cells: state.cells.map((cell) => ({ + ...cell, + guess: undefined, + })), + }; + case REVEAL_GRID: + return { + ...state, + cells: state.cells.map((cell) => ({ + ...cell, + guess: cell.val, + })), + }; + case SELECT_CELL: + return { + ...state, + cells: state.cells.map((cell) => ({ + ...cell, + selected: + cell.pos.col === action.payload.col && + cell.pos.row === action.payload.row, + })), + }; + case UPDATE_GRID: + return { + ...state, + cells: action.payload, + }; + default: + return state; + } +} + +// Create the context +const CellsContext = createContext<{ + state: CellsState; + clearGrid: () => void; + revealGrid: () => void; + select: (position: CellPosition) => void; + updateGrid: (cells: Cell[]) => void; +} | null>(null); + +// Context provider component +export const CellsProvider: React.FC<{ children: ReactElement }> = ({ + children, +}) => { + const [state, dispatch] = useReducer(cellsReducer, initialState); + + // Memoize action functions with useCallback + const clearGrid = useCallback( + () => dispatch({ type: CLEAR_GRID }), + [dispatch], + ); + const revealGrid = useCallback( + () => dispatch({ type: REVEAL_GRID }), + [dispatch], + ); + const select = useCallback( + (position: CellPosition) => + dispatch({ + type: SELECT_CELL, + payload: position, + }), + [dispatch], + ); + const updateGrid = useCallback( + (cells: Cell[]) => dispatch({ type: UPDATE_GRID, payload: cells }), + [dispatch], + ); + + // Memoize context value to avoid unnecessary re-renders + const contextValue = useMemo( + () => ({ + state, + clearGrid, + revealGrid, + select, + updateGrid, + }), + [state, clearGrid, revealGrid, select, updateGrid], + ); + return ( + + {children} + + ); +}; + +export const useCellsContext = () => { + const context = useContext(CellsContext); + if (!context) { + throw new Error('useCluesContext must be used within a CluesProvider'); + } + return context; +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/CluesContext.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/CluesContext.tsx new file mode 100644 index 000000000..519a67a77 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/CluesContext.tsx @@ -0,0 +1,186 @@ +// @ts-nocheck + +import React, { + createContext, + type ReactElement, + useContext, + useMemo, + useReducer, +} from 'react'; +import { Clue } from '../interfaces'; + +// Define the initial state +const initialState = { + clues: [], +}; + +// Define CluesState type +export interface CluesState { + clues: Clue[]; +} + +type CluesAction = + | { + type: 'ANSWER_GRID'; + } + | { + type: 'ANSWER_ONE'; + payload: string[]; + } + | { + type: 'SELECT_CLUE'; + payload: string; + } + | { + type: 'UNANSWER_GRID'; + } + | { + type: 'UNANSWER_ONE'; + payload: string[]; + } + | { + type: 'UPDATE_GRID'; + payload: Clue[]; + }; + +const ANSWER_GRID = 'ANSWER_GRID'; +const ANSWER_ONE = 'ANSWER_ONE'; +const SELECT_CLUE = 'SELECT_CLUE'; +const UNANSWER_GRID = 'UNANSWER_GRID'; +const UNANSWER_ONE = 'UNANSWER_ONE'; +const UPDATE_GRID = 'UPDATE_GRID'; + +// Reducer function +function cluesReducer(state: CluesState, action: CluesAction): CluesState { + switch (action.type) { + case ANSWER_GRID: + return { + ...state, + clues: state.clues.map((clue) => ({ + ...clue, + answered: true, + })), + }; + case ANSWER_ONE: + return { + ...state, + clues: state.clues.map((clue) => + action.payload.includes(clue.id) ? { ...clue, answered: true } : clue, + ), + }; + case SELECT_CLUE: + // Update URL + window.history.replaceState(null, '', `#${action.payload}`); + return { + ...state, + clues: state.clues.map((clue) => ({ + ...clue, + selected: clue.id === action.payload, + })), + }; + case UNANSWER_GRID: + return { + ...state, + clues: state.clues.map((clue) => ({ + ...clue, + answered: false, + })), + }; + case UNANSWER_ONE: + return { + ...state, + clues: state.clues.map((clue) => + action.payload.includes(clue.id) + ? { ...clue, answered: false } + : clue, + ), + }; + case UPDATE_GRID: + return { + ...state, + clues: action.payload, + }; + default: + return state; + } +} + +// Create Context +const CluesContext = createContext<{ + state: CluesState; + answerGrid: () => void; + answerOne: (ids: string[]) => void; + select: (id: string) => void; + unanswerGrid: () => void; + unanswerOne: (ids: string[]) => void; + updateGrid: (clues: Clue[]) => void; +} | null>(null); + +// Context Provider Component +export const CluesProvider: React.FC<{ children: ReactElement }> = ({ + children, +}) => { + const [state, dispatch] = useReducer(cluesReducer, initialState); + + // Action functions using useCallback to memoize + const answerGrid = React.useCallback( + () => dispatch({ type: ANSWER_GRID }), + [], + ); + const answerOne = React.useCallback( + (ids: string[]) => dispatch({ type: ANSWER_ONE, payload: ids }), + [], + ); + const select = React.useCallback( + (id: string) => dispatch({ type: SELECT_CLUE, payload: id }), + [], + ); + const unanswerGrid = React.useCallback( + () => dispatch({ type: UNANSWER_GRID }), + [], + ); + const unanswerOne = React.useCallback( + (ids: string[]) => dispatch({ type: UNANSWER_ONE, payload: ids }), + [], + ); + const updateGrid = React.useCallback( + (clues: Clue[]) => dispatch({ type: UPDATE_GRID, payload: clues }), + [], + ); + + // Memoize context value to prevent unnecessary re-renders + const contextValue = useMemo( + () => ({ + state, + answerGrid, + answerOne, + select, + unanswerGrid, + unanswerOne, + updateGrid, + }), + [ + state, + answerGrid, + answerOne, + select, + unanswerGrid, + unanswerOne, + updateGrid, + ], + ); + + return ( + + {children} + + ); +}; + +export const useCluesContext = () => { + const context = useContext(CluesContext); + if (!context) { + throw new Error('useCluesContext must be used within a CluesProvider'); + } + return context; +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/GameProvider.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/GameProvider.tsx new file mode 100644 index 000000000..6289e97bd --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/context/GameProvider.tsx @@ -0,0 +1,17 @@ +// @ts-nocheck + +import type React from 'react'; +import type { ReactElement } from 'react'; +import { CellsProvider } from './CellsContext'; +import { CluesProvider } from './CluesContext'; + +// Combined GameProvider to provide both CellsContext and CluesContext +export const GameProvider: React.FC<{ children: ReactElement }> = ({ + children, +}) => { + return ( + + {children} + + ); +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/index.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/index.ts new file mode 100644 index 000000000..b98fd5e13 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/index.ts @@ -0,0 +1,5 @@ +// @ts-nocheck + +export { default as useBreakpoint } from './useBreakpoint/useBreakpoint'; +export { default as useDebounce } from './useDebounce/useDebounce'; +export { default as useLocalStorage } from './useLocalStorage/useLocalStorage'; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useBreakpoint/useBreakpoint.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useBreakpoint/useBreakpoint.ts new file mode 100644 index 000000000..7a45de721 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useBreakpoint/useBreakpoint.ts @@ -0,0 +1,46 @@ +// @ts-nocheck + +import * as React from 'react'; + +interface Breakpoint { + name: string; + max: number; +} + +const breakpoints: Breakpoint[] = [ + { name: 'xs', max: 576 }, + { name: 'sm', max: 768 }, + { name: 'md', max: 992 }, + { name: 'lg', max: 1200 }, + { name: 'xl', max: 1400 }, + { name: 'xxl', max: 99999 }, +]; + +export default function useBreakpoint() { + const [breakpoint, setBreakPoint] = React.useState(); + const [windowWidth, setWindowWidth] = React.useState(); + + const handleResize = () => { + setWindowWidth(window.innerWidth); + }; + + React.useEffect(() => { + window.addEventListener('resize', handleResize); + handleResize(); + + if (windowWidth !== undefined) { + // set the breakpoint to the first match (smallest first) + const matches = breakpoints.filter((bp) => windowWidth < bp.max); + if (matches.length > 0) { + setBreakPoint(matches[0]?.name); + } else { + setBreakPoint(undefined); + } + } + + return function cleanup() { + window.removeEventListener('resize', handleResize); + }; + }, [windowWidth]); + return breakpoint; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useDebounce/useDebounce.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useDebounce/useDebounce.ts new file mode 100644 index 000000000..4a657347c --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useDebounce/useDebounce.ts @@ -0,0 +1,20 @@ +// @ts-nocheck + +import * as React from 'react'; + +export default function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + React.useEffect(() => { + // update debounced value after delay + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useLocalStorage/useLocalStorage.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useLocalStorage/useLocalStorage.ts new file mode 100644 index 000000000..ef6b7244b --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/hooks/useLocalStorage/useLocalStorage.ts @@ -0,0 +1,26 @@ +// @ts-nocheck + +import * as React from 'react'; + +export default function useLocalStorage(key: string, initialValue?: T) { + const [storedValue, setStoredValue] = React.useState(() => { + try { + const item = window.localStorage.getItem(key); + return item !== null ? JSON.parse(item) : initialValue; + } catch (error: unknown) { + return initialValue; + } + }); + + // return a wrapped version of useState's setter function that + // persists the new value to localStorage. + const setValue = (value: T | ((val: T) => T)) => { + // allow value to be a function so we have same API as useState + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + }; + + return [storedValue, setValue] as const; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/index.tsx b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/index.tsx new file mode 100644 index 000000000..dbab4a467 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/index.tsx @@ -0,0 +1,11 @@ +// @ts-nocheck + +import type { CrosswordPlayerProps } from './components'; +import { MyCrossword } from './components'; + +export type { GuardianCrossword } from './interfaces'; +export type { CrosswordPlayerProps } from './components'; + +export const CrosswordPlayer = (props: CrosswordPlayerProps) => { + return ; +}; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Cell.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Cell.ts new file mode 100644 index 000000000..7fc2c3e39 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Cell.ts @@ -0,0 +1,12 @@ +// @ts-nocheck + +import { CellPosition, Char } from './index'; + +export default interface Cell { + clueIds: string[]; + guess?: Char; + num?: number; + pos: CellPosition; + selected: boolean; + val: Char; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellChange.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellChange.ts new file mode 100644 index 000000000..96668981e --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellChange.ts @@ -0,0 +1,9 @@ +// @ts-nocheck + +import type { CellPosition, Char } from './index'; + +export default interface CellChange { + pos: CellPosition; + guess?: Char; + previousGuess?: Char; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellFocus.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellFocus.ts new file mode 100644 index 000000000..04e23b512 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellFocus.ts @@ -0,0 +1,8 @@ +// @ts-nocheck + +import type { CellPosition } from './index'; + +export default interface CellFocus { + pos: CellPosition; + clueId: string; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellPosition.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellPosition.ts new file mode 100644 index 000000000..e8fb4fd6d --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/CellPosition.ts @@ -0,0 +1,6 @@ +// @ts-nocheck + +export default interface CellPosition { + col: number; + row: number; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Char.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Char.ts new file mode 100644 index 000000000..eabe0abe7 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Char.ts @@ -0,0 +1,41 @@ +// @ts-nocheck + +type Char = + | 'A' + | 'B' + | 'C' + | 'D' + | 'E' + | 'F' + | 'G' + | 'H' + | 'I' + | 'J' + | 'K' + | 'L' + | 'M' + | 'N' + | 'O' + | 'P' + | 'Q' + | 'R' + | 'S' + | 'T' + | 'U' + | 'V' + | 'W' + | 'X' + | 'Y' + | 'Z' + | '0' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + | ''; +export default Char; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Clue.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Clue.ts new file mode 100644 index 000000000..80b2e49b2 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Clue.ts @@ -0,0 +1,8 @@ +// @ts-nocheck + +import GuardianClue from './GuardianClue'; + +export default interface Clue extends GuardianClue { + answered: boolean; + selected: boolean; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Direction.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Direction.ts new file mode 100644 index 000000000..5f7e4c9ed --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/Direction.ts @@ -0,0 +1,4 @@ +// @ts-nocheck + +type Direction = 'across' | 'down'; +export default Direction; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuardianClue.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuardianClue.ts new file mode 100644 index 000000000..d2f581815 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuardianClue.ts @@ -0,0 +1,16 @@ +// @ts-nocheck + +import { Direction, SeparatorLocationsOptional } from './index'; + +export default interface GuardianClue { + clue: string; + direction: Direction; + group: string[]; + humanNumber: string; + id: string; + length: number; + number: number; + position: { x: number; y: number }; + separatorLocations: SeparatorLocationsOptional; + solution?: string; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuardianCrossword.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuardianCrossword.ts new file mode 100644 index 000000000..970747d1e --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuardianCrossword.ts @@ -0,0 +1,30 @@ +// @ts-nocheck + +import type GuardianClue from './GuardianClue'; + +export default interface GuardianCrossword { + creator?: { + name: string; + webUrl: string; + }; + crosswordType: + | 'cryptic' + | 'quick' + | 'quiptic' + | 'speedy' + | 'prize' + | 'everyman'; + date: number; + dateSolutionAvailable?: number; + dimensions: { + cols: number; + rows: number; + }; + entries: GuardianClue[]; + id: string; + name: string; + number: number; + pdf?: string; + solutionAvailable: boolean; + webPublicationDate?: number; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuessGrid.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuessGrid.ts new file mode 100644 index 000000000..bcef99214 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/GuessGrid.ts @@ -0,0 +1,7 @@ +// @ts-nocheck + +import { Char } from './index'; + +export default interface GuessGrid { + value: Char[][]; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/SeparatorLocations.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/SeparatorLocations.ts new file mode 100644 index 000000000..b277c3f73 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/SeparatorLocations.ts @@ -0,0 +1,4 @@ +// @ts-nocheck + +export type SeparatorLocations = { [key in ',' | '-']: number[] }; +export type SeparatorLocationsOptional = { [key in ',' | '-']?: number[] }; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/index.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/index.ts new file mode 100644 index 000000000..61066781e --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/interfaces/index.ts @@ -0,0 +1,14 @@ +// @ts-nocheck + +export type { default as Cell } from './Cell'; +export type { default as CellChange } from './CellChange'; +export type { default as CellPosition } from './CellPosition'; +export type { default as CellFocus } from './CellFocus'; +export type { default as Direction } from './Direction'; +export type { default as Clue } from './Clue'; +export type { default as Char } from './Char'; +export type { CrosswordData as GuardianCrossword } from '../../../@types/crossword'; +export type { default as GuardianClue } from './GuardianClue'; +export type { default as GuessGrid } from './GuessGrid'; + +export * from './SeparatorLocations'; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/react-app-env.d.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/react-app-env.d.ts new file mode 100644 index 000000000..2ae0f4361 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/react-app-env.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck + +/// diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.incomplete.1.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.incomplete.1.ts new file mode 100644 index 000000000..bbb10312b --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.incomplete.1.ts @@ -0,0 +1,90 @@ +// @ts-nocheck + +import type { GuardianCrossword } from '../interfaces'; + +const data: GuardianCrossword = { + id: 'test/valid/1', + number: 1, + name: 'Valid Test 1', + date: 1542326400000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Toy on a string (2-2)', + direction: 'across', + length: 4, + group: ['1-across'], + position: { x: 0, y: 0 }, + separatorLocations: { + '-': [2], + }, + solution: 'YOY', + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Have a rest (3,4)', + direction: 'across', + length: 7, + group: ['4-across'], + position: { x: 0, y: 2 }, + separatorLocations: { + ',': [3], + }, + solution: 'LIEDOWN', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Colour (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { x: 0, y: 0 }, + separatorLocations: {}, + solution: '', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Bits and bobs (4,3,4)', + direction: 'down', + length: 7, + group: ['2-down', '3-down'], + position: { x: 3, y: 0 }, + separatorLocations: { + ',': [4, 7], + }, + solution: 'ODDSAND', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + solution: 'ENDS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1542326400000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'quick', +}; + +export default data; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.1.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.1.ts new file mode 100644 index 000000000..9dc581981 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.1.ts @@ -0,0 +1,90 @@ +// @ts-nocheck + +import type { GuardianCrossword } from '../interfaces'; + +const data: GuardianCrossword = { + id: 'test/invalid/1', + number: 1, + name: 'Invalid Test 1', + date: 1542326400000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Toy on a string (2-2)', + direction: 'across', + length: 3, // <--------------- INVALID LENGTH + group: ['1-across'], + position: { x: 0, y: 0 }, + separatorLocations: { + '-': [2], + }, + solution: 'YOYO', + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Have a rest (3,4)', + direction: 'across', + length: 7, + group: ['4-across'], + position: { x: 0, y: 2 }, + separatorLocations: { + ',': [3], + }, + solution: 'LIEDOWN', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Colour (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { x: 0, y: 0 }, + separatorLocations: {}, + solution: 'YELLOW', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Bits and bobs (4,3,4)', + direction: 'down', + length: 7, + group: ['2-down', '3-down'], + position: { x: 3, y: 0 }, + separatorLocations: { + ',': [4], + }, + solution: 'ODDSAND', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + solution: 'ENDS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1542326400000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'quick', +}; + +export default data; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.2.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.2.ts new file mode 100644 index 000000000..1a1dbe69d --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.2.ts @@ -0,0 +1,90 @@ +// @ts-nocheck + +import type { GuardianCrossword } from '../interfaces'; + +const data: GuardianCrossword = { + id: 'test/invalid/2', + number: 2, + name: 'Invalid Test 2', + date: 1542326400000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Toy on a string (2-2)', + direction: 'across', + length: 4, + group: ['1-across'], + position: { x: 0, y: 0 }, + separatorLocations: { + '-': [2], + }, + solution: 'YOYO', + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Have a rest (3,4)', + direction: 'across', + length: 7, + group: ['4-across'], + position: { x: 0, y: 2 }, + separatorLocations: { + ',': [3], + }, + solution: 'LIEDOWN', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Colour (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { x: 0, y: 0 }, + separatorLocations: {}, + solution: 'YELLOW', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Bits and bobs (4,3,4)', + direction: 'down', + length: 7, + group: ['2-down', '3-down'], + position: { x: 3, y: 0 }, + separatorLocations: { + ',': [4], + }, + solution: 'ODDSAND', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + solution: 'ENDS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1542326400000, + dimensions: { + cols: 3, // <--------------- INVALID GRID SIZE + rows: 3, + }, + crosswordType: 'quick', +}; + +export default data; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.3.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.3.ts new file mode 100644 index 000000000..d9352d1b8 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.3.ts @@ -0,0 +1,90 @@ +// @ts-nocheck + +import type { GuardianCrossword } from '../interfaces'; + +const data: GuardianCrossword = { + id: 'test/invalid/3', + number: 3, + name: 'Invalid Test 3', + date: 1542326400000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Toy on a string (2-2)', + direction: 'across', + length: 4, + group: ['1-across'], + position: { x: 0, y: 0 }, + separatorLocations: { + '-': [2], + }, + solution: 'YOYO', + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Have a rest (3,4)', + direction: 'across', + length: 7, + group: ['4-across'], + position: { x: 0, y: 2 }, + separatorLocations: { + ',': [3], + }, + solution: 'LIEDOWN', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Colour (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { x: 0, y: 0 }, + separatorLocations: {}, + solution: 'XELLOW', // <--------------- INVALID CHARACTER + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Bits and bobs (4,3,4)', + direction: 'down', + length: 7, + group: ['2-down', '3-down'], + position: { x: 3, y: 0 }, + separatorLocations: { + ',': [4], + }, + solution: 'ODDSAND', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + solution: 'ENDS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1542326400000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'quick', +}; + +export default data; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.4.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.4.ts new file mode 100644 index 000000000..cd9dc4fe1 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.4.ts @@ -0,0 +1,102 @@ +// @ts-nocheck + +import type { GuardianCrossword } from '../interfaces'; + +const data: GuardianCrossword = { + id: 'test/invalid/4', + number: 4, + name: 'Invalid Test 4', + date: 1542326400000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Toy on a string (2-2)', + direction: 'across', + length: 4, + group: ['1-across'], + position: { x: 0, y: 0 }, + separatorLocations: { + '-': [2], + }, + solution: 'YOYO', + }, + { + id: '99-across', + number: 99, + humanNumber: '99', + clue: 'Test', + direction: 'across', + length: 4, + group: ['99-across'], + position: { x: 2, y: 0 }, // <--------------- INVALID CLUE POSITION + separatorLocations: {}, + solution: 'YOYO', + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Have a rest (3,4)', + direction: 'across', + length: 7, + group: ['4-across'], + position: { x: 0, y: 2 }, + separatorLocations: { + ',': [3], + }, + solution: 'LIEDOWN', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Colour (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { x: 0, y: 0 }, + separatorLocations: {}, + solution: 'YELLOW', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Bits and bobs (4,3,4)', + direction: 'down', + length: 7, + group: ['2-down', '3-down'], + position: { x: 3, y: 0 }, + separatorLocations: { + ',': [4], + }, + solution: 'ODDSAND', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + solution: 'ENDS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1542326400000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'quick', +}; + +export default data; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.5.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.5.ts new file mode 100644 index 000000000..eff556a14 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.5.ts @@ -0,0 +1,102 @@ +// @ts-nocheck + +import type { GuardianCrossword } from '../interfaces'; + +const data: GuardianCrossword = { + id: 'test/invalid/5', + number: 5, + name: 'Invalid Test 5', + date: 1542326400000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Toy on a string (2-2)', + direction: 'across', + length: 4, + group: ['1-across'], + position: { x: 0, y: 0 }, + separatorLocations: { + '-': [2], + }, + solution: 'YOYO', + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Have a rest (3,4)', + direction: 'across', + length: 7, + group: ['4-across'], + position: { x: 0, y: 2 }, + separatorLocations: { + ',': [3], + }, + solution: 'LIEDOWN', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Colour (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { x: 0, y: 0 }, + separatorLocations: {}, + solution: 'YELLOW', + }, + { + id: '99-down', + number: 99, + humanNumber: '99', + clue: 'Test', + direction: 'down', + length: 3, + group: ['99-down'], + position: { x: 0, y: 3 }, // <--------------- INVALID CLUE POSITION + separatorLocations: {}, + solution: 'LOW', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Bits and bobs (4,3,4)', + direction: 'down', + length: 7, + group: ['2-down', '3-down'], + position: { x: 3, y: 0 }, + separatorLocations: { + ',': [4], + }, + solution: 'ODDSAND', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + solution: 'ENDS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1542326400000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'quick', +}; + +export default data; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.6.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.6.ts new file mode 100644 index 000000000..54618d410 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.6.ts @@ -0,0 +1,90 @@ +// @ts-nocheck + +import type { GuardianCrossword } from '../interfaces'; + +const data: GuardianCrossword = { + id: 'test/invalid/6', + number: 6, + name: 'Invalid Test 6', + date: 1542326400000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Toy on a string (2-2)', + direction: 'across', + length: 4, + group: [], // <--------------- INVALID EMPTY GROUP + position: { x: 0, y: 0 }, + separatorLocations: { + '-': [2], + }, + solution: 'YOYO', + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Have a rest (3,4)', + direction: 'across', + length: 7, + group: ['4-across'], + position: { x: 0, y: 2 }, + separatorLocations: { + ',': [3], + }, + solution: 'LIEDOWN', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Colour (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { x: 0, y: 0 }, + separatorLocations: {}, + solution: 'YELLOW', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Bits and bobs (4,3,4)', + direction: 'down', + length: 7, + group: ['2-down', '3-down'], + position: { x: 3, y: 0 }, + separatorLocations: { + ',': [4, 7], + }, + solution: 'ODDSAND', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + solution: 'ENDS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1542326400000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'quick', +}; + +export default data; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.7.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.7.ts new file mode 100644 index 000000000..c21e1fb56 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.invalid.7.ts @@ -0,0 +1,90 @@ +// @ts-nocheck + +import type { GuardianCrossword } from '../interfaces'; + +const data: GuardianCrossword = { + id: 'test/invalid/7', + number: 7, + name: 'Invalid Test 7', + date: 1542326400000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Toy on a string (2-2)', + direction: 'across', + length: 4, + group: ['1-across'], + position: { x: 0, y: 0 }, + separatorLocations: { + '-': [2], + }, + solution: 'YOYO', + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Have a rest (3,4)', + direction: 'across', + length: 7, + group: ['4-across'], + position: { x: 0, y: 2 }, + separatorLocations: { + ',': [3], + }, + solution: 'LIEDOWN', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Colour (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { x: 0, y: 0 }, + separatorLocations: {}, + solution: 'YELLOW', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Bits and bobs (4,3,4)', + direction: 'down', + length: 7, + group: ['2-down', '99-down'], // <--------------- INVALID CLUE ID + position: { x: 3, y: 0 }, + separatorLocations: { + ',': [4, 7], + }, + solution: 'ODDSAND', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + solution: 'ENDS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1542326400000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'quick', +}; + +export default data; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.valid.1.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.valid.1.ts new file mode 100644 index 000000000..b8d37d623 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/testData/test.valid.1.ts @@ -0,0 +1,90 @@ +// @ts-nocheck + +import type { GuardianCrossword } from '../interfaces'; + +const data: GuardianCrossword = { + id: 'test/valid/1', + number: 1, + name: 'Valid Test 1', + date: 1542326400000, + entries: [ + { + id: '1-across', + number: 1, + humanNumber: '1', + clue: 'Toy on a string (2-2)', + direction: 'across', + length: 4, + group: ['1-across'], + position: { x: 0, y: 0 }, + separatorLocations: { + '-': [2], + }, + solution: 'YOYO', + }, + { + id: '4-across', + number: 4, + humanNumber: '4', + clue: 'Have a rest (3,4)', + direction: 'across', + length: 7, + group: ['4-across'], + position: { x: 0, y: 2 }, + separatorLocations: { + ',': [3], + }, + solution: 'LIEDOWN', + }, + { + id: '1-down', + number: 1, + humanNumber: '1', + clue: 'Colour (6)', + direction: 'down', + length: 6, + group: ['1-down'], + position: { x: 0, y: 0 }, + separatorLocations: {}, + solution: 'YELLOW', + }, + { + id: '2-down', + number: 2, + humanNumber: '2', + clue: 'Bits and bobs (4,3,4)', + direction: 'down', + length: 7, + group: ['2-down', '3-down'], + position: { x: 3, y: 0 }, + separatorLocations: { + ',': [4, 7], + }, + solution: 'ODDSAND', + }, + { + id: '3-down', + number: 3, + humanNumber: '3', + clue: 'See 2', + direction: 'down', + length: 4, + group: ['2-down', '3-down'], + position: { + x: 6, + y: 1, + }, + separatorLocations: {}, + solution: 'ENDS', + }, + ], + solutionAvailable: true, + dateSolutionAvailable: 1542326400000, + dimensions: { + cols: 13, + rows: 13, + }, + crosswordType: 'quick', +}; + +export default data; diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/cell.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/cell.ts new file mode 100644 index 000000000..209a6779b --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/cell.ts @@ -0,0 +1,147 @@ +// @ts-nocheck + +import { + Cell, + CellPosition, + Char, + GuardianClue, + GuessGrid, +} from '../interfaces'; + +export function mergeCell(newCell: Cell, cells: Cell[]) { + return cells.map((cell) => { + if (cell.pos.col === newCell.pos.col && cell.pos.row === newCell.pos.row) { + return newCell; + } + return cell; + }); +} + +function findByPos(cells: Cell[], cellPos: CellPosition) { + return cells.find( + (cell) => cell.pos.col === cellPos.col && cell.pos.row === cellPos.row, + ); +} + +export function blankNeighbours( + cells: Cell[], + currentCell: Cell, + across: boolean, +) { + const cellOne = findByPos(cells, { + col: currentCell.pos.col - (across ? 0 : 1), + row: currentCell.pos.row - (across ? 1 : 0), + }); + + const cellTwo = findByPos(cells, { + col: currentCell.pos.col + (across ? 0 : 1), + row: currentCell.pos.row + (across ? 1 : 0), + }); + + return cellOne?.guess === undefined && cellTwo?.guess === undefined; +} + +/** + * Transpose clue entries to cell array. + * @param cols + * @param rows + * @param entries + * @returns + */ +export function initialiseCells({ + cols, + rows, + entries, + guessGrid, + allowMissingSolutions = false, +}: { + cols: number; + rows: number; + entries: GuardianClue[]; + guessGrid?: GuessGrid; + allowMissingSolutions?: boolean; +}) { + const cells: Cell[] = []; + const entryIds = entries.map((entry) => entry.id); + + entries.forEach((entry) => { + for (let i = 0; i < entry.length; i += 1) { + const across = entry.direction === 'across'; + const col = across ? entry.position.x + i : entry.position.x; + const row = across ? entry.position.y : entry.position.y + i; + + // grid validation + if (col < 0 || col >= cols || row < 0 || row >= rows) { + throw new Error('Crossword data error: out of bounds'); + } + + if ( + !allowMissingSolutions && + entry.solution !== undefined && + entry.length !== entry.solution.length + ) { + throw new Error('Crossword data error: solution length mismatch'); + } + + if (!entry.group.includes(entry.id)) { + throw new Error('Crossword data error: clue id missing from group'); + } + + if (!entry.group.every((clueId) => entryIds.includes(clueId))) { + throw new Error('Crossword data error: group clue id not found'); + } + + // check if the cell already exists + const currentCell = cells.find( + ({ pos }) => pos.col === col && pos.row === row, + ); + + if (currentCell === undefined) { + const guess = guessGrid?.value[col][row]; + + // add cell + const newCell: Cell = { + clueIds: [entry.id], + guess: guess !== '' ? guess : undefined, + num: i === 0 ? entry.number : undefined, + pos: { col, row }, + selected: false, + val: entry.solution?.[i] as Char, + }; + + cells.push(newCell); + } else { + if ( + across && + currentCell.clueIds.some((clueId) => clueId.endsWith('across')) + ) { + throw new Error('Crossword data error: overlapping across solutions'); + } + + if ( + !across && + currentCell.clueIds.some((clueId) => clueId.endsWith('down')) + ) { + throw new Error('Crossword data error: overlapping down solutions'); + } + + const newChar = entry.solution?.[i]; + + if (!allowMissingSolutions && currentCell.val !== newChar) { + throw new Error('Crossword data error: solution character clash'); + } + + // merge cell + currentCell.num = i === 0 ? entry.number : currentCell.num; + currentCell.clueIds = [...currentCell.clueIds, entry.id]; + + // overwrite with new value + if (allowMissingSolutions && newChar !== undefined) { + currentCell.val = newChar as Char; + } + } + } + }); + + return cells; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/clue.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/clue.ts new file mode 100644 index 000000000..27634a985 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/clue.ts @@ -0,0 +1,85 @@ +// @ts-nocheck + +import type { + Cell, + Clue, + GuardianClue, + SeparatorLocations, +} from '../interfaces'; + +export function getGroupCells(groupIds: string[], cells: Cell[]) { + const groupCells: Cell[] = []; + + // get cells for each clueId in group array + groupIds.forEach((groupId) => { + const cellsForGroup = cells + .filter((cell) => cell.clueIds.includes(groupId)) + .sort( + (a: Cell, b: Cell) => a.pos.col - b.pos.col || a.pos.row - b.pos.row, + ); + + groupCells.push(...cellsForGroup); + }); + + return groupCells; +} + +export function getGroupSeparators(groupIds: string[], clues: Clue[]) { + const separators: SeparatorLocations = { ',': [], '-': [] }; + let total = 0; + + // combine separators for all clues in the group + groupIds.forEach((groupId) => { + const groupClue = clues.find((clue) => clue.id === groupId); + + if (groupClue !== undefined) { + const spaces = groupClue.separatorLocations[',']?.map( + (sep) => sep + total, + ); + separators[','] = [...separators[','], ...(spaces ?? [])]; + + const hyphens = groupClue.separatorLocations['-']?.map( + (sep) => sep + total, + ); + separators['-'] = [...separators['-'], ...(hyphens ?? [])]; + } + + total += groupClue !== undefined ? groupClue.length : 0; + }); + + return separators; +} + +export function isCluePopulated(clue: Clue, cells: Cell[]) { + const groupCells = getGroupCells(clue.group, cells); + const populatedCells = groupCells.filter((cell) => cell.guess !== undefined); + + return groupCells.length > 0 && groupCells.length === populatedCells.length; +} + +export function getCrossingClueIds(clue: Clue, cells: Cell[]) { + const clueIds: string[] = []; + const groupCells = getGroupCells(clue.group, cells); + + groupCells.forEach((cell) => { + clueIds.push(...cell.clueIds); + }); + + // remove duplicates + return Array.from(new Set(clueIds)); +} + +export function initialiseClues( + entries: GuardianClue[], + cells: Cell[], + selectedClueId?: string, +) { + return entries.map((entry) => ({ + ...entry, + answered: isCluePopulated( + { ...entry, selected: false, answered: false }, + cells, + ), + selected: entry.id === selectedClueId, + })); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/general.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/general.ts new file mode 100644 index 000000000..ddcf28de3 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/general.ts @@ -0,0 +1,36 @@ +// @ts-nocheck + +export const DEFAULT_HTML_TAGS = ['b', 'strong', 'i', 'em', 'sub', 'sup']; +export const DEFAULT_CELL_MATCHER = /[A-Z]/; + +export function isValidChar(char: string, matcher: RegExp) { + if (char.length !== 1) { + return false; + } + + return char.match(matcher); +} + +export function isInViewport(rect: DOMRect) { + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) + ); +} + +export function isInPerimeterRect(rect: DOMRect, perimeterRect: DOMRect) { + return ( + rect.top >= perimeterRect.top && + rect.left >= perimeterRect.left && + rect.right <= perimeterRect.right && + rect.bottom <= perimeterRect.bottom + ); +} + +export function decodeHtmlEntities(html: string) { + const textArea = document.createElement('textarea'); + textArea.innerHTML = html; + return textArea.value; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/guess.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/guess.ts new file mode 100644 index 000000000..2ce416207 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/guess.ts @@ -0,0 +1,65 @@ +// @ts-nocheck + +import { Cell, GuessGrid } from '../interfaces'; +import { isValidChar } from './general'; + +/** + * Initialise a guess grid with a single char (default = ''). + * @param cols + * @param rows + * @param cells + * @returns + */ +export function initialiseGuessGrid( + cols: number, + rows: number, + char: string = '', +) { + const grid: GuessGrid = { + value: new Array(cols).fill(char).map(() => new Array(rows).fill(char)), + }; + return grid; +} + +/** + * Generate guess grid from cells. + * @param cols + * @param rows + * @param cells + * @returns + */ +export function getGuessGrid(cols: number, rows: number, cells: Cell[]) { + const grid = initialiseGuessGrid(cols, rows); + + // overlay the guesses from the cells + cells.forEach(({ guess, pos }) => { + grid.value[pos.col][pos.row] = guess !== undefined ? guess : ''; + }); + + return grid; +} + +export function validateGuessGrid( + guessGrid: GuessGrid, + cols: number, + rows: number, + cellMatcher: RegExp, +) { + // check grid has correct total + const total = guessGrid.value.reduce((count, row) => count + row.length, 0); + if (total !== cols * rows) { + return false; + } + + // check all entries are valid characters + for (let i = 0; i < cols; i += 1) { + for (let j = 0; j < rows; j += 1) { + const cell = guessGrid.value[i][j]; + if (cell !== '' && !isValidChar(cell, cellMatcher)) { + return false; + } + } + } + + return true; +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/jest.ts b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/jest.ts new file mode 100644 index 000000000..d4ff633b4 --- /dev/null +++ b/libs/@guardian/source-development-kitchen/src/react-components/crossword/vendor/mycrossword/utils/jest.ts @@ -0,0 +1,11 @@ +// @ts-nocheck + +type ConsoleMessageType = 'info' | 'log' | 'debug' | 'warn' | 'error'; + +export function suppressConsoleMessage(type: ConsoleMessageType) { + jest.spyOn(console, type).mockImplementation(() => jest.fn()); +} + +export function restoreConsoleMessage(type: ConsoleMessageType) { + jest.spyOn(console, type).mockRestore(); +} diff --git a/libs/@guardian/source-development-kitchen/src/react-components/index.test.ts b/libs/@guardian/source-development-kitchen/src/react-components/index.test.ts index b2f4a2a89..ded67f6fa 100644 --- a/libs/@guardian/source-development-kitchen/src/react-components/index.test.ts +++ b/libs/@guardian/source-development-kitchen/src/react-components/index.test.ts @@ -4,6 +4,7 @@ import * as pkgExports from './index'; // it won't catch that new ones have been added, but can anyone? export type { AgeWarningProps, + CrosswordProps, ExpandingWrapperProps, FileInputProps, FooterLinksProps, @@ -21,6 +22,7 @@ export type { it('Should have exactly these exports', () => { expect(Object.keys(pkgExports).sort()).toEqual([ 'AgeWarning', + 'Crossword', 'DashedLines', 'Divider', 'DottedLines', diff --git a/libs/@guardian/source-development-kitchen/src/react-components/index.ts b/libs/@guardian/source-development-kitchen/src/react-components/index.ts index 0b72c5315..6619d860a 100644 --- a/libs/@guardian/source-development-kitchen/src/react-components/index.ts +++ b/libs/@guardian/source-development-kitchen/src/react-components/index.ts @@ -62,3 +62,6 @@ export type { TabContainerProps, TabProps } from './tabs/types'; export { Ticker } from './ticker/Ticker'; export type { TickerSettings } from './ticker/Ticker'; + +export { Crossword } from './crossword/Crossword'; +export type { CrosswordProps } from './crossword/Crossword';