[
  {
    "path": ".editorconfig",
    "content": "[*]\nend_of_line = lf\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: e2e tests\n\non:\n  push:\n    branches: [master]\n  pull_request:\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        node-version: [16.x]\n\n    steps:\n      - uses: actions/checkout@v3\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v3\n        with:\n          node-version: ${{ matrix.node-version }}\n      - run: yarn --frozen-lockfile\n      - run: yarn test-prep\n      - run: yarn test\n"
  },
  {
    "path": ".gitignore",
    "content": "/node_modules\n/dist\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"printWidth\": 80,\n  \"singleQuote\": true,\n  \"trailingComma\": \"none\"\n}\n"
  },
  {
    "path": ".storybook/main.js",
    "content": "module.exports = {\n  stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],\n  addons: [\n    '@storybook/addon-links',\n    '@storybook/addon-essentials',\n    '@storybook/preset-scss'\n  ]\n};\n"
  },
  {
    "path": ".storybook/preview.js",
    "content": "\nexport const parameters = {\n  actions: { argTypesRegex: \"^on[A-Z].*\" },\n}"
  },
  {
    "path": "LICENSE.md",
    "content": "MIT License\n\nCopyright (c) 2020 Beamworks Enterprise Software Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# React CSV Importer\n\n[![https://www.npmjs.com/package/react-csv-importer](https://img.shields.io/npm/v/react-csv-importer)](https://www.npmjs.com/package/react-csv-importer) [![https://github.com/beamworks/react-csv-importer/actions](https://github.com/beamworks/react-csv-importer/actions/workflows/test.yml/badge.svg)](https://github.com/beamworks/react-csv-importer/actions)\n\nThis library combines an uploader + CSV parser + raw file preview + UI for custom user column\nmapping, all in one.\n\nUse this to provide a typical bulk data import experience:\n\n- 📤 drag-drop or select a file for upload\n- 👓 preview the raw uploaded data\n- ✏ pick which columns to import\n- ⏳ wait for backend logic to finish processing data\n\n![React CSV Importer usage demo](https://github.com/beamworks/react-csv-importer/raw/59f967c13bbbd20eb2a663538797dd718f9bc57e/package-core/react-csv-importer-demo-20200915.gif)\n\n[Try it in the live code sandbox](https://codesandbox.io/s/github/beamworks/react-csv-importer/tree/master/demo-sandbox)\n\n### Feature summary:\n\n- raw file preview\n- drag-drop UI to remap input columns as needed\n- i18n (EN, DA, DE, IT, PT, TR or custom)\n- screen reader accessibility (yes, really!)\n- keyboard a11y\n- standalone CSS stylesheet (no frameworks required)\n- existing parser implementation: Papa Parse CSV\n- TypeScript support\n\n### Enterprise-level data file handling:\n\n- 1GB+ CSV file size (true streaming support without crashing browser)\n- automatically strip leading BOM character in data\n- async parsing logic (pause file read while your app makes backend updates)\n- fixes a [multibyte streaming issue in PapaParse](https://github.com/mholt/PapaParse/issues/908)\n\n## Install\n\n```sh\n# using NPM\nnpm install --save react-csv-importer\n\n# using Yarn\nyarn add react-csv-importer\n```\n\nMake sure that the bundled CSS stylesheet (`/dist/index.css`) is present in your app's page or bundle.\n\nThis package is easy to fork with your own customizations, and you can use your fork directly as a [Git dependency](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#git-urls-as-dependencies) in any of your projects, see below. For simple CSS customization you can also just override the built-in styling with your own style rules.\n\n## How It Works\n\nRender the React CSV Importer UI component where you need it in your app. This will present the upload widget to the user. After a file is selected and reviewed by the user, CSV file data is parsed in-browser and passed to your front-end code as a list of JSON objects. Each object will have fields corresponding to the columns that the user selected.\n\nLarge files (can be up to 1GB and more!) are parsed in chunks: return a promise from your data handler and the file reader will pause until you are ready for more data.\n\nInstead of a custom CSV parser this library uses the popular Papa Parse CSV reader. Because the file reader runs in-browser, your backend (if you have one) never has to deal with raw CSV data.\n\n## Example Usage\n\n```js\nimport { Importer, ImporterField } from 'react-csv-importer';\n\n// include the widget CSS file whichever way your bundler supports it\nimport 'react-csv-importer/dist/index.css';\n\n// in your component code:\n<Importer\n  dataHandler={async (rows, { startIndex }) => {\n    // required, may be called several times\n    // receives a list of parsed objects based on defined fields and user column mapping;\n    // (if this callback returns a promise, the widget will wait for it before parsing more data)\n    for (row of rows) {\n      await myAppMethod(row);\n    }\n  }}\n  defaultNoHeader={false} // optional, keeps \"data has headers\" checkbox off by default\n  restartable={false} // optional, lets user choose to upload another file when import is complete\n  onStart={({ file, preview, fields, columnFields }) => {\n    // optional, invoked when user has mapped columns and started import\n    prepMyAppForIncomingData();\n  }}\n  onComplete={({ file, preview, fields, columnFields }) => {\n    // optional, invoked right after import is done (but user did not dismiss/reset the widget yet)\n    showMyAppToastNotification();\n  }}\n  onClose={({ file, preview, fields, columnFields }) => {\n    // optional, if this is specified the user will see a \"Finish\" button after import is done,\n    // which will call this when clicked\n    goToMyAppNextPage();\n  }}\n\n  // CSV options passed directly to PapaParse if specified:\n  // delimiter={...}\n  // newline={...}\n  // quoteChar={...}\n  // escapeChar={...}\n  // comments={...}\n  // skipEmptyLines={...}\n  // delimitersToGuess={...}\n  // chunkSize={...} // defaults to 10000\n  // encoding={...} // defaults to utf-8, see FileReader API\n>\n  <ImporterField name=\"name\" label=\"Name\" />\n  <ImporterField name=\"email\" label=\"Email\" />\n  <ImporterField name=\"dob\" label=\"Date of Birth\" optional />\n  <ImporterField name=\"postalCode\" label=\"Postal Code\" optional />\n</Importer>;\n```\n\nIn the above example, if the user uploads a CSV file with column headers \"Name\", \"Email\" and so on, the columns will be automatically matched to fields with same labels. If any of the headers do not match, the user will have an opportunity to manually remap columns to the defined fields.\n\nThe `preview` object available to some callbacks above contains a snippet of CSV file information (only the first portion of the file is read, not the entire thing). The structure is:\n\n```js\n{\n  rawData: '...', // raw string contents of first file chunk\n  columns: [ // array of preview columns, e.g.:\n    { index: 0, header: 'Date', values: [ '2020-09-20', '2020-09-25' ] },\n    { index: 1, header: 'Name', values: [ 'Alice', 'Bob' ] }\n  ],\n  skipHeaders: false, // true when user selected that data has no headers\n  parseWarning: undefined, // any non-blocking warning object produced by Papa Parse\n}\n```\n\nImporter component children may be defined as a render-prop function that receives an object with `preview` and `file` fields (see above). It can then, for example, dynamically return different fields depending which headers are present in the CSV.\n\nFor more, please see [storybook examples](src/components/Importer.stories.tsx).\n\n## Internationalization (i18n) and Localization (l10n)\n\nYou can swap the text used in the UI to a different locale.\n\n```\nimport { Importer, deDE } from 'react-csv-importer';\n\n// provide the locale to main UI\n<Importer\n  locale={deDE}\n  // normal props, etc\n/>\n```\n\nThese locales are provided as part of the NPM module:\n\n- `en-US`\n- `de-DE`\n- `it-IT`\n- `pt-BR`\n- `da-DK`\n- `tr-TR`\n\nYou can also pass your own fully custom locale definition as the locale value. See `ImporterLocale` interface in `src/locale` for the full definition, and use an existing locale like `en-US` as basis. For better performance, please ensure that the customized locale value does not change on every render.\n\n## Dependencies\n\n- [Papa Parse](https://www.papaparse.com/) for CSV parsing\n- [react-dropzone](https://react-dropzone.js.org/) for file upload\n- [@use-gesture/react](https://github.com/pmndrs/use-gesture) for drag-and-drop\n\n## Local Development\n\nPerform local `git clone`, etc. Then ensure modules are installed:\n\n```sh\nyarn\n```\n\nTo start Storybook to have a hot-reloaded local sandbox:\n\n```sh\nyarn storybook\n```\n\nTo run the end-to-end test suite:\n\n```sh\nyarn test\n```\n\nYou can use your own fork of this library in your own project by referencing the forked repo as a [Git dependency](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#git-urls-as-dependencies). NPM will then run the `prepare` script, which runs the same Webpack/dist command as when the NPM package is published, so your custom dependency should work just as conveniently as the stock NPM version. Of course if your custom fixes could be useful to the rest of us then please submit a PR to this repo!\n\n## Changes\n\n- 0.8.1\n  - fix double-start issue for React 18 dev mode\n- 0.8.0\n  - more translations (thanks [**@p-multani-0**](https://github.com/p-multani-0), [**@GuilhermeMelati**](https://github.com/GuilhermeMelati), [**@tobiaskkd**](https://github.com/tobiaskkd) and [**@memoricab**](https://github.com/memoricab))\n  - refactor to work with later versions of @use-gesture/react (thanks [**@dbismut**](https://github.com/dbismut))\n  - upgrade to newer version of react-dropzone\n  - rename assumeNoHeaders to defaultNoHeader (with deprecation warning)\n  - rename processChunk to dataHandler (with deprecation warning)\n  - expose display width customization (`displayColumnPageSize`, `displayFieldRowSize`)\n  - bug fixes for button type and labels\n- 0.7.1\n  - fix peerDependencies for React 18+ (thanks [**@timarney**](https://github.com/timarney))\n  - hide Finish button by default\n  - button label tweaks\n- 0.7.0\n  - add i18n (thanks [**@tstehr**](https://github.com/tstehr) and [**@Valodim**](https://github.com/Valodim))\n- 0.6.0\n  - improve multibyte stream parsing safety\n  - support all browser encodings via TextDecoder\n  - remove readable-web-to-node-stream dependency\n  - bug fix for preview of short no-EOL files\n- 0.5.2\n  - update readable-web-to-node-stream to have stream shim\n  - use npm prepare script for easier fork installs\n- 0.5.1\n  - correctly use custom Papa Parse config for the main processing stream\n  - drag-drop fixes on scrolled pages\n  - bug fixes for older Safari, mobile issues\n- 0.5.0\n  - report file preview to callbacks and render-prop\n  - report startIndex in processChunk callback\n- 0.4.1\n  - clearer error display\n  - add more information about ongoing import\n- 0.4.0\n  - auto-assign column headers\n- 0.3.0\n  - allow passing PapaParse config options\n- 0.2.3\n  - tweak TS compilation targets\n  - live editable sandbox link in docs\n- 0.2.2\n  - empty file checks\n  - fix up package metadata\n  - extra docs\n- 0.2.1\n  - update index.d.ts generation\n- 0.2.0\n  - bundling core package using Webpack\n  - added source maps\n- 0.1.0\n  - first beta release\n  - true streaming support using shim for PapaParse\n  - lifecycle hooks receive info about the import\n"
  },
  {
    "path": "demo-sandbox/.gitignore",
    "content": "/node_modules\n\n# ignore this lockfile to keep versioning simple\n/yarn.lock\n"
  },
  {
    "path": "demo-sandbox/.prettierrc",
    "content": "{\n  \"printWidth\": 80,\n  \"singleQuote\": false\n}\n"
  },
  {
    "path": "demo-sandbox/index.css",
    "content": "body {\n  font-family: Arial, Helvetica, sans-serif;\n  padding: 1em;\n  background: #e0f4f8;\n  background-image: radial-gradient(#d0e0e0 1px, transparent 0);\n  background-size: 24px 24px;\n}\n\nh1 {\n  color: #304040;\n}\n"
  },
  {
    "path": "demo-sandbox/index.jsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { Importer, ImporterField } from \"react-csv-importer\";\n\n// theme CSS for React CSV Importer\nimport \"react-csv-importer/dist/index.css\";\n\n// basic styling and font for sandbox window\nimport \"./index.css\";\n\n// sample importer usage snippet, play around with the settings and try it out!\n// (open console output to see sample results)\nReactDOM.render(\n  <div>\n    <h1>React CSV Importer sandbox</h1>\n\n    <Importer\n      dataHandler={async (rows) => {\n        // required, receives a list of parsed objects based on defined fields and user column mapping;\n        // may be called several times if file is large\n        // (if this callback returns a promise, the widget will wait for it before parsing more data)\n        console.log(\"received batch of rows\", rows);\n\n        // mock timeout to simulate processing\n        await new Promise((resolve) => setTimeout(resolve, 500));\n      }}\n      chunkSize={10000} // optional, internal parsing chunk size in bytes\n      defaultNoHeader={false} // optional, keeps \"data has headers\" checkbox off by default\n      restartable={false} // optional, lets user choose to upload another file when import is complete\n      onStart={({ file, fields }) => {\n        // optional, invoked when user has mapped columns and started import\n        console.log(\"starting import of file\", file, \"with fields\", fields);\n      }}\n      onComplete={({ file, fields }) => {\n        // optional, invoked right after import is done (but user did not dismiss/reset the widget yet)\n        console.log(\"finished import of file\", file, \"with fields\", fields);\n      }}\n      onClose={() => {\n        // optional, invoked when import is done and user clicked \"Finish\"\n        // (if this is not specified, the widget lets the user upload another file)\n        console.log(\"importer dismissed\");\n      }}\n    >\n      <ImporterField name=\"name\" label=\"Name\" />\n      <ImporterField name=\"email\" label=\"Email\" />\n      <ImporterField name=\"dob\" label=\"Date of Birth\" optional />\n      <ImporterField name=\"postalCode\" label=\"Postal Code\" optional />\n    </Importer>\n  </div>,\n  document.getElementById(\"root\")\n);\n"
  },
  {
    "path": "demo-sandbox/package.json",
    "content": "{\n  \"name\": \"demo-sandbox\",\n  \"version\": \"0.0.1\",\n  \"description\": \"Sample react-csv-importer usage snippet\",\n  \"main\": \"index.jsx\",\n  \"author\": \"Nick Matantsev <nick.matantsev@beamworks.io>\",\n  \"license\": \"ISC\",\n  \"dependencies\": {\n    \"react\": \"^18.0.0\",\n    \"react-csv-importer\": \"^0.8.1\",\n    \"react-dom\": \"^18.0.0\"\n  }\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"react-csv-importer\",\n  \"version\": \"0.8.1\",\n  \"description\": \"React CSV import widget with user-customizable mapping\",\n  \"keywords\": [\n    \"react\",\n    \"csv\",\n    \"upload\",\n    \"parser\",\n    \"import\",\n    \"preview\",\n    \"raw preview\",\n    \"TextDecoder\",\n    \"papa parse\",\n    \"papaparse\"\n  ],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/beamworks/react-csv-importer\"\n  },\n  \"main\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"files\": [\n    \"dist/**\"\n  ],\n  \"scripts\": {\n    \"prepare\": \"webpack --mode production && dts-bundle-generator -o dist/index.d.ts src/index.ts\",\n    \"lint\": \"eslint --max-warnings=0 --ext ts --ext tsx src\",\n    \"lint-fix\": \"eslint --max-warnings=0 --ext ts --ext tsx src --fix\",\n    \"stylelint\": \"stylelint \\\"src/**/*.scss\\\"\",\n    \"stylelint-fix\": \"stylelint \\\"src/**/*.scss\\\" --fix\",\n    \"test-prep\": \"yarn global add chromedriver@latest\",\n    \"test\": \"cross-env TS_NODE_COMPILER_OPTIONS={\\\\\\\"module\\\\\\\":\\\\\\\"commonjs\\\\\\\"} mocha --require ts-node/register --timeout 30000 test/**/*.test.ts\",\n    \"storybook\": \"start-storybook -p 6006\",\n    \"build-storybook\": \"build-storybook\",\n    \"dist\": \"yarn prepare\"\n  },\n  \"author\": \"Nick Matantsev <nick.matantsev@beamworks.io>\",\n  \"license\": \"MIT\",\n  \"husky\": {\n    \"hooks\": {\n      \"pre-commit\": \"lint-staged\"\n    }\n  },\n  \"lint-staged\": {\n    \"src/**/*.{ts,tsx}\": \"eslint --max-warnings=0\",\n    \"src/**/*.scss\": \"stylelint\",\n    \"test/**/*.{js,ts}\": \"eslint --max-warnings=0\"\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.11.6\",\n    \"@storybook/addon-actions\": \"^6.0.21\",\n    \"@storybook/addon-essentials\": \"^6.0.21\",\n    \"@storybook/addon-links\": \"^6.0.21\",\n    \"@storybook/preset-scss\": \"^1.0.2\",\n    \"@storybook/react\": \"^6.0.21\",\n    \"@types/chai\": \"^4.2.14\",\n    \"@types/mocha\": \"^8.2.0\",\n    \"@types/papaparse\": \"^5.2.2\",\n    \"@types/react\": \"^16.9.49\",\n    \"@types/react-dom\": \"^16.9.8\",\n    \"@types/selenium-webdriver\": \"^4.0.11\",\n    \"@types/webpack-dev-server\": \"^3.11.1\",\n    \"@typescript-eslint/eslint-plugin\": \"^4.1.0\",\n    \"@typescript-eslint/parser\": \"^4.1.0\",\n    \"babel-loader\": \"^8.1.0\",\n    \"chai\": \"^4.2.0\",\n    \"clean-webpack-plugin\": \"^3.0.0\",\n    \"cross-env\": \"^7.0.3\",\n    \"css-loader\": \"^4.3.0\",\n    \"dotenv-webpack\": \"^2.0.0\",\n    \"dts-bundle-generator\": \"^6.0.0\",\n    \"eslint\": \"^7.8.1\",\n    \"eslint-config-prettier\": \"^6.11.0\",\n    \"eslint-plugin-prettier\": \"^3.1.4\",\n    \"eslint-plugin-react\": \"^7.20.6\",\n    \"eslint-plugin-react-hooks\": \"^4.1.0\",\n    \"expose-loader\": \"^1.0.3\",\n    \"file-loader\": \"^6.1.0\",\n    \"husky\": \"^4.3.0\",\n    \"lint-staged\": \"^10.3.0\",\n    \"mini-css-extract-plugin\": \"^0.11.1\",\n    \"mocha\": \"^8.2.1\",\n    \"prettier\": \"^2.1.1\",\n    \"react\": \"^16.8.3\",\n    \"react-dom\": \"^16.8.3\",\n    \"react-is\": \"^16.13.1\",\n    \"rimraf\": \"^3.0.2\",\n    \"sass\": \"^1.26.10\",\n    \"sass-loader\": \"^10.0.2\",\n    \"selenium-webdriver\": \"^4.0.0-alpha.8\",\n    \"style-loader\": \"^1.2.1\",\n    \"stylelint\": \"^13.7.0\",\n    \"stylelint-config-standard\": \"^20.0.0\",\n    \"stylelint-order\": \"^4.1.0\",\n    \"stylelint-scss\": \"^3.18.0\",\n    \"ts-loader\": \"^8.0.3\",\n    \"ts-node\": \"^9.1.1\",\n    \"typescript\": \"^4.0.2\",\n    \"webpack\": \"^4.44.1\",\n    \"webpack-cli\": \"^3.3.12\",\n    \"webpack-dev-server\": \"^3.7.0\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^16.8.0 || >=17.0.0\",\n    \"react-dom\": \"^16.8.0 || >=17.0.0\"\n  },\n  \"dependencies\": {\n    \"@use-gesture/react\": \"^10.2.11\",\n    \"papaparse\": \"^5.3.0\",\n    \"react-dropzone\": \"^12.1.0\"\n  }\n}\n"
  },
  {
    "path": "src/.eslintrc.json",
    "content": "{\n  \"env\": {\n    \"browser\": true,\n    \"es6\": true\n  },\n  \"settings\": {\n    \"react\": {\n      \"version\": \"detect\"\n    }\n  },\n  \"parser\": \"@typescript-eslint/parser\",\n  \"extends\": [\n    \"eslint:recommended\",\n    \"plugin:@typescript-eslint/recommended\",\n    \"plugin:react/recommended\",\n    \"plugin:react-hooks/recommended\",\n    \"plugin:prettier/recommended\",\n    \"prettier/@typescript-eslint\",\n    \"prettier/react\"\n  ],\n  \"plugins\": [\"@typescript-eslint\", \"react\", \"react-hooks\"],\n  \"rules\": {\n    \"react/prop-types\": \"off\"\n  }\n}\n"
  },
  {
    "path": "src/.stylelintrc",
    "content": "{\n  \"extends\": \"stylelint-config-standard\",\n  \"plugins\": [\n    \"stylelint-scss\",\n    \"stylelint-order\"\n  ],\n  \"rules\": {\n    \"at-rule-no-unknown\": null,\n    \"scss/at-rule-no-unknown\": true,\n    \"declaration-empty-line-before\": [ \"always\", { \"except\": \"first-nested\", \"ignore\": [ \"after-declaration\", \"after-comment\" ] } ],\n    \"no-descending-specificity\": null,\n    \"order/order\": [\n      { \"type\": \"at-rule\", \"name\": \"include\" },\n      \"custom-properties\",\n      \"declarations\",\n      { \"type\": \"at-rule\", \"name\": \"media\", \"hasBlock\": true },\n      \"rules\"\n    ],\n    \"order/properties-order\": [\n      \"content\",\n      \"position\",\n      {\n        \"groupName\": \"layoutChildOptions\",\n        \"properties\": [\n          \"top\",\n          \"right\",\n          \"bottom\",\n          \"left\",\n          \"z-index\",\n          \"clear\",\n          \"float\",\n          \"align-self\",\n          \"flex\",\n          \"flex-basis\",\n          \"flex-grow\",\n          \"flex-shrink\",\n          \"order\"\n        ]\n      },\n      \"display\",\n      \"visibility\",\n      \"appearance\",\n      {\n        \"groupName\": \"layoutContainerOptions\",\n        \"properties\": [\n          \"table-layout\",\n          \"flex-direction\",\n          \"flex-flow\",\n          \"flex-wrap\",\n          \"align-content\",\n          \"align-items\",\n          \"justify-content\"\n        ]\n      },\n      {\n        \"groupName\": \"blockOuter\",\n        \"properties\": [\n          \"margin\",\n          \"margin-top\",\n          \"margin-right\",\n          \"margin-bottom\",\n          \"margin-left\"\n        ]\n      },\n      {\n        \"groupName\": \"blockSize\",\n        \"properties\": [\n          \"box-sizing\",\n          \"max-width\",\n          \"max-height\",\n          \"min-width\",\n          \"min-height\",\n          \"width\",\n          \"height\"\n        ]\n      },\n      {\n        \"groupName\": \"blockInner\",\n        \"properties\": [\n          \"border\",\n          \"border-top\",\n          \"border-right\",\n          \"border-bottom\",\n          \"border-left\",\n          \"padding\",\n          \"padding-top\",\n          \"padding-right\",\n          \"padding-bottom\",\n          \"padding-left\",\n          \"overflow\",\n          \"overflow-x\",\n          \"overflow-y\",\n          \"border-radius\",\n          \"border-top-left-radius\",\n          \"border-top-right-radius\",\n          \"border-bottom-right-radius\",\n          \"border-bottom-left-radius\",\n          \"background\",\n          \"background-attachment\",\n          \"background-blend-mode\",\n          \"background-color\",\n          \"background-image\",\n          \"background-position\",\n          \"background-repeat\",\n          \"background-size\",\n          \"box-shadow\"\n        ]\n      },\n      {\n        \"groupName\": \"typography\",\n        \"properties\": [\n          \"text-align\",\n          \"text-indent\",\n          \"list-style\",\n          \"list-style-position\",\n          \"line-height\",\n          \"font-family\",\n          \"font-size\",\n          \"font-style\",\n          \"font-weight\",\n          \"letter-spacing\",\n          \"color\",\n          \"text-decoration\",\n          \"text-overflow\",\n          \"text-transform\"\n        ]\n      },\n      {\n        \"groupName\": \"transform\",\n        \"properties\": [\n          \"transform\",\n          \"transform-origin\",\n          \"transform-perspective\"\n        ]\n      },\n      {\n        \"groupName\": \"compositing\",\n        \"properties\": [\n          \"clip\",\n          \"fill\",\n          \"mix-blend-mode\",\n          \"opacity\"\n        ]\n      },\n      {\n        \"groupName\": \"animation\",\n        \"properties\": [\n          \"transition\",\n          \"animation\",\n          \"animation-name\",\n          \"animation-timing-function\",\n          \"animation-delay\",\n          \"animation-duration\",\n          \"animation-direction\",\n          \"animation-fill-mode\",\n          \"animation-iteration-count\",\n          \"animation-play-state\",\n          \"will-change\"\n        ]\n      },\n      \"cursor\"\n    ]\n  }\n}\n"
  },
  {
    "path": "src/components/IconButton.scss",
    "content": "@import '../theme.scss';\n\n.CSVImporter_IconButton {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  margin: 0; // override default\n  width: 3em;\n  height: 3em;\n  border: 0;\n  padding: 0;\n  border-radius: 50%;\n  background: transparent;\n  font-size: inherit;\n  color: $fgColor;\n  cursor: pointer;\n\n  &:hover:not(:disabled) {\n    background: rgba($controlBorderColor, 0.25);\n  }\n\n  &:disabled {\n    cursor: default;\n  }\n\n  &[data-small='true'] {\n    width: 2em;\n    height: 2em;\n  }\n\n  &[data-focus-only='true'] {\n    opacity: 0;\n    pointer-events: none;\n\n    &:focus {\n      opacity: 1;\n    }\n  }\n\n  > span {\n    display: block;\n    width: 1.75em;\n    height: 1.75em;\n    background-position: 50% 50%;\n    background-repeat: no-repeat;\n    background-size: cover;\n\n    &[data-type='back'] {\n      // MUI ChevronLeft\n      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE1LjQxIDcuNDFMMTQgNmwtNiA2IDYgNiAxLjQxLTEuNDFMMTAuODMgMTJ6Ij48L3BhdGg+PC9zdmc+');\n    }\n\n    &[data-type='forward'] {\n      // MUI ChevronRight\n      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiI+PC9wYXRoPjwvc3ZnPg==');\n    }\n\n    &[data-type='replay'] {\n      // MUI Replay\n      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDVWMUw3IDZsNSA1VjdjMy4zMSAwIDYgMi42OSA2IDZzLTIuNjkgNi02IDYtNi0yLjY5LTYtNkg0YzAgNC40MiAzLjU4IDggOCA4czgtMy41OCA4LTgtMy41OC04LTgtOHoiPjwvcGF0aD48L3N2Zz4=');\n    }\n\n    &[data-type='arrowBack'] {\n      // MUI ArrowBack\n      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIwIDExSDcuODNsNS41OS01LjU5TDEyIDRsLTggOCA4IDggMS40MS0xLjQxTDcuODMgMTNIMjB2LTJ6Ij48L3BhdGg+PC9zdmc+');\n    }\n\n    &[data-type='close'] {\n      // MUI Close\n      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE5IDYuNDFMMTcuNTkgNSAxMiAxMC41OSA2LjQxIDUgNSA2LjQxIDEwLjU5IDEyIDUgMTcuNTkgNi40MSAxOSAxMiAxMy40MSAxNy41OSAxOSAxOSAxNy41OSAxMy40MSAxMnoiPjwvcGF0aD48L3N2Zz4=');\n    }\n  }\n\n  &:disabled > span {\n    opacity: 0.25;\n  }\n\n  &[data-small='true'] > span {\n    font-size: 0.75em;\n  }\n}\n"
  },
  {
    "path": "src/components/IconButton.tsx",
    "content": "import React from 'react';\n\nimport './IconButton.scss';\n\nexport const IconButton: React.FC<{\n  label: string;\n  type: 'back' | 'forward' | 'replay' | 'arrowBack' | 'close';\n  small?: boolean;\n  focusOnly?: boolean;\n  disabled?: boolean;\n  onClick?: () => void;\n}> = ({ type, label, small, focusOnly, disabled, onClick }) => {\n  return (\n    <button\n      className=\"CSVImporter_IconButton\"\n      type=\"button\" // avoid triggering form submit\n      aria-label={label}\n      disabled={disabled}\n      onClick={onClick}\n      data-small={!!small}\n      data-focus-only={!!focusOnly}\n    >\n      <span data-type={type} />\n    </button>\n  );\n};\n"
  },
  {
    "path": "src/components/Importer.scss",
    "content": ".CSVImporter_Importer {\n  // base styling for all content\n  box-sizing: border-box;\n  line-height: 1.4;\n\n  * {\n    box-sizing: border-box;\n  }\n}\n\n// prevent text selection while dragging on mobile\n// (must be on body per https://www.reddit.com/r/webdev/comments/g1wvsb/ios_safari_how_to_disable_longpress_text_selection/)\nbody.CSVImporter_dragging {\n  -webkit-user-select: none; // needed for Safari\n  user-select: none;\n}\n"
  },
  {
    "path": "src/components/Importer.stories.tsx",
    "content": "import React, { useState } from 'react';\nimport { Story, Meta } from '@storybook/react';\n\nimport { ImporterProps } from './ImporterProps';\nimport { Importer, ImporterField } from './Importer';\nimport { deDE } from '../locale';\n\nexport default {\n  title: 'Importer',\n  component: Importer,\n  parameters: {\n    actions: { argTypesRegex: '^on.*|dataHandler' }\n  }\n} as Meta;\n\ntype SampleImporterProps = ImporterProps<{ fieldA: string }>;\n\nexport const Main: Story<SampleImporterProps> = (args: SampleImporterProps) => {\n  return (\n    <Importer {...args}>\n      <ImporterField name=\"fieldA\" label=\"Field A\" />\n      <ImporterField name=\"fieldB\" label=\"Field B\" optional />\n    </Importer>\n  );\n};\n\nexport const LocaleDE: Story<SampleImporterProps> = (\n  args: SampleImporterProps\n) => {\n  return (\n    <Importer {...args} locale={deDE}>\n      <ImporterField name=\"fieldA\" label=\"Field A\" />\n      <ImporterField name=\"fieldB\" label=\"Field B\" optional />\n    </Importer>\n  );\n};\n\nexport const Timesheet: Story<SampleImporterProps> = (\n  args: SampleImporterProps\n) => {\n  return (\n    <Importer {...args}>\n      <ImporterField name=\"date\" label=\"Date\" />\n      <ImporterField name=\"clientName\" label=\"Client\" />\n      <ImporterField name=\"projectName\" label=\"Project\" />\n      <ImporterField name=\"projectCode\" label=\"Project Code\" optional />\n      <ImporterField name=\"taskName\" label=\"Task\" />\n      <ImporterField name=\"notes\" label=\"Notes\" optional />\n    </Importer>\n  );\n};\n\nexport const CustomDelimiterConfig: Story<SampleImporterProps> = (\n  args: SampleImporterProps\n) => {\n  return (\n    <Importer {...args}>\n      <ImporterField name=\"fieldA\" label=\"Field A\" />\n      <ImporterField name=\"fieldB\" label=\"Field B\" />\n    </Importer>\n  );\n};\n\nCustomDelimiterConfig.args = {\n  delimiter: '!' // use a truly unusual delimiter that PapaParse would not guess normally\n};\n\nexport const InsideScrolledPage: Story<SampleImporterProps> = (\n  args: SampleImporterProps\n) => {\n  return (\n    <div>\n      Scroll below\n      <div style={{ paddingTop: '120vh' }}></div>\n      <Importer {...args}>\n        <ImporterField name=\"fieldA\" label=\"Field A\" />\n        <ImporterField name=\"fieldB\" label=\"Field B\" optional />\n      </Importer>\n    </div>\n  );\n};\n\nexport const CustomWidth: Story<SampleImporterProps> = (\n  args: SampleImporterProps\n) => {\n  return (\n    <div style={{ width: '20rem' }}>\n      <Importer {...args}>\n        <ImporterField name=\"fieldA\" label=\"Field A\" />\n        <ImporterField name=\"fieldB\" label=\"Field B\" optional />\n      </Importer>\n    </div>\n  );\n};\n\nCustomWidth.args = {\n  displayColumnPageSize: 2, // fewer columns for e.g. a narrower display\n  displayFieldRowSize: 3 // fewer columns for e.g. a narrower display\n};\n\nexport const RenderProp: Story<SampleImporterProps> = (\n  args: SampleImporterProps\n) => {\n  return (\n    <Importer {...args}>\n      {({ preview }) => {\n        return (\n          <>\n            <ImporterField name=\"coreFieldA\" label=\"Field A\" />\n            <ImporterField name=\"coreFieldB\" label=\"Field B\" />\n\n            {preview &&\n              preview.columns.map(({ header, index }) =>\n                header ? (\n                  <ImporterField\n                    key={index}\n                    name={`uploaded_${header}`}\n                    label={`Field ${header}`}\n                  />\n                ) : null\n              )}\n          </>\n        );\n      }}\n    </Importer>\n  );\n};\n\nconst PresetSelector: React.FC<{\n  children: (fieldContent: React.ReactNode) => React.ReactElement;\n}> = ({ children }) => {\n  const [selection, setSelection] = useState('Person');\n\n  return (\n    <div>\n      <div style={{ marginBottom: '1rem' }}>\n        <select\n          style={{ fontSize: '150%' }}\n          value={selection}\n          onChange={(event) => setSelection(event.target.value)}\n        >\n          <option>Person</option>\n          <option>Car</option>\n        </select>\n      </div>\n\n      {children(\n        selection === 'Person' ? (\n          <>\n            <ImporterField name=\"person_name\" label=\"Preset A: Person Name\" />\n            <ImporterField name=\"person_age\" label=\"Preset A: Person Age\" />\n          </>\n        ) : (\n          <>\n            <ImporterField name=\"car_make\" label=\"Preset B: Car Make\" />\n            <ImporterField name=\"car_model\" label=\"Preset B: Car Model\" />\n          </>\n        )\n      )}\n    </div>\n  );\n};\n\nexport const ChooseFieldPresets: Story<SampleImporterProps> = (\n  args: SampleImporterProps\n) => {\n  return (\n    <PresetSelector>\n      {(fields) => <Importer {...args}>{fields}</Importer>}\n    </PresetSelector>\n  );\n};\n"
  },
  {
    "path": "src/components/Importer.tsx",
    "content": "import React, { useMemo, useState, useEffect } from 'react';\n\nimport { BaseRow } from '../parser';\nimport { FileStep, FileStepState } from './file-step/FileStep';\nimport { generatePreviewColumns } from './fields-step/ColumnPreview';\nimport { FieldsStep, FieldsStepState } from './fields-step/FieldsStep';\nimport { ProgressDisplay } from './ProgressDisplay';\nimport { ImporterFilePreview, ImporterProps } from './ImporterProps';\n\n// re-export from a central spot\nexport { ImporterField } from './ImporterField';\nimport { useFieldDefinitions } from './ImporterField';\n\nimport './Importer.scss';\nimport { LocaleContext } from '../locale/LocaleContext';\nimport { enUS } from '../locale';\n\nexport function Importer<Row extends BaseRow>(\n  props: ImporterProps<Row>\n): React.ReactElement {\n  const {\n    dataHandler,\n    processChunk,\n    defaultNoHeader,\n    assumeNoHeaders,\n    restartable,\n    displayFieldRowSize,\n    displayColumnPageSize,\n    onStart,\n    onComplete,\n    onClose,\n    children: content,\n    locale: userLocale,\n    ...customPapaParseConfig\n  } = props;\n\n  // helper to combine our displayed content and the user code that provides field definitions\n  const [fields, userFieldContentWrapper] = useFieldDefinitions();\n\n  const [fileState, setFileState] = useState<FileStepState | null>(null);\n  const [fileAccepted, setFileAccepted] = useState<boolean>(false);\n\n  const [fieldsState, setFieldsState] = useState<FieldsStepState | null>(null);\n  const [fieldsAccepted, setFieldsAccepted] = useState<boolean>(false);\n\n  // reset field assignments when file changes\n  const activeFile = fileState && fileState.file;\n  useEffect(() => {\n    if (activeFile) {\n      setFieldsState(null);\n    }\n  }, [activeFile]);\n\n  const externalPreview = useMemo<ImporterFilePreview | null>(() => {\n    // generate stable externally-visible data objects\n    const externalColumns =\n      fileState &&\n      generatePreviewColumns(fileState.firstRows, fileState.hasHeaders);\n    return (\n      fileState &&\n      externalColumns && {\n        rawData: fileState.firstChunk,\n        columns: externalColumns,\n        skipHeaders: !fileState.hasHeaders,\n        parseWarning: fileState.parseWarning\n      }\n    );\n  }, [fileState]);\n\n  // fall back to enUS if no locale provided\n  const locale = userLocale ?? enUS;\n\n  if (!fileAccepted || fileState === null || externalPreview === null) {\n    return (\n      <LocaleContext.Provider value={locale}>\n        <div className=\"CSVImporter_Importer\">\n          <FileStep\n            customConfig={customPapaParseConfig}\n            defaultNoHeader={defaultNoHeader ?? assumeNoHeaders}\n            prevState={fileState}\n            onChange={(parsedPreview) => {\n              setFileState(parsedPreview);\n            }}\n            onAccept={() => {\n              setFileAccepted(true);\n            }}\n          />\n        </div>\n      </LocaleContext.Provider>\n    );\n  }\n\n  if (!fieldsAccepted || fieldsState === null) {\n    return (\n      <LocaleContext.Provider value={locale}>\n        <div className=\"CSVImporter_Importer\">\n          <FieldsStep\n            fileState={fileState}\n            fields={fields}\n            prevState={fieldsState}\n            displayFieldRowSize={displayFieldRowSize}\n            displayColumnPageSize={displayColumnPageSize}\n            onChange={(state) => {\n              setFieldsState(state);\n            }}\n            onAccept={() => {\n              setFieldsAccepted(true);\n            }}\n            onCancel={() => {\n              // keep existing preview data and assignments\n              setFileAccepted(false);\n            }}\n          />\n\n          {userFieldContentWrapper(\n            // render the provided child content that defines the fields\n            typeof content === 'function'\n              ? content({\n                  file: fileState && fileState.file,\n                  preview: externalPreview\n                })\n              : content\n          )}\n        </div>\n      </LocaleContext.Provider>\n    );\n  }\n\n  return (\n    <LocaleContext.Provider value={locale}>\n      <div className=\"CSVImporter_Importer\">\n        <ProgressDisplay\n          fileState={fileState}\n          fieldsState={fieldsState}\n          externalPreview={externalPreview}\n          // @todo remove assertion after upgrading to TS 4.1+\n          dataHandler={dataHandler ?? processChunk!} // eslint-disable-line @typescript-eslint/no-non-null-assertion\n          onStart={onStart}\n          onRestart={\n            restartable\n              ? () => {\n                  // reset all state\n                  setFileState(null);\n                  setFileAccepted(false);\n                  setFieldsState(null);\n                  setFieldsAccepted(false);\n                }\n              : undefined\n          }\n          onComplete={onComplete}\n          onClose={onClose}\n        />\n      </div>\n    </LocaleContext.Provider>\n  );\n}\n"
  },
  {
    "path": "src/components/ImporterField.tsx",
    "content": "import React, { useMemo, useState, useEffect, useContext } from 'react';\n\nimport { ImporterFieldProps } from './ImporterProps';\n\nexport interface Field {\n  name: string;\n  label: string;\n  isOptional: boolean;\n}\n\n// internal context for registering field definitions\ntype FieldDef = Field & { instanceId: symbol };\ntype FieldListSetter = (prev: FieldDef[]) => FieldDef[];\n\nconst FieldDefinitionContext = React.createContext<\n  ((setter: FieldListSetter) => void) | null\n>(null);\n\n// internal helper to allow user code to provide field definitions\nexport function useFieldDefinitions(): [\n  Field[],\n  (content: React.ReactNode) => React.ReactElement\n] {\n  const [fields, setFields] = useState<FieldDef[]>([]);\n\n  const userFieldContentWrapper = (content: React.ReactNode) => (\n    <FieldDefinitionContext.Provider value={setFields}>\n      {content}\n    </FieldDefinitionContext.Provider>\n  );\n\n  return [fields, userFieldContentWrapper];\n}\n\n// defines a field to be filled from file column during import\nexport const ImporterField: React.FC<ImporterFieldProps> = ({\n  name,\n  label,\n  optional\n}) => {\n  // make unique internal ID (this is never rendered in HTML and does not affect SSR)\n  const instanceId = useMemo(() => Symbol('internal unique field ID'), []);\n  const fieldSetter = useContext(FieldDefinitionContext);\n\n  // update central list as needed\n  useEffect(() => {\n    if (!fieldSetter) {\n      console.error('importer field must be a child of importer'); // @todo\n      return;\n    }\n\n    fieldSetter((prev) => {\n      const copy = [...prev];\n      const existingIndex = copy.findIndex(\n        (item) => item.instanceId === instanceId\n      );\n\n      // add or update the field definition instance in-place\n      // (using internal field instance ID helps gracefully tolerate duplicates, renames, etc)\n      const newField = {\n        instanceId,\n        name,\n        label,\n        isOptional: !!optional\n      };\n      if (existingIndex === -1) {\n        copy.push(newField);\n      } else {\n        copy[existingIndex] = newField;\n      }\n\n      return copy;\n    });\n  }, [instanceId, fieldSetter, name, label, optional]);\n\n  // on component unmount, remove this field from list by ID\n  useEffect(() => {\n    if (!fieldSetter) {\n      console.error('importer field must be a child of importer'); // @todo\n      return;\n    }\n\n    return () => {\n      fieldSetter((prev) =>\n        prev.filter((field) => field.instanceId !== instanceId)\n      );\n    };\n  }, [instanceId, fieldSetter]);\n\n  return null;\n};\n"
  },
  {
    "path": "src/components/ImporterFrame.scss",
    "content": "@import '../theme.scss';\n\n// @todo use em instead of rem\n.CSVImporter_ImporterFrame {\n  border: 1px solid $controlBorderColor;\n  padding: 1.2em;\n  border-radius: $borderRadius;\n  background: $controlBgColor;\n\n  &__header {\n    display: flex;\n    align-items: center;\n    margin-top: -1em; // cancel out button padding\n    margin-bottom: 0.2em;\n    margin-left: -1em;\n  }\n\n  &__headerTitle {\n    padding-bottom: 0.1em; // centering nudge\n    overflow: hidden;\n    font-size: $titleFontSize;\n    color: $textColor;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  &__headerCrumbSeparator {\n    flex: none;\n    display: flex; // for correct icon alignment\n    margin-right: 0.5em;\n    margin-left: 0.5em;\n    font-size: 1.2em;\n    opacity: 0.5;\n\n    > span {\n      display: block;\n      width: 1em;\n      height: 1em;\n      // MUI ChevronRight\n      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiI+PC9wYXRoPjwvc3ZnPg==');\n      background-position: 50% 50%;\n      background-repeat: no-repeat;\n      background-size: cover;\n    }\n  }\n\n  &__headerSubtitle {\n    flex: none;\n    padding-bottom: 0.1em; // centering nudge\n    font-size: $titleFontSize;\n    color: $textColor;\n  }\n\n  &__footer {\n    display: flex;\n    align-items: center;\n\n    margin-top: 1.2em;\n  }\n\n  &__footerFill {\n    flex: 1 1 0;\n  }\n\n  &__footerError {\n    flex: none;\n    line-height: 0.8; // in case of line break\n    color: $errorTextColor;\n    word-break: break-word;\n  }\n\n  &__footerSecondary {\n    flex: none;\n    display: flex; // for more consistent button alignment\n    margin-left: 1em;\n  }\n\n  &__footerNext {\n    flex: none;\n    display: flex; // for more consistent button alignment\n    margin-left: 1em;\n  }\n}\n"
  },
  {
    "path": "src/components/ImporterFrame.tsx",
    "content": "import React, { useRef, useEffect } from 'react';\n\nimport { TextButton } from './TextButton';\nimport { IconButton } from './IconButton';\n\nimport './ImporterFrame.scss';\nimport { useLocale } from '../locale/LocaleContext';\n\nexport const ImporterFrame: React.FC<{\n  fileName: string;\n  subtitle?: string; // @todo allow multiple crumbs\n  secondaryDisabled?: boolean;\n  secondaryLabel?: string;\n  nextDisabled?: boolean;\n  nextLabel: string | false;\n  error?: string | null;\n  onSecondary?: () => void;\n  onNext: () => void;\n  onCancel?: () => void;\n}> = ({\n  fileName,\n  subtitle,\n  secondaryDisabled,\n  secondaryLabel,\n  nextDisabled,\n  nextLabel,\n  error,\n  onSecondary,\n  onNext,\n  onCancel,\n  children\n}) => {\n  const titleRef = useRef<HTMLDivElement>(null);\n  const subtitleRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (subtitleRef.current) {\n      subtitleRef.current.focus();\n    } else if (titleRef.current) {\n      titleRef.current.focus();\n    }\n  }, []);\n\n  const l10n = useLocale('general');\n\n  return (\n    <div className=\"CSVImporter_ImporterFrame\">\n      <div className=\"CSVImporter_ImporterFrame__header\">\n        <IconButton\n          label={l10n.goToPreviousStepTooltip}\n          type=\"arrowBack\"\n          disabled={!onCancel}\n          onClick={onCancel}\n        />\n\n        <div\n          className=\"CSVImporter_ImporterFrame__headerTitle\"\n          tabIndex={-1}\n          ref={titleRef}\n        >\n          {fileName}\n        </div>\n\n        {subtitle ? (\n          <>\n            <div className=\"CSVImporter_ImporterFrame__headerCrumbSeparator\">\n              <span />\n            </div>\n            <div\n              className=\"CSVImporter_ImporterFrame__headerSubtitle\"\n              tabIndex={-1}\n              ref={subtitleRef}\n            >\n              {subtitle}\n            </div>\n          </>\n        ) : null}\n      </div>\n\n      {children}\n\n      <div className=\"CSVImporter_ImporterFrame__footer\">\n        <div className=\"CSVImporter_ImporterFrame__footerFill\" />\n\n        {error ? (\n          <div className=\"CSVImporter_ImporterFrame__footerError\" role=\"status\">\n            {error}\n          </div>\n        ) : null}\n\n        {secondaryLabel ? (\n          <div className=\"CSVImporter_ImporterFrame__footerSecondary\">\n            <TextButton disabled={!!secondaryDisabled} onClick={onSecondary}>\n              {secondaryLabel}\n            </TextButton>\n          </div>\n        ) : null}\n\n        {nextLabel !== false ? (\n          <div className=\"CSVImporter_ImporterFrame__footerNext\">\n            <TextButton disabled={!!nextDisabled} onClick={onNext}>\n              {nextLabel}\n            </TextButton>\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/ImporterProps.ts",
    "content": "import React from 'react';\nimport { ImporterLocale } from '../locale';\nimport { CustomizablePapaParseConfig, ParseCallback, BaseRow } from '../parser';\n\n// information for displaying a spreadsheet-style column\nexport interface ImporterPreviewColumn {\n  index: number; // 0-based position inside spreadsheet\n  header?: string; // header, if present\n  values: string[]; // row values after the header\n}\n\nexport interface ImporterFilePreview {\n  rawData: string; // raw first data chunk consumed by parser for preview\n  columns: ImporterPreviewColumn[]; // per-column parsed preview\n  skipHeaders: boolean; // true if user has indicated that file has no headers\n  parseWarning?: Papa.ParseError; // any non-blocking PapaParse message\n}\n\n// separate props definition to safely include in tests\nexport interface ImportInfo {\n  file: File;\n  preview: ImporterFilePreview;\n  fields: string[]; // list of fields that user has assigned\n  columnFields: (string | undefined)[]; // per-column list of field names (or undefined if unassigned)\n}\n\nexport type ImporterContentRenderProp = (info: {\n  file: File | null;\n  preview: ImporterFilePreview | null;\n}) => React.ReactNode;\n\nexport interface ImporterFieldProps {\n  name: string;\n  label: string;\n  optional?: boolean;\n}\n\nexport type ImporterDataHandlerProps<Row extends BaseRow> =\n  | {\n      dataHandler: ParseCallback<Row>;\n      processChunk?: undefined; // for ease of rest-spread\n    }\n  | {\n      /**\n       * @deprecated renamed to `dataHandler`\n       */\n      processChunk: ParseCallback<Row>;\n      dataHandler?: undefined; // disambiguate from newer naming\n    };\n\nexport type ImporterProps<Row extends BaseRow> = ImporterDataHandlerProps<\n  Row\n> & {\n  defaultNoHeader?: boolean;\n  /**\n   * @deprecated renamed to `defaultNoHeader`\n   */\n  assumeNoHeaders?: boolean;\n\n  displayColumnPageSize?: number;\n  displayFieldRowSize?: number;\n\n  restartable?: boolean;\n  onStart?: (info: ImportInfo) => void;\n  onComplete?: (info: ImportInfo) => void;\n  onClose?: (info: ImportInfo) => void;\n  children?: ImporterContentRenderProp | React.ReactNode;\n  locale?: ImporterLocale;\n} & CustomizablePapaParseConfig;\n"
  },
  {
    "path": "src/components/ProgressDisplay.scss",
    "content": "@import '../theme.scss';\n\n.CSVImporter_ProgressDisplay {\n  padding: 2em;\n\n  &__status {\n    text-align: center;\n    font-size: $titleFontSize;\n    color: $textColor;\n\n    &.-pending {\n      color: $textSecondaryColor;\n    }\n  }\n\n  &__count {\n    text-align: right;\n    font-size: 1em;\n    color: $textSecondaryColor;\n\n    > var {\n      display: inline-block;\n      width: 1px;\n      height: 1px;\n      overflow: hidden;\n      opacity: 0;\n    }\n  }\n\n  &__progressBar {\n    position: relative; // for indicator\n    width: 100%;\n    height: 0.5em;\n    background: $fillColor;\n  }\n\n  &__progressBarIndicator {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 0; // dynamically set in code\n    height: 100%;\n    background: $textColor;\n\n    transition: width 0.2s ease-out;\n  }\n}\n"
  },
  {
    "path": "src/components/ProgressDisplay.tsx",
    "content": "import React, { useState, useEffect, useMemo, useRef } from 'react';\n\nimport { processFile, ParseCallback, BaseRow } from '../parser';\nimport { FileStepState } from './file-step/FileStep';\nimport { FieldsStepState } from './fields-step/FieldsStep';\nimport { ImporterFilePreview, ImportInfo } from './ImporterProps';\nimport { ImporterFrame } from './ImporterFrame';\n\nimport './ProgressDisplay.scss';\nimport { useLocale } from '../locale/LocaleContext';\n\n// compute actual UTF-8 bytes used by a string\n// (inspired by https://stackoverflow.com/questions/10576905/how-to-convert-javascript-unicode-notation-code-to-utf-8)\nfunction countUTF8Bytes(item: string) {\n  // re-encode into UTF-8\n  const escaped = encodeURIComponent(item);\n\n  // convert byte escape sequences into single characters\n  const normalized = escaped.replace(/%\\d\\d/g, '_');\n\n  return normalized.length;\n}\n\nexport function ProgressDisplay<Row extends BaseRow>({\n  fileState,\n  fieldsState,\n  externalPreview,\n  dataHandler,\n  onStart,\n  onComplete,\n  onRestart,\n  onClose\n}: React.PropsWithChildren<{\n  fileState: FileStepState;\n  fieldsState: FieldsStepState;\n  externalPreview: ImporterFilePreview;\n  dataHandler: ParseCallback<Row>;\n  onStart?: (info: ImportInfo) => void;\n  onComplete?: (info: ImportInfo) => void;\n  onRestart?: () => void;\n  onClose?: (info: ImportInfo) => void;\n}>): React.ReactElement {\n  const [progressCount, setProgressCount] = useState(0);\n  const [isComplete, setIsComplete] = useState(false);\n  const [error, setError] = useState<Error | null>(null);\n  const [isDismissed, setIsDismissed] = useState(false); // prevents double-clicking finish\n\n  // info object exposed to the progress callbacks\n  const importInfo = useMemo<ImportInfo>(() => {\n    const fieldList = Object.keys(fieldsState.fieldAssignments);\n\n    const columnSparseList: (string | undefined)[] = [];\n    fieldList.forEach((field) => {\n      const col = fieldsState.fieldAssignments[field];\n      if (col !== undefined) {\n        columnSparseList[col] = field;\n      }\n    });\n\n    return {\n      file: fileState.file,\n      preview: externalPreview,\n      fields: fieldList,\n      columnFields: [...columnSparseList]\n    };\n  }, [fileState, fieldsState, externalPreview]);\n\n  // estimate number of rows\n  const estimatedRowCount = useMemo(() => {\n    // sum up sizes of all the parsed preview rows and get estimated average\n    const totalPreviewRowBytes = fileState.firstRows.reduce(\n      (prevCount, row) => {\n        const rowBytes = row.reduce((prev, item) => {\n          return prev + countUTF8Bytes(item) + 1; // add a byte for separator or newline\n        }, 0);\n\n        return prevCount + rowBytes;\n      },\n      0\n    );\n\n    const averagePreviewRowSize =\n      totalPreviewRowBytes / fileState.firstRows.length;\n\n    // divide file size by estimated row size (or fall back to a sensible amount)\n    return averagePreviewRowSize > 1\n      ? fileState.file.size / averagePreviewRowSize\n      : 100;\n  }, [fileState]);\n\n  // notify on start of processing\n  // (separate effect in case of errors)\n  const onStartRef = useRef(onStart); // wrap in ref to avoid re-triggering (only first instance is needed)\n  useEffect(() => {\n    if (onStartRef.current) {\n      onStartRef.current(importInfo);\n    }\n  }, [importInfo]);\n\n  // notify on end of processing\n  // (separate effect in case of errors)\n  const onCompleteRef = useRef(onComplete); // wrap in ref to avoid re-triggering\n  onCompleteRef.current = onComplete;\n  useEffect(() => {\n    if (isComplete && onCompleteRef.current) {\n      onCompleteRef.current(importInfo);\n    }\n  }, [importInfo, isComplete]);\n\n  // ensure status gets focus when complete, in case status role is not read out\n  const statusRef = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    if ((isComplete || error) && statusRef.current) {\n      statusRef.current.focus();\n    }\n  }, [isComplete, error]);\n\n  // trigger processing from an effect to mitigate React 18 double-run in dev\n  const [ready, setReady] = useState(false);\n  useEffect(() => {\n    setReady(true);\n  }, []);\n\n  // perform main async parse\n  const dataHandlerRef = useRef(dataHandler); // wrap in ref to avoid re-triggering\n  const asyncLockRef = useRef<number>(0);\n  useEffect(() => {\n    // avoid running on first render due to React 18 double-run\n    if (!ready) {\n      return;\n    }\n\n    const oplock = asyncLockRef.current;\n\n    processFile(\n      { ...fileState, fieldAssignments: fieldsState.fieldAssignments },\n      (deltaCount) => {\n        // ignore if stale\n        if (oplock !== asyncLockRef.current) {\n          return; // @todo signal abort\n        }\n\n        setProgressCount((prev) => prev + deltaCount);\n      },\n      dataHandlerRef.current\n    ).then(\n      () => {\n        // ignore if stale\n        if (oplock !== asyncLockRef.current) {\n          return;\n        }\n\n        setIsComplete(true);\n      },\n      (error) => {\n        // ignore if stale\n        if (oplock !== asyncLockRef.current) {\n          return;\n        }\n\n        setError(error);\n      }\n    );\n\n    return () => {\n      // invalidate current oplock on change or unmount\n      asyncLockRef.current += 1;\n    };\n  }, [ready, fileState, fieldsState]);\n\n  // simulate asymptotic progress percentage\n  const progressPercentage = useMemo(() => {\n    if (isComplete) {\n      return 100;\n    }\n\n    // inputs hand-picked so that correctly estimated total is about 75% of the bar\n    const progressPower = 2.5 * (progressCount / estimatedRowCount);\n    const progressLeft = 0.5 ** progressPower;\n\n    // convert to .1 percent precision for smoother bar display\n    return Math.floor(1000 - 1000 * progressLeft) / 10;\n  }, [estimatedRowCount, progressCount, isComplete]);\n\n  const l10n = useLocale('progressStep');\n\n  return (\n    <ImporterFrame\n      fileName={fileState.file.name}\n      subtitle={l10n.stepSubtitle}\n      error={error && (error.message || String(error))}\n      secondaryDisabled={!isComplete || isDismissed}\n      secondaryLabel={onRestart && onClose ? l10n.uploadMoreButton : undefined}\n      onSecondary={onRestart && onClose ? onRestart : undefined}\n      nextDisabled={!isComplete || isDismissed}\n      nextLabel={\n        !!(onClose || onRestart) &&\n        (onClose ? l10n.finishButton : l10n.uploadMoreButton)\n      }\n      onNext={() => {\n        if (onClose) {\n          setIsDismissed(true);\n          onClose(importInfo);\n        } else if (onRestart) {\n          onRestart();\n        }\n      }}\n    >\n      <div className=\"CSVImporter_ProgressDisplay\">\n        {isComplete || error ? (\n          <div\n            className=\"CSVImporter_ProgressDisplay__status\"\n            role=\"status\"\n            tabIndex={-1}\n            ref={statusRef}\n          >\n            {error ? l10n.statusError : l10n.statusComplete}\n          </div>\n        ) : (\n          <div\n            className=\"CSVImporter_ProgressDisplay__status -pending\"\n            role=\"status\"\n          >\n            {l10n.statusPending}\n          </div>\n        )}\n\n        <div className=\"CSVImporter_ProgressDisplay__count\" role=\"text\">\n          <var>{l10n.processedRowsLabel}</var> {progressCount}\n        </div>\n\n        <div className=\"CSVImporter_ProgressDisplay__progressBar\">\n          <div\n            className=\"CSVImporter_ProgressDisplay__progressBarIndicator\"\n            style={{ width: `${progressPercentage}%` }}\n          />\n        </div>\n      </div>\n    </ImporterFrame>\n  );\n}\n"
  },
  {
    "path": "src/components/TextButton.scss",
    "content": "@import '../theme.scss';\n\n.CSVImporter_TextButton {\n  display: block;\n  margin: 0; // override default\n  border: 1px solid $controlBorderColor;\n  padding: 0.4em 1em 0.5em;\n  border-radius: $borderRadius;\n  background: $fillColor;\n  font-size: inherit;\n  color: $fgColor;\n  cursor: pointer;\n\n  &:hover:not(:disabled) {\n    background: darken($fillColor, 10%);\n  }\n\n  &:disabled {\n    opacity: 0.25;\n    cursor: default;\n  }\n}\n"
  },
  {
    "path": "src/components/TextButton.tsx",
    "content": "import React from 'react';\n\nimport './TextButton.scss';\n\nexport const TextButton: React.FC<{\n  disabled?: boolean;\n  onClick?: () => void;\n}> = ({ disabled, onClick, children }) => {\n  return (\n    <button\n      className=\"CSVImporter_TextButton\"\n      type=\"button\" // avoid triggering form submit\n      disabled={disabled}\n      onClick={onClick}\n    >\n      {children}\n    </button>\n  );\n};\n"
  },
  {
    "path": "src/components/fields-step/ColumnDragCard.scss",
    "content": "@import '../../theme.scss';\n\n.CSVImporter_ColumnDragCard {\n  position: relative;\n  z-index: 0; // reset stacking context\n  padding: 0.5em 0.75em;\n  border-radius: $borderRadius;\n  background: $controlBgColor;\n  box-shadow: 0 1px 1px rgba(#000, 0.25);\n  cursor: default;\n\n  &[data-draggable='true'] {\n    cursor: grab;\n\n    // avoid triggering scroll on iOS Safari (needed despite preventDefault also being used)\n    touch-action: none;\n  }\n\n  &[data-dummy='true'] {\n    border-radius: 0;\n    background: $fillColor;\n    box-shadow: none;\n    opacity: 0.5;\n    user-select: none;\n  }\n\n  &[data-error='true'] {\n    background: rgba($errorTextColor, 0.25);\n    color: $textColor;\n  }\n\n  &[data-shadow='true'] {\n    background: $fillColor;\n    box-shadow: none;\n    color: rgba($textColor, 0.25); // reduce text\n  }\n\n  &[data-drop-indicator='true'] {\n    box-shadow: 0 1px 2px rgba(#000, 0.5);\n    color: $fgColor;\n  }\n\n  &__cardHeader {\n    margin-top: -0.25em;\n    margin-right: -0.5em;\n    margin-bottom: 0.25em;\n    margin-left: -0.5em;\n    height: 1.5em; // sized to be covered by small button\n    font-weight: bold;\n    color: $textSecondaryColor;\n\n    & > b {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      height: 100%;\n      background: $fillColor;\n      line-height: 1; // centered by parent anyway\n    }\n\n    > var {\n      display: block;\n      margin-bottom: -1px;\n      width: 1px; // non-zero size for reader\n      height: 1px;\n      overflow: hidden;\n    }\n  }\n\n  &__cardPaper[data-draggable='true']:hover &__cardHeader,\n  &__cardPaper[data-dragged='true'] &__cardHeader {\n    color: $fgColor;\n  }\n\n  &__cardValue {\n    margin-top: 0.25em;\n    overflow: hidden;\n    line-height: 1.25em; // might not be inherited from main content\n    font-size: 0.75em;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n\n    &[data-header='true'] {\n      text-align: center;\n      font-style: italic;\n      color: $textSecondaryColor;\n    }\n\n    & + div {\n      margin-top: 0;\n    }\n  }\n\n  &[data-shadow='true'] > &__cardValue[data-header='true'] {\n    color: rgba($textSecondaryColor, 0.25); // reduce text\n  }\n}\n"
  },
  {
    "path": "src/components/fields-step/ColumnDragCard.tsx",
    "content": "import React, { useMemo } from 'react';\n\nimport { PREVIEW_ROW_COUNT } from '../../parser';\nimport { Column } from './ColumnPreview';\n\nimport './ColumnDragCard.scss';\nimport { useLocale } from '../../locale/LocaleContext';\n\n// @todo sort out \"grabbing\" cursor state (does not work with pointer-events:none)\nexport const ColumnDragCard: React.FC<{\n  hasHeaders?: boolean; // for correct display of dummy card\n  column?: Column;\n  rowCount?: number;\n  hasError?: boolean;\n  isAssigned?: boolean;\n  isShadow?: boolean;\n  isDraggable?: boolean;\n  isDragged?: boolean;\n  isDropIndicator?: boolean;\n}> = ({\n  hasHeaders,\n  column: optionalColumn,\n  rowCount = PREVIEW_ROW_COUNT,\n  hasError,\n  isAssigned,\n  isShadow,\n  isDraggable,\n  isDragged,\n  isDropIndicator\n}) => {\n  const isDummy = !optionalColumn;\n\n  const column = useMemo<Column>(\n    () =>\n      optionalColumn || {\n        index: -1,\n        code: '',\n        header: hasHeaders ? '' : undefined,\n        values: [...new Array(PREVIEW_ROW_COUNT)].map(() => '')\n      },\n    [optionalColumn, hasHeaders]\n  );\n\n  const headerValue = column.header;\n  const dataValues = column.values.slice(\n    0,\n    headerValue === undefined ? rowCount : rowCount - 1\n  );\n\n  const l10n = useLocale('fieldsStep');\n\n  return (\n    // not changing variant dynamically because it causes a height jump\n    <div\n      key={isDummy || isShadow ? 1 : isDropIndicator ? 2 : 0} // force re-creation to avoid transition anim\n      className=\"CSVImporter_ColumnDragCard\"\n      data-dummy={!!isDummy}\n      data-error={!!hasError}\n      data-shadow={!!isShadow}\n      data-draggable={!!isDraggable}\n      data-dragged={!!isDragged}\n      data-drop-indicator={!!isDropIndicator}\n    >\n      <div className=\"CSVImporter_ColumnDragCard__cardHeader\">\n        {isDummy ? (\n          <var role=\"text\">{l10n.columnCardDummyHeader}</var>\n        ) : (\n          <var role=\"text\">{l10n.getColumnCardHeader(column.code)}</var>\n        )}\n        {isDummy || isAssigned ? '\\u00a0' : <b aria-hidden>{column.code}</b>}\n      </div>\n\n      {headerValue !== undefined ? (\n        <div className=\"CSVImporter_ColumnDragCard__cardValue\" data-header>\n          {headerValue || '\\u00a0'}\n        </div>\n      ) : null}\n\n      {/* all values grouped into one readable string */}\n      <div role=\"text\">\n        {dataValues.map((value, valueIndex) => (\n          <div\n            key={valueIndex}\n            className=\"CSVImporter_ColumnDragCard__cardValue\"\n          >\n            {value || '\\u00a0'}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/fields-step/ColumnDragObject.scss",
    "content": ".CSVImporter_ColumnDragObject {\n  &__overlay {\n    // scroll-independent container\n    position: fixed;\n    top: 0;\n    left: 0;\n    width: 100vw;\n    height: 100vh;\n    overflow: none; // clipping to avoid triggering scrollbar when dragging near edges\n    pointer-events: none;\n  }\n\n  &__positioner {\n    // movement of mouse gesture inside overlay\n    position: absolute; // @todo this is not working with scroll\n    top: 0;\n    left: 0;\n    min-width: 8em; // in case could not compute\n    width: 0; // dynamically set at drag start\n    height: 0; // dynamically set at drag start\n  }\n\n  &__holder {\n    // placement of visible card relative to mouse pointer\n    position: absolute;\n    top: -0.75em;\n    left: -0.75em;\n    width: 100%;\n    opacity: 0.9;\n  }\n}\n"
  },
  {
    "path": "src/components/fields-step/ColumnDragObject.tsx",
    "content": "import React, { useRef, useLayoutEffect } from 'react';\nimport { createPortal } from 'react-dom';\n\nimport { ColumnDragCard } from './ColumnDragCard';\nimport { DragState } from './ColumnDragState';\n\nimport './ColumnDragObject.scss';\n\nexport const ColumnDragObject: React.FC<{\n  dragState: DragState | null;\n}> = ({ dragState }) => {\n  const referenceBoxRef = useRef<HTMLDivElement | null>(null);\n\n  // the dragged box is wrapped in a no-events overlay to clip against screen edges\n  const dragBoxRef = useRef<HTMLDivElement | null>(null);\n  const dragObjectPortal =\n    dragState && dragState.pointerStartInfo\n      ? createPortal(\n          <div className=\"CSVImporter_ColumnDragObject__overlay\">\n            <div\n              className=\"CSVImporter_ColumnDragObject__positioner\"\n              ref={dragBoxRef}\n            >\n              <div className=\"CSVImporter_ColumnDragObject__holder\">\n                <ColumnDragCard column={dragState.column} isDragged />\n              </div>\n            </div>\n          </div>,\n          document.body\n        )\n      : null;\n\n  // set up initial position when pointer-based gesture is started\n  const pointerStartInfo = dragState && dragState.pointerStartInfo;\n  useLayoutEffect(() => {\n    // ignore non-pointer drag states\n    if (!pointerStartInfo || !dragBoxRef.current) {\n      return;\n    }\n\n    // place based on initial position + size relative to viewport overlay\n    const rect = pointerStartInfo.initialClientRect;\n    dragBoxRef.current.style.left = `${rect.left}px`;\n    dragBoxRef.current.style.top = `${rect.top}px`;\n    dragBoxRef.current.style.width = `${rect.width}px`;\n    dragBoxRef.current.style.height = `${rect.height}px`;\n\n    // copy known cascaded font style from main content into portal content\n    // @todo consider other text style properties?\n    if (referenceBoxRef.current) {\n      const computedStyle = window.getComputedStyle(referenceBoxRef.current);\n      dragBoxRef.current.style.fontFamily = computedStyle.fontFamily;\n      dragBoxRef.current.style.fontSize = computedStyle.fontSize;\n      dragBoxRef.current.style.fontWeight = computedStyle.fontWeight;\n      dragBoxRef.current.style.fontStyle = computedStyle.fontStyle;\n      dragBoxRef.current.style.letterSpacing = computedStyle.letterSpacing;\n    }\n  }, [pointerStartInfo]);\n\n  // subscribe to live position updates without state changes\n  useLayoutEffect(() => {\n    if (dragState) {\n      const updateListener = (movement: number[]) => {\n        if (!dragBoxRef.current) return;\n\n        // update the visible offset relative to starting position\n        const [x, y] = movement;\n        dragBoxRef.current.style.transform = `translate(${x}px, ${y}px)`;\n      };\n\n      dragState.updateListeners.push(updateListener);\n\n      // clean up listener\n      return () => {\n        const removeIndex = dragState.updateListeners.indexOf(updateListener);\n        if (removeIndex !== -1) {\n          dragState.updateListeners.splice(removeIndex, 1);\n        }\n      };\n    }\n  }, [dragState]);\n\n  return <div ref={referenceBoxRef}>{dragObjectPortal}</div>;\n};\n"
  },
  {
    "path": "src/components/fields-step/ColumnDragSourceArea.scss",
    "content": "@import '../../theme.scss';\n\n.CSVImporter_ColumnDragSourceArea {\n  display: flex;\n  margin-top: 0.5em;\n  margin-bottom: 1em;\n\n  &__control {\n    flex: none;\n    display: flex;\n    align-items: center;\n  }\n\n  &__page {\n    position: relative; // for indicator\n    flex: 1 1 0;\n    display: flex;\n    padding-top: 0.5em; // some room for the indicator\n    padding-left: 0.5em; // match interior box spacing\n  }\n\n  &__pageIndicator {\n    position: absolute;\n    top: -0.5em;\n    right: 0;\n    left: 0;\n    text-align: center;\n    font-size: 0.75em;\n  }\n\n  &__pageFiller {\n    flex: 1 1 0;\n    margin-right: 0.5em; // match interior box spacing\n  }\n\n  &__box {\n    position: relative; // for action\n    flex: 1 1 0;\n    margin-right: 0.5em;\n    width: 0; // prevent internal sizing from affecting placement\n  }\n\n  &__boxAction {\n    position: absolute;\n    top: 0; // icon button padding matches card padding\n    right: 0;\n    z-index: 1; // right above content\n  }\n}\n"
  },
  {
    "path": "src/components/fields-step/ColumnDragSourceArea.tsx",
    "content": "import React, { useState, useMemo } from 'react';\nimport { useDrag } from '@use-gesture/react';\n\nimport { FieldAssignmentMap } from '../../parser';\nimport { Column } from './ColumnPreview';\nimport { DragState } from './ColumnDragState';\nimport { ColumnDragCard } from './ColumnDragCard';\nimport { IconButton } from '../IconButton';\n\nimport './ColumnDragSourceArea.scss';\nimport { useLocale } from '../../locale/LocaleContext';\n\nconst DEFAULT_PAGE_SIZE = 5; // fraction of 10 for easier counting\n\n// @todo readable status text if not mouse-drag\nconst SourceBox: React.FC<{\n  column: Column;\n  fieldAssignments: FieldAssignmentMap;\n  dragState: DragState | null;\n  eventBinder: (column: Column) => ReturnType<typeof useDrag>;\n  onSelect: (column: Column) => void;\n  onUnassign: (column: Column) => void;\n}> = ({\n  column,\n  fieldAssignments,\n  dragState,\n  eventBinder,\n  onSelect,\n  onUnassign\n}) => {\n  const isDragged = dragState ? column === dragState.column : false;\n\n  const isAssigned = useMemo(\n    () =>\n      Object.keys(fieldAssignments).some(\n        (fieldName) => fieldAssignments[fieldName] === column.index\n      ),\n    [fieldAssignments, column]\n  );\n\n  const eventHandlers = useMemo(() => eventBinder(column), [\n    eventBinder,\n    column\n  ]);\n\n  const l10n = useLocale('fieldsStep');\n\n  return (\n    <div className=\"CSVImporter_ColumnDragSourceArea__box\">\n      <div\n        {...(isAssigned ? {} : eventHandlers)}\n        style={{ touchAction: 'none' }}\n      >\n        <ColumnDragCard\n          column={column}\n          isAssigned={isAssigned}\n          isShadow={isDragged || isAssigned}\n          isDraggable={!dragState && !isDragged && !isAssigned}\n        />\n      </div>\n\n      {/* tab order after column contents */}\n      <div className=\"CSVImporter_ColumnDragSourceArea__boxAction\">\n        {isAssigned ? (\n          <IconButton\n            key=\"clear\" // key-prop helps clear focus on click\n            label={l10n.clearAssignmentTooltip}\n            small\n            type=\"replay\"\n            onClick={() => {\n              onUnassign(column);\n            }}\n          />\n        ) : (\n          <IconButton\n            key=\"dragSelect\" // key-prop helps clear focus on click\n            focusOnly\n            label={\n              dragState && dragState.column === column\n                ? l10n.unselectColumnTooltip\n                : l10n.selectColumnTooltip\n            }\n            small\n            type=\"back\"\n            onClick={() => {\n              onSelect(column);\n            }}\n          />\n        )}\n      </div>\n    </div>\n  );\n};\n\n// @todo current page indicator (dots)\nexport const ColumnDragSourceArea: React.FC<{\n  columns: Column[];\n  columnPageSize?: number;\n  fieldAssignments: FieldAssignmentMap;\n  dragState: DragState | null;\n  eventBinder: (column: Column) => ReturnType<typeof useDrag>;\n  onSelect: (column: Column) => void;\n  onUnassign: (column: Column) => void;\n}> = ({\n  columns,\n  columnPageSize,\n  fieldAssignments,\n  dragState,\n  eventBinder,\n  onSelect,\n  onUnassign\n}) => {\n  // sanitize page size setting\n  const pageSize = Math.round(Math.max(1, columnPageSize ?? DEFAULT_PAGE_SIZE));\n\n  // track pagination state (resilient to page size changes)\n  const [pageStart, setPageStart] = useState<number>(0);\n  const [pageChanged, setPageChanged] = useState<boolean>(false);\n\n  const page = Math.floor(pageStart / pageSize); // round down in case page size changes\n  const pageCount = Math.ceil(columns.length / pageSize);\n\n  // display page items and fill up with dummy divs up to pageSize\n  const pageContents = columns\n    .slice(page * pageSize, (page + 1) * pageSize)\n    .map((column, columnIndex) => (\n      <SourceBox\n        key={columnIndex}\n        column={column}\n        fieldAssignments={fieldAssignments}\n        dragState={dragState}\n        eventBinder={eventBinder}\n        onSelect={onSelect}\n        onUnassign={onUnassign}\n      />\n    ));\n\n  while (pageContents.length < pageSize) {\n    pageContents.push(\n      <div\n        key={pageContents.length}\n        className=\"CSVImporter_ColumnDragSourceArea__pageFiller\"\n      />\n    );\n  }\n\n  const l10n = useLocale('fieldsStep');\n\n  return (\n    <section\n      className=\"CSVImporter_ColumnDragSourceArea\"\n      aria-label={l10n.dragSourceAreaCaption}\n    >\n      <div className=\"CSVImporter_ColumnDragSourceArea__control\">\n        <IconButton\n          label={l10n.previousColumnsTooltip}\n          type=\"back\"\n          disabled={page === 0}\n          onClick={() => {\n            setPageStart(\n              (prev) => Math.max(0, Math.floor(prev / pageSize) - 1) * pageSize\n            );\n            setPageChanged(true);\n          }}\n        />\n      </div>\n      <div className=\"CSVImporter_ColumnDragSourceArea__page\">\n        {dragState && !dragState.pointerStartInfo ? (\n          <div\n            className=\"CSVImporter_ColumnDragSourceArea__pageIndicator\"\n            role=\"status\"\n          >\n            {l10n.getDragSourceActiveStatus(dragState.column.code)}\n          </div>\n        ) : (\n          // show page number if needed (and treat as status role if it has changed)\n          // @todo changing role to status does not seem to work\n          pageCount > 1 && (\n            <div\n              className=\"CSVImporter_ColumnDragSourceArea__pageIndicator\"\n              role={pageChanged ? 'status' : 'text'}\n            >\n              {l10n.getDragSourcePageIndicator(page + 1, pageCount)}\n            </div>\n          )\n        )}\n\n        {pageContents}\n      </div>\n      <div className=\"CSVImporter_ColumnDragSourceArea__control\">\n        <IconButton\n          label={l10n.nextColumnsTooltip}\n          type=\"forward\"\n          disabled={page >= pageCount - 1}\n          onClick={() => {\n            setPageStart(\n              (prev) =>\n                Math.min(pageCount - 1, Math.floor(prev / pageSize) + 1) *\n                pageSize\n            );\n          }}\n        />\n      </div>\n    </section>\n  );\n};\n"
  },
  {
    "path": "src/components/fields-step/ColumnDragState.tsx",
    "content": "import { useState, useCallback, useRef } from 'react';\n\nimport { Column } from './ColumnPreview';\n\nexport interface DragState {\n  // null if this is a non-pointer-initiated state\n  pointerStartInfo: {\n    // position + size of originating card relative to viewport overlay\n    initialClientRect: DOMRectReadOnly;\n  } | null;\n\n  column: Column;\n  dropFieldName: string | null;\n  updateListeners: ((xy: number[]) => void)[];\n}\n\nexport interface DragInfo {\n  dragState: DragState | null;\n  columnSelectHandler: (column: Column) => void;\n  dragStartHandler: (\n    column: Column,\n    startFieldName: string | undefined,\n    initialClientRect: DOMRectReadOnly\n  ) => void;\n  dragMoveHandler: (movement: [number, number]) => void;\n  dragEndHandler: () => void;\n\n  dragHoverHandler: (fieldName: string, isOn: boolean) => void;\n  assignHandler: (fieldName: string) => void;\n  unassignHandler: (column: Column) => void;\n}\n\n// state machine to represent the steps taken to assign a column to target field:\n// - pick column (drag start or keyboard select)\n// - hover over field (while dragging only)\n// - assign picked column to field (drag end)\n// @todo move the useDrag setup outside as well?\nexport function useColumnDragState(\n  onColumnAssignment: (column: Column, fieldName: string | null) => void\n): DragInfo {\n  // wrap in ref to avoid re-triggering effects\n  const onColumnAssignmentRef = useRef(onColumnAssignment);\n  onColumnAssignmentRef.current = onColumnAssignment;\n\n  const [dragState, setDragState] = useState<DragState | null>(null);\n\n  const dragStartHandler = useCallback(\n    (\n      column: Column,\n      startFieldName: string | undefined,\n      initialClientRect: DOMRectReadOnly\n    ) => {\n      // create new pointer-based drag state\n      setDragState({\n        pointerStartInfo: {\n          initialClientRect\n        },\n        column,\n        dropFieldName: startFieldName !== undefined ? startFieldName : null,\n        updateListeners: []\n      });\n    },\n    []\n  );\n\n  const dragMoveHandler = useCallback(\n    (movement: [number, number]) => {\n      // @todo figure out a cleaner event stream solution\n      if (dragState) {\n        const listeners = dragState.updateListeners;\n        for (const listener of listeners) {\n          listener(movement);\n        }\n      }\n    },\n    [dragState]\n  );\n\n  const dragEndHandler = useCallback(() => {\n    setDragState(null);\n\n    if (dragState) {\n      onColumnAssignmentRef.current(dragState.column, dragState.dropFieldName);\n    }\n  }, [dragState]);\n\n  const columnSelectHandler = useCallback((column: Column) => {\n    setDragState((prev) => {\n      // toggle off if needed\n      if (prev && prev.column === column) {\n        return null;\n      }\n\n      return {\n        pointerStartInfo: null, // no draggable position information\n        column,\n        dropFieldName: null,\n        updateListeners: []\n      };\n    });\n  }, []);\n\n  const dragHoverHandler = useCallback((fieldName: string, isOn: boolean) => {\n    setDragState((prev): DragState | null => {\n      if (!prev) {\n        return prev;\n      }\n\n      if (isOn) {\n        // set the new drop target\n        return {\n          ...prev,\n          dropFieldName: fieldName\n        };\n      } else if (prev.dropFieldName === fieldName) {\n        // clear drop target if we are still the current one\n        return {\n          ...prev,\n          dropFieldName: null\n        };\n      }\n\n      // no changes by default\n      return prev;\n    });\n  }, []);\n\n  const assignHandler = useCallback(\n    (fieldName: string) => {\n      // clear active drag state\n      setDragState(null);\n\n      if (dragState) {\n        onColumnAssignmentRef.current(dragState.column, fieldName);\n      }\n    },\n    [dragState]\n  );\n\n  const unassignHandler = useCallback((column: Column) => {\n    // clear active drag state\n    setDragState(null);\n\n    onColumnAssignmentRef.current(column, null);\n  }, []);\n\n  return {\n    dragState,\n    dragStartHandler,\n    dragMoveHandler,\n    dragEndHandler,\n    dragHoverHandler,\n    columnSelectHandler,\n    assignHandler,\n    unassignHandler\n  };\n}\n"
  },
  {
    "path": "src/components/fields-step/ColumnDragTargetArea.scss",
    "content": "@import '../../theme.scss';\n\n.CSVImporter_ColumnDragTargetArea {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: flex-start;\n\n  &__box {\n    flex-basis: 25%;\n    flex-grow: 0;\n    flex-shrink: 1;\n    width: 0; // avoid interference from internal width\n    padding-top: 1em; // not using margin for cleaner percentage calculation\n    padding-right: 1em;\n  }\n\n  &__boxLabel {\n    margin-bottom: 0.25em;\n    font-weight: bold;\n    color: $textColor;\n    word-break: break-word;\n\n    & > b {\n      margin-left: 0.25em;\n      color: $errorTextColor;\n    }\n  }\n\n  &__boxValue {\n    position: relative; // for action and placeholder\n    z-index: 0; // contain the z-indexes of contents (to prevent e.g. placeholder being above drag object)\n  }\n\n  &__boxValueAction {\n    position: absolute;\n    top: 0; // icon button padding matches card padding\n    right: 0;\n    z-index: 1; // right above content\n  }\n\n  &__boxPlaceholderHelp {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 1; // right above content\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 98%; // nudge up a bit\n    padding: 0.5em;\n    text-align: center; // in case text wraps\n    color: $textSecondaryColor; // @todo font-size\n  }\n}\n"
  },
  {
    "path": "src/components/fields-step/ColumnDragTargetArea.tsx",
    "content": "import React, { useMemo, useRef } from 'react';\nimport { useDrag } from '@use-gesture/react';\n\nimport { FieldAssignmentMap } from '../../parser';\nimport { Column } from './ColumnPreview';\nimport { DragState } from './ColumnDragState';\nimport { ColumnDragCard } from './ColumnDragCard';\nimport { IconButton } from '../IconButton';\nimport { Field } from '../ImporterField';\n\nexport type FieldTouchedMap = { [name: string]: boolean | undefined };\n\nimport './ColumnDragTargetArea.scss';\nimport { useLocale } from '../../locale/LocaleContext';\n\nconst TargetBox: React.FC<{\n  field: Field;\n  hasHeaders: boolean; // for correct display of dummy card\n  flexBasis?: string; // style override\n  touched?: boolean;\n  assignedColumn: Column | null;\n  dragState: DragState | null;\n  eventBinder: (\n    column: Column,\n    startFieldName?: string\n  ) => ReturnType<typeof useDrag>;\n  onHover: (fieldName: string, isOn: boolean) => void;\n  onAssign: (fieldName: string) => void;\n  onUnassign: (column: Column) => void;\n}> = ({\n  field,\n  hasHeaders,\n  flexBasis,\n  touched,\n  assignedColumn,\n  dragState,\n  eventBinder,\n  onHover,\n  onAssign,\n  onUnassign\n}) => {\n  // respond to hover events when there is active mouse drag happening\n  // (not keyboard-emulated one)\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  // if this field is the current highlighted drop target,\n  // get the originating column data for display\n  const sourceColumn =\n    dragState && dragState.dropFieldName === field.name\n      ? dragState.column\n      : null;\n\n  // see if currently assigned column is being dragged again\n  const isReDragged = dragState ? dragState.column === assignedColumn : false;\n\n  // drag start handlers for columns that can be re-dragged (i.e. are assigned)\n  const dragStartHandlers = useMemo(\n    () =>\n      assignedColumn && !isReDragged\n        ? eventBinder(assignedColumn, field.name)\n        : {},\n    [eventBinder, assignedColumn, isReDragged, field.name]\n  );\n\n  const valueContents = useMemo(() => {\n    if (sourceColumn) {\n      return (\n        <ColumnDragCard rowCount={3} column={sourceColumn} isDropIndicator />\n      );\n    }\n\n    if (assignedColumn) {\n      return (\n        <ColumnDragCard\n          rowCount={3}\n          column={assignedColumn}\n          isShadow={isReDragged}\n          isDraggable={!isReDragged}\n        />\n      );\n    }\n\n    const hasError = touched && !field.isOptional;\n    return (\n      <ColumnDragCard\n        rowCount={3}\n        hasHeaders={hasHeaders}\n        hasError={hasError}\n      />\n    );\n  }, [hasHeaders, field, touched, assignedColumn, sourceColumn, isReDragged]);\n\n  const l10n = useLocale('fieldsStep');\n\n  // @todo mouse cursor changes to reflect draggable state\n  return (\n    <section\n      className=\"CSVImporter_ColumnDragTargetArea__box\"\n      aria-label={\n        field.isOptional\n          ? l10n.getDragTargetOptionalCaption(field.label)\n          : l10n.getDragTargetRequiredCaption(field.label)\n      }\n      ref={containerRef}\n      style={{ flexBasis }}\n      onPointerEnter={() => onHover(field.name, true)}\n      onPointerLeave={() => onHover(field.name, false)}\n    >\n      <div className=\"CSVImporter_ColumnDragTargetArea__boxLabel\" aria-hidden>\n        {field.label}\n        {field.isOptional ? null : <b>*</b>}\n      </div>\n\n      <div className=\"CSVImporter_ColumnDragTargetArea__boxValue\">\n        {!sourceColumn && !assignedColumn && (\n          <div\n            className=\"CSVImporter_ColumnDragTargetArea__boxPlaceholderHelp\"\n            aria-hidden\n          >\n            {l10n.dragTargetPlaceholder}\n          </div>\n        )}\n\n        <div {...dragStartHandlers} style={{ touchAction: 'none' }}>\n          {valueContents}\n        </div>\n\n        {/* tab order after column contents */}\n        {dragState && !dragState.pointerStartInfo ? (\n          <div className=\"CSVImporter_ColumnDragTargetArea__boxValueAction\">\n            <IconButton\n              label={l10n.getDragTargetAssignTooltip(dragState.column.code)}\n              small\n              type=\"forward\"\n              onClick={() => onAssign(field.name)}\n            />\n          </div>\n        ) : (\n          !sourceColumn &&\n          assignedColumn && (\n            <div className=\"CSVImporter_ColumnDragTargetArea__boxValueAction\">\n              <IconButton\n                label={l10n.dragTargetClearTooltip}\n                small\n                type=\"close\"\n                onClick={() => onUnassign(assignedColumn)}\n              />\n            </div>\n          )\n        )}\n      </div>\n    </section>\n  );\n};\n\nexport const ColumnDragTargetArea: React.FC<{\n  hasHeaders: boolean; // for correct display of dummy card\n  fields: Field[];\n  columns: Column[];\n  fieldRowSize?: number;\n  fieldTouched: FieldTouchedMap;\n  fieldAssignments: FieldAssignmentMap;\n  dragState: DragState | null;\n  eventBinder: (\n    // @todo import type from drag state tracker\n    column: Column,\n    startFieldName?: string\n  ) => ReturnType<typeof useDrag>;\n  onHover: (fieldName: string, isOn: boolean) => void;\n  onAssign: (fieldName: string) => void;\n  onUnassign: (column: Column) => void;\n}> = ({\n  hasHeaders,\n  fields,\n  columns,\n  fieldRowSize,\n  fieldTouched,\n  fieldAssignments,\n  dragState,\n  eventBinder,\n  onHover,\n  onAssign,\n  onUnassign\n}) => {\n  const l10n = useLocale('fieldsStep');\n\n  // override flex basis for unusual situations\n  const flexBasis = fieldRowSize ? `${100 / fieldRowSize}%` : undefined;\n\n  return (\n    <section\n      className=\"CSVImporter_ColumnDragTargetArea\"\n      aria-label={l10n.dragTargetAreaCaption}\n    >\n      {fields.map((field) => {\n        const assignedColumnIndex = fieldAssignments[field.name];\n\n        return (\n          <TargetBox\n            key={field.name}\n            field={field}\n            flexBasis={flexBasis}\n            touched={fieldTouched[field.name]}\n            hasHeaders={hasHeaders}\n            assignedColumn={\n              assignedColumnIndex !== undefined\n                ? columns[assignedColumnIndex]\n                : null\n            }\n            dragState={dragState}\n            eventBinder={eventBinder}\n            onHover={onHover}\n            onAssign={onAssign}\n            onUnassign={onUnassign}\n          />\n        );\n      })}\n    </section>\n  );\n};\n"
  },
  {
    "path": "src/components/fields-step/ColumnPreview.tsx",
    "content": "import { ImporterPreviewColumn } from '../ImporterProps';\n\nexport interface Column extends ImporterPreviewColumn {\n  code: string;\n}\n\n// spreadsheet-style column code computation (A, B, ..., Z, AA, AB, ..., etc)\nexport function generateColumnCode(value: number): string {\n  // ignore dummy index\n  if (value < 0) {\n    return '';\n  }\n\n  // first, determine how many base-26 letters there should be\n  // (because the notation is not purely positional)\n  let digitCount = 1;\n  let base = 0;\n  let next = 26;\n\n  while (next <= value) {\n    digitCount += 1;\n    base = next;\n    next = next * 26 + 26;\n  }\n\n  // then, apply normal positional digit computation on remainder above base\n  let remainder = value - base;\n\n  const digits = [];\n  while (digits.length < digitCount) {\n    const lastDigit = remainder % 26;\n    remainder = Math.floor((remainder - lastDigit) / 26); // applying floor just in case\n\n    // store ASCII code, with A as 0\n    digits.unshift(65 + lastDigit);\n  }\n\n  return String.fromCharCode.apply(null, digits);\n}\n\n// prepare spreadsheet-like column display information for given raw data preview\nexport function generatePreviewColumns(\n  firstRows: string[][],\n  hasHeaders: boolean\n): ImporterPreviewColumn[] {\n  const columnStubs = [...new Array(firstRows[0].length)];\n\n  return columnStubs.map((empty, index) => {\n    const values = firstRows.map((row) => row[index] || '');\n\n    const headerValue = hasHeaders ? values.shift() : undefined;\n\n    return {\n      index,\n      header: headerValue,\n      values\n    };\n  });\n}\n"
  },
  {
    "path": "src/components/fields-step/FieldsStep.tsx",
    "content": "import React, { useState, useMemo, useEffect, useRef } from 'react';\nimport { useDrag } from '@use-gesture/react';\n\nimport { FieldAssignmentMap } from '../../parser';\nimport { FileStepState } from '../file-step/FileStep';\nimport { ImporterFrame } from '../ImporterFrame';\nimport {\n  generatePreviewColumns,\n  generateColumnCode,\n  Column\n} from './ColumnPreview';\nimport { useColumnDragState } from './ColumnDragState';\nimport { ColumnDragObject } from './ColumnDragObject';\nimport { ColumnDragSourceArea } from './ColumnDragSourceArea';\nimport { ColumnDragTargetArea, FieldTouchedMap } from './ColumnDragTargetArea';\nimport { Field } from '../ImporterField';\nimport { useLocale } from '../../locale/LocaleContext';\n\nexport interface FieldsStepState {\n  fieldAssignments: FieldAssignmentMap;\n}\n\nexport const FieldsStep: React.FC<{\n  fields: Field[]; // current field definitions\n  displayFieldRowSize?: number; // override defaults for unusual widths\n  displayColumnPageSize?: number;\n\n  fileState: FileStepState; // output from the file selector step\n  prevState: FieldsStepState | null; // confirmed field selections so far\n\n  onChange: (state: FieldsStepState) => void;\n  onAccept: () => void;\n  onCancel: () => void;\n}> = ({\n  fields,\n  displayColumnPageSize,\n  displayFieldRowSize,\n  fileState,\n  prevState,\n  onChange,\n  onAccept,\n  onCancel\n}) => {\n  const l10n = useLocale('fieldsStep');\n\n  const onChangeRef = useRef(onChange);\n  onChangeRef.current = onChange;\n\n  const columns = useMemo<Column[]>(\n    () =>\n      generatePreviewColumns(\n        fileState.firstRows,\n        fileState.hasHeaders\n      ).map((item) => ({ ...item, code: generateColumnCode(item.index) })),\n    [fileState]\n  );\n\n  // field assignments state\n  const [fieldAssignments, setFieldAssignments] = useState<FieldAssignmentMap>(\n    prevState ? prevState.fieldAssignments : {}\n  );\n\n  // make sure there are no extra fields\n  useEffect(() => {\n    const removedFieldNames = Object.keys(fieldAssignments).filter(\n      (existingFieldName) =>\n        !fields.some((field) => field.name === existingFieldName)\n    );\n\n    if (removedFieldNames.length > 0) {\n      // @todo put everything inside this setter\n      setFieldAssignments((prev) => {\n        const copy = { ...prev };\n\n        removedFieldNames.forEach((fieldName) => {\n          delete copy[fieldName];\n        });\n\n        return copy;\n      });\n    }\n  }, [fields, fieldAssignments]);\n\n  // for any field, try to find an automatic match from known column names\n  useEffect(() => {\n    // prep insensitive/fuzzy match stems for known columns\n    const columnStemMap: Record<string, number | undefined> = {};\n    for (const column of columns) {\n      const stem = column.header?.trim().toLowerCase() || undefined;\n\n      if (stem) {\n        columnStemMap[stem] = column.index;\n      }\n    }\n\n    setFieldAssignments((prev) => {\n      // prepare a lookup of already assigned columns\n      const assignedColumns = columns.map(() => false);\n\n      for (const fieldName of Object.keys(prev)) {\n        const assignedColumnIndex = prev[fieldName];\n        if (assignedColumnIndex !== undefined) {\n          assignedColumns[assignedColumnIndex] = true;\n        }\n      }\n\n      // augment with new auto-assignments\n      const copy = { ...prev };\n      for (const field of fields) {\n        // ignore if field is already assigned\n        if (copy[field.name] !== undefined) {\n          continue;\n        }\n\n        // find by field stem\n        const fieldLabelStem = field.label.trim().toLowerCase(); // @todo consider normalizing other whitespace/non-letters\n        const matchingColumnIndex = columnStemMap[fieldLabelStem];\n\n        // ignore if equivalent column not found\n        if (matchingColumnIndex === undefined) {\n          continue;\n        }\n\n        // ignore if column is already assigned\n        if (assignedColumns[matchingColumnIndex]) {\n          continue;\n        }\n\n        // auto-assign the column\n        copy[field.name] = matchingColumnIndex;\n      }\n\n      return copy;\n    });\n  }, [fields, columns]);\n\n  // track which fields need to show validation warning\n  const [fieldTouched, setFieldTouched] = useState<FieldTouchedMap>({});\n  const [validationError, setValidationError] = useState<string | null>(null);\n\n  // clean up touched field map when dynamic field list changes\n  useEffect(() => {\n    setFieldTouched((prev) => {\n      const result: FieldTouchedMap = {};\n      for (const field of fields) {\n        result[field.name] = prev[field.name];\n      }\n\n      return result;\n    });\n  }, [fields]);\n\n  // abstract mouse drag/keyboard state tracker\n  const {\n    dragState,\n\n    dragStartHandler,\n    dragMoveHandler,\n    dragEndHandler,\n    dragHoverHandler,\n\n    columnSelectHandler,\n    assignHandler,\n    unassignHandler\n  } = useColumnDragState((column: Column, fieldName: string | null) => {\n    // update field assignment map state\n    setFieldAssignments((prev) => {\n      const currentFieldName = Object.keys(prev).find(\n        (fieldName) => prev[fieldName] === column.index\n      );\n\n      // see if there is nothing to do\n      if (currentFieldName === undefined && fieldName === null) {\n        return prev;\n      }\n\n      const copy = { ...prev };\n\n      // ensure dropped column does not show up elsewhere\n      if (currentFieldName) {\n        delete copy[currentFieldName];\n      }\n\n      // set new field column\n      if (fieldName !== null) {\n        copy[fieldName] = column.index;\n      }\n\n      return copy;\n    });\n\n    // mark for validation display\n    if (fieldName) {\n      setFieldTouched((prev) => {\n        if (prev[fieldName]) {\n          return prev;\n        }\n\n        return { ...prev, [fieldName]: true };\n      });\n    }\n  });\n\n  // drag gesture wire-up\n  const bindDrag = useDrag(\n    ({ first, last, movement, xy, args, currentTarget }) => {\n      if (first) {\n        const [column, startFieldName] = args as [Column, string | undefined];\n        const initialClientRect =\n          currentTarget instanceof HTMLElement\n            ? currentTarget.getBoundingClientRect()\n            : new DOMRect(xy[0], xy[1], 0, 0); // fall back on just pointer position\n\n        dragStartHandler(column, startFieldName, initialClientRect);\n      } else if (last) {\n        dragEndHandler();\n      } else {\n        dragMoveHandler(movement);\n      }\n    },\n    {\n      pointer: { capture: false } // turn off pointer capture to avoid interfering with hover tests\n    }\n  );\n\n  // when dragging, set root-level user-select:none to prevent text selection, see Importer.scss\n  // (done via class toggle to avoid interfering with any other dynamic style changes)\n  useEffect(() => {\n    if (dragState) {\n      document.body.classList.add('CSVImporter_dragging');\n    } else {\n      // remove text selection prevention after a delay (otherwise on iOS it still selects something)\n      const timeoutId = setTimeout(() => {\n        document.body.classList.remove('CSVImporter_dragging');\n      }, 200);\n\n      return () => {\n        // if another drag state comes along then cancel our delay and just clean up class right away\n        clearTimeout(timeoutId);\n        document.body.classList.remove('CSVImporter_dragging');\n      };\n    }\n  }, [dragState]);\n\n  // notify of current state\n  useEffect(() => {\n    onChangeRef.current({ fieldAssignments: { ...fieldAssignments } });\n  }, [fieldAssignments]);\n\n  return (\n    <ImporterFrame\n      fileName={fileState.file.name}\n      subtitle={l10n.stepSubtitle}\n      error={validationError}\n      onCancel={onCancel}\n      onNext={() => {\n        // mark all fields as touched (to show all the errors now)\n        const fullTouchedMap: typeof fieldTouched = {};\n        fields.forEach((field) => {\n          fullTouchedMap[field.name] = true;\n        });\n        setFieldTouched(fullTouchedMap);\n\n        // submit if validation succeeds\n        const hasUnassignedRequired = fields.some(\n          (field) =>\n            !field.isOptional && fieldAssignments[field.name] === undefined\n        );\n\n        if (!hasUnassignedRequired) {\n          onAccept();\n        } else {\n          setValidationError(l10n.requiredFieldsError);\n        }\n      }}\n      nextLabel={l10n.nextButton}\n    >\n      <ColumnDragSourceArea\n        columns={columns}\n        columnPageSize={displayColumnPageSize}\n        fieldAssignments={fieldAssignments}\n        dragState={dragState}\n        eventBinder={bindDrag}\n        onSelect={columnSelectHandler}\n        onUnassign={unassignHandler}\n      />\n\n      <ColumnDragTargetArea\n        hasHeaders={fileState.hasHeaders}\n        fieldRowSize={displayFieldRowSize}\n        fields={fields}\n        columns={columns}\n        fieldTouched={fieldTouched}\n        fieldAssignments={fieldAssignments}\n        dragState={dragState}\n        eventBinder={bindDrag}\n        onHover={dragHoverHandler}\n        onAssign={assignHandler}\n        onUnassign={unassignHandler}\n      />\n\n      <ColumnDragObject dragState={dragState} />\n    </ImporterFrame>\n  );\n};\n"
  },
  {
    "path": "src/components/file-step/FileSelector.scss",
    "content": "@import '../../theme.scss';\n\n.CSVImporter_FileSelector {\n  border: 0.25em dashed $fgColor;\n  padding: 4em;\n  border-radius: $borderRadius;\n  background: $fillColor;\n  text-align: center;\n  color: $textColor;\n  cursor: pointer;\n\n  &[data-active='true'] {\n    background: darken($fillColor, 10%);\n    transition: background 0.1s ease-out;\n  }\n}\n"
  },
  {
    "path": "src/components/file-step/FileSelector.tsx",
    "content": "import React, { useCallback, useRef } from 'react';\nimport { useDropzone } from 'react-dropzone';\nimport { useLocale } from '../../locale/LocaleContext';\n\nimport './FileSelector.scss';\n\nexport const FileSelector: React.FC<{ onSelected: (file: File) => void }> = ({\n  onSelected\n}) => {\n  const onSelectedRef = useRef(onSelected);\n  onSelectedRef.current = onSelected;\n\n  const dropHandler = useCallback((acceptedFiles: File[]) => {\n    // silently ignore if nothing to do\n    if (acceptedFiles.length < 1) {\n      return;\n    }\n\n    const file = acceptedFiles[0];\n    onSelectedRef.current(file);\n  }, []);\n\n  const { getRootProps, getInputProps, isDragActive } = useDropzone({\n    onDrop: dropHandler\n  });\n\n  const l10n = useLocale('fileStep');\n\n  return (\n    <div\n      className=\"CSVImporter_FileSelector\"\n      data-active={!!isDragActive}\n      {...getRootProps()}\n    >\n      <input {...getInputProps()} />\n\n      {isDragActive ? (\n        <span>{l10n.activeDragDropPrompt}</span>\n      ) : (\n        <span>{l10n.initialDragDropPrompt}</span>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/file-step/FileStep.scss",
    "content": "@import '../../theme.scss';\n\n.CSVImporter_FileStep {\n  &__header {\n    display: flex;\n    align-items: center;\n    margin-bottom: 0.5em;\n    font-size: $titleFontSize;\n    color: $textSecondaryColor;\n  }\n\n  &__headerToggle {\n    display: flex;\n    align-items: center;\n    margin-top: -0.5em; // allow for larger toggle element\n    margin-bottom: -0.5em;\n    margin-left: 1.5em;\n    color: $textColor;\n    cursor: pointer;\n\n    > input[type='checkbox'] {\n      margin-right: 0.5em;\n      width: 1.2em;\n      height: 1.2em;\n      cursor: pointer;\n    }\n  }\n\n  &__mainPendingBlock {\n    display: flex;\n    align-content: center;\n    justify-content: center;\n    padding: 2em;\n    color: $textSecondaryColor;\n  }\n}\n"
  },
  {
    "path": "src/components/file-step/FileStep.tsx",
    "content": "import React, { useMemo, useRef, useEffect, useState } from 'react';\n\nimport {\n  parsePreview,\n  PreviewResults,\n  PreviewReport,\n  CustomizablePapaParseConfig\n} from '../../parser';\nimport { ImporterFrame } from '../ImporterFrame';\nimport { FileSelector } from './FileSelector';\nimport { FormatRawPreview } from './FormatRawPreview';\nimport { FormatDataRowPreview } from './FormatDataRowPreview';\nimport { FormatErrorMessage } from './FormatErrorMessage';\n\nimport './FileStep.scss';\nimport { useLocale } from '../../locale/LocaleContext';\n\nexport interface FileStepState extends PreviewReport {\n  papaParseConfig: CustomizablePapaParseConfig; // config that was used for preview parsing\n  hasHeaders: boolean;\n}\n\nexport const FileStep: React.FC<{\n  customConfig: CustomizablePapaParseConfig;\n  defaultNoHeader?: boolean;\n  prevState: FileStepState | null;\n  onChange: (state: FileStepState | null) => void;\n  onAccept: () => void;\n}> = ({ customConfig, defaultNoHeader, prevState, onChange, onAccept }) => {\n  const l10n = useLocale('fileStep');\n\n  // seed from previous state as needed\n  const [selectedFile, setSelectedFile] = useState<File | null>(\n    prevState ? prevState.file : null\n  );\n\n  const [preview, setPreview] = useState<PreviewResults | null>(\n    () =>\n      prevState && {\n        parseError: undefined,\n        ...prevState\n      }\n  );\n\n  const [papaParseConfig, setPapaParseConfig] = useState(\n    prevState ? prevState.papaParseConfig : customConfig\n  );\n\n  const [hasHeaders, setHasHeaders] = useState(\n    prevState ? prevState.hasHeaders : false\n  );\n\n  // wrap in ref to avoid triggering effect\n  const customConfigRef = useRef(customConfig);\n  customConfigRef.current = customConfig;\n  const defaultNoHeaderRef = useRef(defaultNoHeader);\n  defaultNoHeaderRef.current = defaultNoHeader;\n  const onChangeRef = useRef(onChange);\n  onChangeRef.current = onChange;\n\n  // notify of current state\n  useEffect(() => {\n    onChangeRef.current(\n      preview && !preview.parseError\n        ? { ...preview, papaParseConfig, hasHeaders }\n        : null\n    );\n  }, [preview, papaParseConfig, hasHeaders]);\n\n  // perform async preview parse once for the given file\n  const asyncLockRef = useRef<number>(0);\n  useEffect(() => {\n    // clear other state when file selector is reset\n    if (!selectedFile) {\n      setPreview(null);\n      return;\n    }\n\n    // preserve existing state when parsing for this file is already complete\n    if (preview && preview.file === selectedFile) {\n      return;\n    }\n\n    const oplock = asyncLockRef.current;\n\n    // lock in the current PapaParse config instance for use in multiple spots\n    const config = customConfigRef.current;\n\n    // kick off the preview parse\n    parsePreview(selectedFile, config).then((results) => {\n      // ignore if stale\n      if (oplock !== asyncLockRef.current) {\n        return;\n      }\n\n      // save the results and the original config\n      setPreview(results);\n      setPapaParseConfig(config);\n\n      // pre-fill headers flag (only possible with >1 lines)\n      setHasHeaders(\n        results.parseError\n          ? false\n          : !defaultNoHeaderRef.current && !results.isSingleLine\n      );\n    });\n\n    return () => {\n      // invalidate current oplock on change or unmount\n      asyncLockRef.current += 1;\n    };\n  }, [selectedFile, preview]);\n\n  // clear selected file\n  // preview result content to display\n  const reportBlock = useMemo(() => {\n    if (!preview) {\n      return null;\n    }\n\n    if (preview.parseError) {\n      return (\n        <div className=\"CSVImporter_FileStep__mainResultBlock\">\n          <FormatErrorMessage onCancelClick={() => setSelectedFile(null)}>\n            {l10n.getImportError(\n              preview.parseError.message || String(preview.parseError)\n            )}\n          </FormatErrorMessage>\n        </div>\n      );\n    }\n\n    return (\n      <div className=\"CSVImporter_FileStep__mainResultBlock\">\n        <div className=\"CSVImporter_FileStep__header\">\n          {l10n.rawFileContentsHeading}\n        </div>\n\n        <FormatRawPreview\n          chunk={preview.firstChunk}\n          warning={preview.parseWarning}\n          onCancelClick={() => setSelectedFile(null)}\n        />\n\n        {preview.parseWarning ? null : (\n          <>\n            <div className=\"CSVImporter_FileStep__header\">\n              {l10n.previewImportHeading}\n              {!preview.isSingleLine && ( // hide setting if only one line anyway\n                <label className=\"CSVImporter_FileStep__headerToggle\">\n                  <input\n                    type=\"checkbox\"\n                    checked={hasHeaders}\n                    onChange={() => {\n                      setHasHeaders((prev) => !prev);\n                    }}\n                  />\n                  <span>{l10n.dataHasHeadersCheckbox}</span>\n                </label>\n              )}\n            </div>\n            <FormatDataRowPreview\n              hasHeaders={hasHeaders}\n              rows={preview.firstRows}\n            />\n          </>\n        )}\n      </div>\n    );\n  }, [preview, hasHeaders, l10n]);\n\n  if (!selectedFile) {\n    return <FileSelector onSelected={(file) => setSelectedFile(file)} />;\n  }\n\n  return (\n    <ImporterFrame\n      fileName={selectedFile.name}\n      nextDisabled={!preview || !!preview.parseError || !!preview.parseWarning}\n      onNext={() => {\n        if (!preview || preview.parseError) {\n          throw new Error('unexpected missing preview info');\n        }\n\n        onAccept();\n      }}\n      onCancel={() => setSelectedFile(null)}\n      nextLabel={l10n.nextButton}\n    >\n      {reportBlock || (\n        <div className=\"CSVImporter_FileStep__mainPendingBlock\">\n          {l10n.previewLoadingStatus}\n        </div>\n      )}\n    </ImporterFrame>\n  );\n};\n"
  },
  {
    "path": "src/components/file-step/FormatDataRowPreview.scss",
    "content": "@import '../../theme.scss';\n\n.CSVImporter_FormatDataRowPreview {\n  max-height: 12em;\n  min-height: 6em;\n  border: 1px solid $controlBorderColor;\n  overflow: scroll;\n\n  &__table {\n    width: 100%;\n    border-spacing: 0;\n    border-collapse: collapse;\n\n    > thead > tr > th {\n      font-style: italic;\n      font-weight: normal;\n      color: $textSecondaryColor;\n    }\n\n    > thead > tr > th,\n    > tbody > tr > td {\n      border-right: 1px solid rgba($controlBorderColor, 0.5);\n      padding: 0.5em 0.5em;\n      line-height: 1;\n      font-size: 0.75em;\n      white-space: nowrap;\n\n      &:last-child {\n        border-right: none;\n      }\n    }\n\n    // shrink space between rows\n    > thead + tbody > tr:first-child > td,\n    > tbody > tr + tr > td {\n      padding-top: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "src/components/file-step/FormatDataRowPreview.tsx",
    "content": "import React from 'react';\n\nimport './FormatDataRowPreview.scss';\n\nexport const FormatDataRowPreview: React.FC<{\n  hasHeaders: boolean;\n  rows: string[][];\n  // eslint-disable-next-line react/display-name\n}> = React.memo(({ hasHeaders, rows }) => {\n  const headerRow = hasHeaders ? rows[0] : null;\n  const bodyRows = hasHeaders ? rows.slice(1) : rows;\n\n  return (\n    <div className=\"CSVImporter_FormatDataRowPreview\">\n      <table className=\"CSVImporter_FormatDataRowPreview__table\">\n        {headerRow && (\n          <thead>\n            <tr>\n              {headerRow.map((item, itemIndex) => (\n                <th key={itemIndex}>{item}</th>\n              ))}\n            </tr>\n          </thead>\n        )}\n\n        <tbody>\n          {bodyRows.map((row, rowIndex) => (\n            <tr key={rowIndex}>\n              {row.map((item, itemIndex) => (\n                <td key={itemIndex}>{item}</td>\n              ))}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n});\n"
  },
  {
    "path": "src/components/file-step/FormatErrorMessage.scss",
    "content": "@import '../../theme.scss';\n\n.CSVImporter_FormatErrorMessage {\n  display: flex;\n  align-items: center;\n  padding: 0.5em 1em;\n  border-radius: $borderRadius;\n  background: $fillColor;\n  color: $errorTextColor;\n\n  & > span {\n    flex: 1 1 0;\n    margin-right: 1em;\n    width: 0; // avoid sizing on inner content\n    word-break: break-word;\n  }\n}\n"
  },
  {
    "path": "src/components/file-step/FormatErrorMessage.tsx",
    "content": "import React from 'react';\n\nimport { TextButton } from '../TextButton';\n\nimport './FormatErrorMessage.scss';\nimport { useLocale } from '../../locale/LocaleContext';\n\nexport const FormatErrorMessage: React.FC<{\n  onCancelClick: () => void;\n  // eslint-disable-next-line react/display-name\n}> = React.memo(({ onCancelClick, children }) => {\n  const l10n = useLocale('fileStep');\n  return (\n    <div className=\"CSVImporter_FormatErrorMessage\">\n      <span>{children}</span>\n      <TextButton onClick={onCancelClick}>{l10n.goBackButton}</TextButton>\n    </div>\n  );\n});\n"
  },
  {
    "path": "src/components/file-step/FormatRawPreview.scss",
    "content": "@import '../../theme.scss';\n\n.CSVImporter_FormatRawPreview {\n  &__scroll {\n    margin-bottom: 1.2em;\n    height: 6em;\n    overflow: auto;\n    border-radius: $borderRadius;\n    background: $invertedBgColor;\n    color: $invertedTextColor;\n  }\n\n  &__pre {\n    margin: 0; // override default\n    padding: 0.5em 1em;\n    line-height: 1.25;\n    font-size: 1.15em;\n\n    & > aside {\n      display: inline-block;\n      margin-left: 0.2em;\n      padding: 0 0.25em;\n      border-radius: $borderRadius * 0.5;\n      background: $controlBgColor;\n      font-size: 0.75em;\n      color: $controlBorderColor;\n      opacity: 0.75;\n    }\n  }\n}\n"
  },
  {
    "path": "src/components/file-step/FormatRawPreview.tsx",
    "content": "import React from 'react';\nimport { useLocale } from '../../locale/LocaleContext';\n\nimport { FormatErrorMessage } from './FormatErrorMessage';\n\nimport './FormatRawPreview.scss';\n\nconst RAW_PREVIEW_SIZE = 500;\n\nexport const FormatRawPreview: React.FC<{\n  chunk: string;\n  warning?: Papa.ParseError;\n  onCancelClick: () => void;\n  // eslint-disable-next-line react/display-name\n}> = React.memo(({ chunk, warning, onCancelClick }) => {\n  const chunkSlice = chunk.slice(0, RAW_PREVIEW_SIZE);\n  const chunkHasMore = chunk.length > RAW_PREVIEW_SIZE;\n\n  const l10n = useLocale('fileStep');\n\n  return (\n    <div className=\"CSVImporter_FormatRawPreview\">\n      <div className=\"CSVImporter_FormatRawPreview__scroll\">\n        <pre className=\"CSVImporter_FormatRawPreview__pre\">\n          {chunkSlice}\n          {chunkHasMore && <aside>...</aside>}\n        </pre>\n      </div>\n\n      {warning ? (\n        <FormatErrorMessage onCancelClick={onCancelClick}>\n          {l10n.getDataFormatError(warning.message || String(warning))}\n        </FormatErrorMessage>\n      ) : null}\n    </div>\n  );\n});\n"
  },
  {
    "path": "src/index.ts",
    "content": "export * from './components/ImporterProps';\nexport * from './components/Importer';\nexport * from './locale';\n"
  },
  {
    "path": "src/locale/ImporterLocale.ts",
    "content": "export interface ImporterLocale {\n  general: {\n    goToPreviousStepTooltip: string;\n  };\n\n  fileStep: {\n    initialDragDropPrompt: string;\n    activeDragDropPrompt: string;\n\n    getImportError: (message: string) => string;\n    getDataFormatError: (message: string) => string;\n    goBackButton: string;\n    nextButton: string;\n\n    rawFileContentsHeading: string;\n    previewImportHeading: string;\n    dataHasHeadersCheckbox: string;\n    previewLoadingStatus: string;\n  };\n\n  fieldsStep: {\n    stepSubtitle: string;\n    requiredFieldsError: string;\n    nextButton: string;\n\n    dragSourceAreaCaption: string;\n    getDragSourcePageIndicator: (\n      currentPage: number,\n      pageCount: number\n    ) => string;\n    getDragSourceActiveStatus: (columnCode: string) => string;\n    nextColumnsTooltip: string;\n    previousColumnsTooltip: string;\n    clearAssignmentTooltip: string;\n    selectColumnTooltip: string;\n    unselectColumnTooltip: string;\n\n    dragTargetAreaCaption: string;\n    getDragTargetOptionalCaption: (field: string) => string;\n    getDragTargetRequiredCaption: (field: string) => string;\n    dragTargetPlaceholder: string;\n    getDragTargetAssignTooltip: (columnCode: string) => string;\n    dragTargetClearTooltip: string;\n\n    columnCardDummyHeader: string;\n    getColumnCardHeader: (code: string) => string;\n  };\n\n  progressStep: {\n    stepSubtitle: string;\n    uploadMoreButton: string;\n    finishButton: string;\n    statusError: string;\n    statusComplete: string;\n    statusPending: string;\n    processedRowsLabel: string;\n  };\n}\n"
  },
  {
    "path": "src/locale/LocaleContext.tsx",
    "content": "import React from 'react';\nimport { ImporterLocale, enUS } from '.';\nimport { useContext } from 'react';\n\nexport const LocaleContext = React.createContext<ImporterLocale>(enUS);\n\ntype I18nNamespace = keyof ImporterLocale;\n\nexport function useLocale<N extends I18nNamespace>(\n  namespace: N\n): ImporterLocale[N] {\n  const locale = useContext(LocaleContext);\n  return locale[namespace]; // not using memo for basic property getter\n}\n"
  },
  {
    "path": "src/locale/index.ts",
    "content": "export type { ImporterLocale } from './ImporterLocale';\n\nexport { enUS } from './locale_enUS';\nexport { deDE } from './locale_deDE';\nexport { itIT } from './locale_itIT';\nexport { ptBR } from './locale_ptBR';\nexport { daDK } from './locale_daDK';\nexport { trTR } from './locale_trTR';\n"
  },
  {
    "path": "src/locale/locale_daDK.ts",
    "content": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */\nexport const daDK: ImporterLocale = {\n  general: {\n    goToPreviousStepTooltip: 'Gå til forrige trin'\n  },\n\n  fileStep: {\n    initialDragDropPrompt:\n      'Træk og slip CSV-fil her eller klik for at vælge fra en mappe',\n    activeDragDropPrompt: 'Slip CSV-fil her...',\n\n    getImportError: (message) => `Import-fejl: ${message}`,\n    getDataFormatError: (message) =>\n      `Kontrollér venligst data-formatering: ${message}`,\n    goBackButton: 'Gå tilbage',\n    nextButton: 'Vælg kolonner',\n\n    rawFileContentsHeading: 'Rå filindhold',\n    previewImportHeading: 'Forhåndsvis Import',\n    dataHasHeadersCheckbox: 'Data sidehoved',\n    previewLoadingStatus: 'Indlæser forhåndsvisning...'\n  },\n\n  fieldsStep: {\n    stepSubtitle: 'Vælg kolonner',\n    requiredFieldsError: 'Tildel venligst alle påkrævede felter',\n    nextButton: 'Importér',\n\n    dragSourceAreaCaption: 'Kolonner til import',\n    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>\n      `Side ${currentPage} af ${pageCount}`,\n    getDragSourceActiveStatus: (columnCode: string) =>\n      `Tildeler kolonne ${columnCode}`,\n    nextColumnsTooltip: 'Vis næste kolonner',\n    previousColumnsTooltip: 'Vis forrige kolonner',\n    clearAssignmentTooltip: 'Ryd kolonne-tildeling',\n    selectColumnTooltip: 'Vælg kolonne til tildeling',\n    unselectColumnTooltip: 'Fravælg kolonne',\n\n    dragTargetAreaCaption: 'Mål-felter',\n    getDragTargetOptionalCaption: (field) => `${field} (valgfri)`,\n    getDragTargetRequiredCaption: (field) => `${field} (påkrævet)`,\n    dragTargetPlaceholder: 'Træk kolonne hertil',\n    getDragTargetAssignTooltip: (columnCode: string) =>\n      `Tildel kolonne ${columnCode}`,\n    dragTargetClearTooltip: 'Ryd kolonne-tildeling',\n\n    columnCardDummyHeader: 'Disponibelt felt',\n    getColumnCardHeader: (code) => `Column ${code}`\n  },\n\n  progressStep: {\n    stepSubtitle: 'Importér',\n    uploadMoreButton: 'Upload Mere',\n    finishButton: 'Færdiggør',\n    statusError: 'Kunne ikke importere',\n    statusComplete: 'Færdig',\n    statusPending: 'Importerer...',\n    processedRowsLabel: 'Processerede rækker:'\n  }\n};\n"
  },
  {
    "path": "src/locale/locale_deDE.ts",
    "content": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */\nexport const deDE: ImporterLocale = {\n  general: {\n    goToPreviousStepTooltip: 'Zum vorherigen Schritt'\n  },\n\n  fileStep: {\n    initialDragDropPrompt:\n      'CSV-Datei auf dieses Feld ziehen, oder klicken um eine Datei auszuwählen',\n    activeDragDropPrompt: 'CSV-Datei auf dieses Feld ziehen...',\n    nextButton: 'Spalten auswählen',\n\n    getImportError: (message) => `Fehler beim Import: ${message}`,\n    getDataFormatError: (message: string) =>\n      `Bitte Datenformat überprüfen: ${message}`,\n    goBackButton: 'Zurück',\n\n    rawFileContentsHeading: 'Originaler Datei-Inhalt',\n    previewImportHeading: 'Import-Vorschau',\n    dataHasHeadersCheckbox: 'Mit Kopfzeile',\n    previewLoadingStatus: 'Vorschau wird geladen...'\n  },\n\n  fieldsStep: {\n    stepSubtitle: 'Spalten auswählen',\n    requiredFieldsError:\n      'Bitte weise allen nicht optionalen Spalten einen Wert zu',\n    nextButton: 'Importieren',\n\n    dragSourceAreaCaption: 'Zu importierende Spalte',\n    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>\n      `Seite ${currentPage} von ${pageCount}`,\n    getDragSourceActiveStatus: (columnCode: string) =>\n      `Spalte ${columnCode} zuweisen`,\n    nextColumnsTooltip: 'Nächste Spalten anzeigen',\n    previousColumnsTooltip: 'Vorherige Spalten anzeigen',\n    clearAssignmentTooltip: 'Zugewiesene Spalte entfernen',\n    selectColumnTooltip: 'Spalte zum Zuweisen auswählen',\n    unselectColumnTooltip: 'Spalte abwählen',\n\n    dragTargetAreaCaption: 'Zielfelder',\n    getDragTargetOptionalCaption: (field) => `${field} (optional)`,\n    getDragTargetRequiredCaption: (field) => `${field} (erforderlich)`,\n    dragTargetPlaceholder: 'Spalte hierher ziehen',\n    getDragTargetAssignTooltip: (columnCode: string) =>\n      `Spalte ${columnCode} zuweisen`,\n    dragTargetClearTooltip: 'Zugewiesene Spalte entfernen',\n\n    columnCardDummyHeader: 'Nicht zugewiesenes Feld',\n    getColumnCardHeader: (code) => `Spalte ${code}`\n  },\n\n  progressStep: {\n    stepSubtitle: 'Importieren',\n    uploadMoreButton: 'Weitere hochladen',\n    finishButton: 'Abschließen',\n    statusError: 'Konnte nicht importiert werden',\n    statusComplete: 'Fertig',\n    statusPending: 'Wird importiert...',\n    processedRowsLabel: 'Verarbeitete Zeilen:'\n  }\n};\n"
  },
  {
    "path": "src/locale/locale_enUS.ts",
    "content": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */\nexport const enUS: ImporterLocale = {\n  general: {\n    goToPreviousStepTooltip: 'Go to previous step'\n  },\n\n  fileStep: {\n    initialDragDropPrompt:\n      'Drag-and-drop CSV file here, or click to select in folder',\n    activeDragDropPrompt: 'Drop CSV file here...',\n\n    getImportError: (message) => `Import error: ${message}`,\n    getDataFormatError: (message) => `Please check data formatting: ${message}`,\n    goBackButton: 'Go Back',\n    nextButton: 'Choose columns',\n\n    rawFileContentsHeading: 'Raw File Contents',\n    previewImportHeading: 'Preview Import',\n    dataHasHeadersCheckbox: 'Data has headers',\n    previewLoadingStatus: 'Loading preview...'\n  },\n\n  fieldsStep: {\n    stepSubtitle: 'Select Columns',\n    requiredFieldsError: 'Please assign all required fields',\n    nextButton: 'Import',\n\n    dragSourceAreaCaption: 'Columns to import',\n    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>\n      `Page ${currentPage} of ${pageCount}`,\n    getDragSourceActiveStatus: (columnCode: string) =>\n      `Assigning column ${columnCode}`,\n    nextColumnsTooltip: 'Show next columns',\n    previousColumnsTooltip: 'Show previous columns',\n    clearAssignmentTooltip: 'Clear column assignment',\n    selectColumnTooltip: 'Select column for assignment',\n    unselectColumnTooltip: 'Unselect column',\n\n    dragTargetAreaCaption: 'Target fields',\n    getDragTargetOptionalCaption: (field) => `${field} (optional)`,\n    getDragTargetRequiredCaption: (field) => `${field} (required)`,\n    dragTargetPlaceholder: 'Drag column here',\n    getDragTargetAssignTooltip: (columnCode: string) =>\n      `Assign column ${columnCode}`,\n    dragTargetClearTooltip: 'Clear column assignment',\n\n    columnCardDummyHeader: 'Unassigned field',\n    getColumnCardHeader: (code) => `Column ${code}`\n  },\n\n  progressStep: {\n    stepSubtitle: 'Import',\n    uploadMoreButton: 'Upload More',\n    finishButton: 'Finish',\n    statusError: 'Could not import',\n    statusComplete: 'Complete',\n    statusPending: 'Importing...',\n    processedRowsLabel: 'Processed rows:'\n  }\n};\n"
  },
  {
    "path": "src/locale/locale_itIT.ts",
    "content": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */\nexport const itIT: ImporterLocale = {\n  general: {\n    goToPreviousStepTooltip: 'Torna indietro'\n  },\n\n  fileStep: {\n    initialDragDropPrompt:\n      'Trascina qui il file CSV, o clicca per selezionarlo dal PC',\n    activeDragDropPrompt: 'Rilascia qui il file CSV...',\n\n    getImportError: (message) => `Errore durante l'importazione: ${message}`,\n    getDataFormatError: (message) =>\n      `Si prega di controllare il formato dei dati: ${message}`,\n    goBackButton: 'Torna indietro',\n    nextButton: 'Seleziona le colonne',\n\n    rawFileContentsHeading: 'Contenuto delfile caricato',\n    previewImportHeading: 'Anteprima dei dati',\n    dataHasHeadersCheckbox: 'Intestazione presente nel file',\n    previewLoadingStatus: 'Caricamento anteprima...'\n  },\n\n  fieldsStep: {\n    stepSubtitle: 'Seleziona le colonne',\n    requiredFieldsError: 'Si prega di assegnare tutte le colonne richieste',\n    nextButton: 'Importa',\n\n    dragSourceAreaCaption: 'Colonne da importare',\n    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>\n      `Pagina ${currentPage} di ${pageCount}`,\n    getDragSourceActiveStatus: (columnCode: string) =>\n      `Assegnamento alla colonna ${columnCode}`,\n    nextColumnsTooltip: 'Mostra colonna successiva',\n    previousColumnsTooltip: 'Mostra colonna precedente',\n    clearAssignmentTooltip: 'Cancella tutti gli assegnamenti delle colonne',\n    selectColumnTooltip: 'Seleziona una colonna da assegnare',\n    unselectColumnTooltip: 'Deseleziona colonna',\n\n    dragTargetAreaCaption: 'Campi richiesti',\n    getDragTargetOptionalCaption: (field) => `${field} (opzionale)`,\n    getDragTargetRequiredCaption: (field) => `${field} (obbligatorio)`,\n    dragTargetPlaceholder: 'Trascina qui la colonna',\n    getDragTargetAssignTooltip: (columnCode: string) =>\n      `Assegnamento alla colonna ${columnCode}`,\n    dragTargetClearTooltip: 'Cancella gli assegnamenti alla colonna',\n\n    columnCardDummyHeader: 'Campo non assegnato',\n    getColumnCardHeader: (code) => `Column ${code}`\n  },\n\n  progressStep: {\n    stepSubtitle: 'Importa',\n    uploadMoreButton: 'Carica altri dati',\n    finishButton: 'Fine',\n    statusError: 'Errore di caricamento',\n    statusComplete: 'Completato',\n    statusPending: 'Caricamento...',\n    processedRowsLabel: 'Righe processate:'\n  }\n};\n"
  },
  {
    "path": "src/locale/locale_ptBR.ts",
    "content": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */\nexport const ptBR: ImporterLocale = {\n  general: {\n    goToPreviousStepTooltip: 'Voltar a etapa anterior'\n  },\n\n  fileStep: {\n    initialDragDropPrompt:\n      'Arraste e solte o arquivo CSV aqui ou clique para selecionar na pasta',\n    activeDragDropPrompt: 'Arraste e solte o arquivo CSV aqui...',\n\n    getImportError: (message) => `Erro ao importar: ${message}`,\n    getDataFormatError: (message) =>\n      `Por favor confira a formatação dos dados: ${message}`,\n    goBackButton: 'Voltar',\n    nextButton: 'Escolher Colunas',\n\n    rawFileContentsHeading: 'Conteúdo Bruto do Arquivo',\n    previewImportHeading: 'Visualizar Importação',\n    dataHasHeadersCheckbox: 'Os dados têm cabeçalhos',\n    previewLoadingStatus: 'Carregando visualização...'\n  },\n\n  fieldsStep: {\n    stepSubtitle: 'Selecionar Colunas',\n    requiredFieldsError: 'Atribua todos os campos obrigatórios',\n    nextButton: 'Importar',\n\n    dragSourceAreaCaption: 'Colunas para importar',\n    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>\n      `Página ${currentPage} de ${pageCount}`,\n    getDragSourceActiveStatus: (columnCode: string) =>\n      `Atribuindo coluna ${columnCode}`,\n    nextColumnsTooltip: 'Mostrar as próximas colunas',\n    previousColumnsTooltip: 'Mostrar colunas anteriores',\n    clearAssignmentTooltip: 'Limpar atribuição de coluna',\n    selectColumnTooltip: 'Selecione a coluna para atribuição',\n    unselectColumnTooltip: 'Desmarcar coluna',\n\n    dragTargetAreaCaption: 'Campos de destino',\n    getDragTargetOptionalCaption: (field) => `${field} (opcional)`,\n    getDragTargetRequiredCaption: (field) => `${field} (obrigatório)`,\n    dragTargetPlaceholder: 'Arraste a coluna aqui',\n    getDragTargetAssignTooltip: (columnCode: string) =>\n      `Atribuir coluna ${columnCode}`,\n    dragTargetClearTooltip: 'Limpar atribuição de coluna',\n\n    columnCardDummyHeader: 'Campo não atribuído',\n    getColumnCardHeader: (code) => `Coluna ${code}`\n  },\n\n  progressStep: {\n    stepSubtitle: 'Importar',\n    uploadMoreButton: 'Carregar mais',\n    finishButton: 'Finalizar',\n    statusError: 'Não foi possível importar',\n    statusComplete: 'Completo',\n    statusPending: 'Importando...',\n    processedRowsLabel: 'Linhas processadas:'\n  }\n};\n"
  },
  {
    "path": "src/locale/locale_trTR.ts",
    "content": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */\nexport const trTR: ImporterLocale = {\n  general: {\n    goToPreviousStepTooltip: 'Bir önceki adıma geri dön'\n  },\n\n  fileStep: {\n    initialDragDropPrompt:\n      'CSV dosyasını sürükleyin veya kutunun içine tıklayıp dosyayı seçin',\n    activeDragDropPrompt: 'CSV dosyasını buraya bırakın...',\n\n    getImportError: (message) => `Import hatası: ${message}`,\n    getDataFormatError: (message) =>\n      `Lütfen veri formatını kontrol edin: ${message}`,\n    goBackButton: 'Geri',\n    nextButton: 'Kolonları Seç',\n\n    rawFileContentsHeading: 'CSV dosyası içeriği',\n    previewImportHeading: 'Import önizleme',\n    dataHasHeadersCheckbox: 'Veride başlıklar var',\n    previewLoadingStatus: 'Önizleme yükleniyor...'\n  },\n\n  fieldsStep: {\n    stepSubtitle: 'Kolonları seçin',\n    requiredFieldsError: 'Lütfen zorunlu tüm alanları doldurun.',\n    nextButton: 'Import',\n\n    dragSourceAreaCaption: 'Import edilecek kolonlar',\n    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>\n      `${pageCount} sayfadan ${currentPage}. sayfadasınız`,\n    getDragSourceActiveStatus: (columnCode: string) =>\n      `${columnCode}. kolon atanıyor`,\n    nextColumnsTooltip: 'Sıradaki kolonları göster',\n    previousColumnsTooltip: 'Önceki kolonları göster',\n    clearAssignmentTooltip: 'Kolon atamayı temizle',\n    selectColumnTooltip: 'Atamak için kolon seçiniz',\n    unselectColumnTooltip: 'Kolonu seçmeyi bırak',\n\n    dragTargetAreaCaption: 'Hedef alanlar',\n    getDragTargetOptionalCaption: (field) => `${field} (opsiyonel)`,\n    getDragTargetRequiredCaption: (field) => `${field} (zorunlu)`,\n    dragTargetPlaceholder: 'Kolonu buraya sürükle',\n    getDragTargetAssignTooltip: (columnCode: string) =>\n      `${columnCode}. kolonu ata`,\n    dragTargetClearTooltip: 'Kolon atamayı temizle',\n\n    columnCardDummyHeader: 'Atanmamış alan',\n    getColumnCardHeader: (code) => `Kolon ${code}`\n  },\n\n  progressStep: {\n    stepSubtitle: 'Import',\n    uploadMoreButton: 'Sonrakileri yükle',\n    finishButton: 'Bitir',\n    statusError: 'Import edilemedi',\n    statusComplete: 'Tamamlandı',\n    statusPending: 'Import ediliyor...',\n    processedRowsLabel: 'İşlenen satır sayısı:'\n  }\n};\n"
  },
  {
    "path": "src/parser.ts",
    "content": "import Papa from 'papaparse';\nimport { Readable } from 'stream';\n\nexport interface CustomizablePapaParseConfig {\n  delimiter?: Papa.ParseConfig['delimiter'];\n  newline?: Papa.ParseConfig['newline'];\n  quoteChar?: Papa.ParseConfig['quoteChar'];\n  escapeChar?: Papa.ParseConfig['escapeChar'];\n  comments?: Papa.ParseConfig['comments'];\n  skipEmptyLines?: Papa.ParseConfig['skipEmptyLines'];\n  delimitersToGuess?: Papa.ParseConfig['delimitersToGuess'];\n  chunkSize?: Papa.ParseConfig['chunkSize'];\n  encoding?: Papa.ParseConfig['encoding'];\n}\n\nexport interface PreviewReport {\n  file: File;\n  firstChunk: string;\n  firstRows: string[][]; // always PREVIEW_ROWS count\n  isSingleLine: boolean;\n  parseWarning?: Papa.ParseError;\n}\n\n// success/failure report from the preview parse attempt\nexport type PreviewResults =\n  | {\n      parseError: Error | Papa.ParseError;\n      file: File;\n    }\n  | ({\n      parseError: undefined;\n    } & PreviewReport);\n\nexport const PREVIEW_ROW_COUNT = 5;\n\n// for each given target field name, source from original CSV column index\nexport type FieldAssignmentMap = { [name: string]: number | undefined };\n\nexport type BaseRow = { [name: string]: unknown };\n\nexport type ParseCallback<Row extends BaseRow> = (\n  rows: Row[],\n  info: {\n    startIndex: number;\n  }\n) => void | Promise<void>;\n\n// polyfill as implemented in https://github.com/eligrey/Blob.js/blob/master/Blob.js#L653\n// (this is for Safari pre v14.1)\nfunction streamForBlob(blob: Blob) {\n  if (blob.stream) {\n    return blob.stream();\n  }\n\n  const res = new Response(blob);\n  if (res.body) {\n    return res.body;\n  }\n\n  throw new Error('This browser does not support client-side file reads');\n}\n\n// incredibly cheap wrapper exposing a subset of stream.Readable interface just for PapaParse usage\n// @todo chunk size\nfunction nodeStreamWrapper(stream: ReadableStream, encoding: string): Readable {\n  let dataHandler: ((chunk: string) => void) | null = null;\n  let endHandler: ((unused: unknown) => void) | null = null;\n  let errorHandler: ((error: unknown) => void) | null = null;\n  let isStopped = false;\n\n  let pausePromise: Promise<void> | null = null;\n  let pauseResolver: (() => void) | null = null;\n\n  async function runReaderPump() {\n    // ensure this is truly in the next tick after uncorking\n    await Promise.resolve();\n\n    const streamReader = stream.getReader();\n    const decoder = new TextDecoder(encoding); // this also strips BOM by default\n\n    try {\n      // main reader pump loop\n      while (!isStopped) {\n        // perform read from upstream\n        const { done, value } = await streamReader.read();\n\n        // wait if we became paused since last data event\n        if (pausePromise) {\n          await pausePromise;\n        }\n\n        // check again if stopped and unlistened\n        if (isStopped || !dataHandler || !endHandler) {\n          return;\n        }\n\n        // final data flush and end notification\n        if (done) {\n          const lastChunkString = decoder.decode(value); // value is empty but pass just in case\n          if (lastChunkString) {\n            dataHandler(lastChunkString);\n          }\n\n          endHandler(undefined);\n          return;\n        }\n\n        // otherwise, normal data event after stream-safe decoding\n        const chunkString = decoder.decode(value, { stream: true });\n        dataHandler(chunkString);\n      }\n    } finally {\n      // always release the lock\n      streamReader.releaseLock();\n    }\n  }\n\n  const self = {\n    // marker properties to make PapaParse think this is a Readable object\n    readable: true,\n    read() {\n      throw new Error('only flowing mode is emulated');\n    },\n\n    on(event: string, callback: (param: unknown) => void) {\n      switch (event) {\n        case 'data':\n          if (dataHandler) {\n            throw new Error('two data handlers not supported');\n          }\n          dataHandler = callback;\n\n          // flowing state started, run the main pump loop\n          runReaderPump().catch((error) => {\n            if (errorHandler) {\n              errorHandler(error);\n            } else {\n              // rethrow to show error in console\n              throw error;\n            }\n          });\n\n          return;\n        case 'end':\n          if (endHandler) {\n            throw new Error('two end handlers not supported');\n          }\n          endHandler = callback;\n          return;\n        case 'error':\n          if (errorHandler) {\n            throw new Error('two error handlers not supported');\n          }\n          errorHandler = callback;\n          return;\n      }\n\n      throw new Error('unknown stream shim event: ' + event);\n    },\n\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    removeListener(event: string, callback: (param: unknown) => void) {\n      // stop and clear everything for simplicity\n      isStopped = true;\n      dataHandler = null;\n      endHandler = null;\n      errorHandler = null;\n    },\n\n    pause() {\n      if (!pausePromise) {\n        pausePromise = new Promise((resolve) => {\n          pauseResolver = resolve;\n        });\n      }\n      return self;\n    },\n\n    resume() {\n      if (pauseResolver) {\n        pauseResolver(); // waiting code will proceed in next tick\n        pausePromise = null;\n        pauseResolver = null;\n      }\n      return self;\n    }\n  };\n\n  // pass ourselves off as a real Node stream\n  return (self as unknown) as Readable;\n}\n\nexport function parsePreview(\n  file: File,\n  customConfig: CustomizablePapaParseConfig\n): Promise<PreviewResults> {\n  // wrap synchronous errors in promise\n  return new Promise<PreviewResults>((resolve) => {\n    let firstChunk: string | null = null;\n    let firstWarning: Papa.ParseError | undefined = undefined;\n    const rowAccumulator: string[][] = [];\n\n    function reportSuccess() {\n      // PapaParse normally complains first anyway, but might as well flag it\n      if (rowAccumulator.length === 0) {\n        return {\n          parseError: new Error('File is empty'),\n          file\n        };\n      }\n\n      // remember whether this file has only one line\n      const isSingleLine = rowAccumulator.length === 1;\n\n      // fill preview with blanks if needed\n      while (rowAccumulator.length < PREVIEW_ROW_COUNT) {\n        rowAccumulator.push([]);\n      }\n\n      resolve({\n        file,\n        parseError: undefined,\n        parseWarning: firstWarning || undefined,\n        firstChunk: firstChunk || '',\n        firstRows: rowAccumulator,\n        isSingleLine\n      });\n    }\n\n    // use our own multibyte-safe streamer, bail after first chunk\n    // (this used to add skipEmptyLines but that was hiding possible parse errors)\n    // @todo wait for upstream multibyte fix in PapaParse: https://github.com/mholt/PapaParse/issues/908\n    const nodeStream = nodeStreamWrapper(\n      streamForBlob(file),\n      customConfig.encoding || 'utf-8'\n    );\n\n    Papa.parse(nodeStream, {\n      ...customConfig,\n\n      chunkSize: 10000, // not configurable, preview only @todo make configurable\n      preview: PREVIEW_ROW_COUNT,\n\n      error: (error) => {\n        resolve({\n          parseError: error,\n          file\n        });\n      },\n      beforeFirstChunk: (chunk) => {\n        firstChunk = chunk;\n      },\n      chunk: ({ data, errors }, parser) => {\n        data.forEach((row) => {\n          const stringRow = (row as unknown[]).map((item) =>\n            typeof item === 'string' ? item : ''\n          );\n\n          rowAccumulator.push(stringRow);\n        });\n\n        if (errors.length > 0 && !firstWarning) {\n          firstWarning = errors[0];\n        }\n\n        // finish parsing once we got enough data, otherwise try for more\n        // (in some cases PapaParse flushes out last line as separate chunk)\n        if (rowAccumulator.length >= PREVIEW_ROW_COUNT) {\n          nodeStream.pause(); // parser does not pause source stream, do it here explicitly\n          parser.abort();\n\n          reportSuccess();\n        }\n      },\n      complete: reportSuccess\n    });\n  }).catch((error) => {\n    return {\n      parseError: error, // delegate message display to UI logic\n      file\n    };\n  });\n}\n\nexport interface ParserInput {\n  file: File;\n  papaParseConfig: CustomizablePapaParseConfig;\n  hasHeaders: boolean;\n  fieldAssignments: FieldAssignmentMap;\n}\n\nexport function processFile<Row extends BaseRow>(\n  input: ParserInput,\n  reportProgress: (deltaCount: number) => void,\n  callback: ParseCallback<Row>\n): Promise<void> {\n  const { file, hasHeaders, papaParseConfig, fieldAssignments } = input;\n  const fieldNames = Object.keys(fieldAssignments);\n\n  // wrap synchronous errors in promise\n  return new Promise<void>((resolve, reject) => {\n    // skip first line if needed\n    let skipLine = hasHeaders;\n    let processedCount = 0;\n\n    // use our own multibyte-safe decoding streamer\n    // @todo wait for upstream multibyte fix in PapaParse: https://github.com/mholt/PapaParse/issues/908\n    const nodeStream = nodeStreamWrapper(\n      streamForBlob(file),\n      papaParseConfig.encoding || 'utf-8'\n    );\n\n    Papa.parse(nodeStream, {\n      ...papaParseConfig,\n      chunkSize: papaParseConfig.chunkSize || 10000, // our own preferred default\n\n      error: (error) => {\n        reject(error);\n      },\n      chunk: ({ data }, parser) => {\n        // pause to wait until the rows are consumed\n        nodeStream.pause(); // parser does not pause source stream, do it here explicitly\n        parser.pause();\n\n        const skipped = skipLine && data.length > 0;\n\n        const rows = (skipped ? data.slice(1) : data).map((row) => {\n          const stringRow = (row as unknown[]).map((item) =>\n            typeof item === 'string' ? item : ''\n          );\n\n          const record = {} as { [name: string]: string | undefined };\n\n          fieldNames.forEach((fieldName) => {\n            const columnIndex = fieldAssignments[fieldName];\n            if (columnIndex !== undefined) {\n              record[fieldName] = stringRow[columnIndex];\n            }\n          });\n\n          return record as Row; // @todo look into a more precise setup\n        });\n\n        // clear line skip flag if there was anything to skip\n        if (skipped) {\n          skipLine = false;\n        }\n\n        // info snapshot for processing callback\n        const info = {\n          startIndex: processedCount\n        };\n\n        processedCount += rows.length;\n\n        // @todo collect errors\n        reportProgress(rows.length);\n\n        // wrap sync errors in promise\n        // (avoid invoking callback if there are no rows to consume)\n        const whenConsumed = new Promise<void>((resolve) => {\n          const result = rows.length ? callback(rows, info) : undefined;\n\n          // introduce delay to allow a frame render\n          setTimeout(() => resolve(result), 0);\n        });\n\n        // unpause parsing when done\n        whenConsumed.then(\n          () => {\n            nodeStream.resume();\n            parser.resume();\n          },\n          () => {\n            // @todo collect errors\n            nodeStream.resume();\n            parser.resume();\n          }\n        );\n      },\n      complete: () => {\n        resolve();\n      }\n    });\n  });\n}\n"
  },
  {
    "path": "src/theme.scss",
    "content": "$fgColor: #000;\n$fillColor: #f0f0f0;\n$controlBorderColor: #808080;\n$controlBgColor: #fff;\n\n$invertedTextColor: #f0f0f0;\n$invertedBgColor: #404040;\n\n$textColor: #202020;\n$textSecondaryColor: #808080;\n$textDisabledColor: rgba($textColor, 0.5);\n$errorTextColor: #c00000;\n$titleFontSize: 1.15em; // relative to body font\n\n$borderRadius: 0.4em;\n"
  },
  {
    "path": "test/.eslintrc.json",
    "content": "{\n  \"env\": {\n    \"node\": true,\n    \"mocha\": true\n  },\n  \"parser\": \"@typescript-eslint/parser\",\n  \"extends\": [\n    \"eslint:recommended\",\n    \"plugin:@typescript-eslint/recommended\",\n    \"plugin:prettier/recommended\",\n    \"prettier/@typescript-eslint\"\n  ],\n  \"plugins\": [\"@typescript-eslint\"]\n}\n"
  },
  {
    "path": "test/basics.test.ts",
    "content": "import { By, until } from 'selenium-webdriver';\nimport { expect } from 'chai';\nimport path from 'path';\n\nimport { runTestServer } from './testServer';\nimport { runDriver } from './webdriver';\nimport { runUI } from './uiSetup';\n\n// extra timeout allowance on CI\nconst testTimeoutMs = process.env.CI ? 20000 : 10000;\n\ndescribe('importer basics', () => {\n  const appUrl = runTestServer();\n  const getDriver = runDriver();\n  const initUI = runUI(getDriver);\n\n  beforeEach(async () => {\n    await getDriver().get(appUrl);\n\n    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {\n      ReactDOM.render(\n        React.createElement(\n          ReactCSVImporter,\n          {\n            dataHandler: (rows, info) => {\n              ((window as unknown) as Record<\n                string,\n                unknown\n              >).TEST_DATA_HANDLER_ROWS = rows;\n              ((window as unknown) as Record<\n                string,\n                unknown\n              >).TEST_DATA_HANDLER_INFO = info;\n\n              return new Promise((resolve) => {\n                ((window as unknown) as Record<\n                  string,\n                  unknown\n                >).TEST_DATA_HANDLER_RESOLVE = resolve;\n              });\n            }\n          },\n          [\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldA',\n              label: 'Field A'\n            }),\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldB',\n              label: 'Field B',\n              optional: true\n            })\n          ]\n        ),\n        document.getElementById('root')\n      );\n    });\n  });\n\n  it('shows file selector', async () => {\n    const fileInput = await getDriver().findElement(By.xpath('//input'));\n    expect(await fileInput.getAttribute('type')).to.equal('file');\n  });\n\n  describe('with file selected', () => {\n    beforeEach(async () => {\n      const filePath = path.resolve(__dirname, './fixtures/simple.csv');\n\n      const fileInput = await getDriver().findElement(By.xpath('//input'));\n      await fileInput.sendKeys(filePath);\n\n      await getDriver().wait(\n        until.elementLocated(By.xpath('//*[contains(., \"Raw File Contents\")]')),\n        300 // extra time\n      );\n    });\n\n    it('shows file name under active focus for screen reader', async () => {\n      const focusedHeading = await getDriver().switchTo().activeElement();\n      expect(await focusedHeading.getText()).to.equal('simple.csv');\n    });\n\n    it('shows raw file contents', async () => {\n      const rawPreview = await getDriver().findElement(By.xpath('//pre'));\n      expect(await rawPreview.getText()).to.have.string('AAAA,BBBB,CCCC,DDDD');\n    });\n\n    it('shows a preview table', async () => {\n      const tablePreview = await getDriver().findElement(By.xpath('//table'));\n\n      // header row\n      const tableCols = await tablePreview.findElements(\n        By.xpath('thead/tr/th')\n      );\n      const tableColStrings = await tableCols.reduce(\n        async (acc, col) => [...(await acc), await col.getText()],\n        Promise.resolve([] as string[])\n      );\n      expect(tableColStrings).to.deep.equal(['ColA', 'ColB', 'ColC', 'ColD']);\n\n      // first data row\n      const firstDataCells = await tablePreview.findElements(\n        By.xpath('tbody/tr[1]/td')\n      );\n      const firstDataCellStrings = await firstDataCells.reduce(\n        async (acc, col) => [...(await acc), await col.getText()],\n        Promise.resolve([] as string[])\n      );\n      expect(firstDataCellStrings).to.deep.equal([\n        'AAAA',\n        'BBBB',\n        'CCCC',\n        'DDDD'\n      ]);\n    });\n\n    it('allows toggling header row', async () => {\n      const headersCheckbox = await getDriver().findElement(\n        By.xpath(\n          '//label[contains(., \"Data has headers\")]/input[@type=\"checkbox\"]'\n        )\n      );\n\n      await headersCheckbox.click();\n\n      // ensure there are no headers now\n      const tablePreview = await getDriver().findElement(By.xpath('//table'));\n      const tableCols = await tablePreview.findElements(\n        By.xpath('thead/tr/th')\n      );\n      expect(tableCols.length).to.equal(0);\n\n      // first data row should now show the header strings\n      const firstDataCells = await tablePreview.findElements(\n        By.xpath('tbody/tr[1]/td')\n      );\n      const firstDataCellStrings = await firstDataCells.reduce(\n        async (acc, col) => [...(await acc), await col.getText()],\n        Promise.resolve([] as string[])\n      );\n      expect(firstDataCellStrings).to.deep.equal([\n        'ColA',\n        'ColB',\n        'ColC',\n        'ColD'\n      ]);\n    });\n\n    describe('with preview accepted', () => {\n      beforeEach(async () => {\n        const nextButton = await getDriver().findElement(\n          By.xpath('//button[text() = \"Choose columns\"]')\n        );\n\n        await nextButton.click();\n\n        await getDriver().wait(\n          until.elementLocated(By.xpath('//*[contains(., \"Select Columns\")]')),\n          300 // extra time\n        );\n      });\n\n      it('shows selection prompt under active focus for screen reader', async () => {\n        const focusedHeading = await getDriver().switchTo().activeElement();\n        expect(await focusedHeading.getText()).to.equal('Select Columns');\n      });\n\n      it('shows target fields', async () => {\n        const targetFields = await getDriver().findElements(\n          By.xpath('//section[@aria-label = \"Target fields\"]/section')\n        );\n\n        expect(targetFields.length).to.equal(2);\n        expect(await targetFields[0].getAttribute('aria-label')).to.equal(\n          'Field A (required)'\n        );\n        expect(await targetFields[1].getAttribute('aria-label')).to.equal(\n          'Field B (optional)'\n        );\n      });\n\n      it('does not allow to proceed without assignment', async () => {\n        const nextButton = await getDriver().findElement(\n          By.xpath('//button[text() = \"Import\"]')\n        );\n\n        await nextButton.click();\n\n        await getDriver().wait(\n          until.elementLocated(\n            By.xpath('//*[contains(., \"Please assign all required fields\")]')\n          ),\n          300 // extra time\n        );\n      });\n\n      it('offers keyboard-only select start buttons', async () => {\n        const selectButtons = await getDriver().findElements(\n          By.xpath('//button[@aria-label = \"Select column for assignment\"]')\n        );\n\n        expect(selectButtons.length).to.equal(4);\n      });\n\n      describe('with assigned field', () => {\n        beforeEach(async () => {\n          // start the keyboard-based selection mode\n          const focusedHeading = await getDriver().switchTo().activeElement();\n          await focusedHeading.sendKeys('\\t'); // tab to next element\n\n          const selectButton = await getDriver().findElement(\n            By.xpath(\n              '//button[@aria-label = \"Select column for assignment\"][1]'\n            )\n          );\n          await selectButton.sendKeys('\\n'); // cannot use click\n\n          await getDriver().wait(\n            until.elementLocated(\n              By.xpath('//*[contains(., \"Assigning column A\")]')\n            ),\n            200\n          );\n\n          const assignButton = await getDriver().findElement(\n            By.xpath('//button[@aria-label = \"Assign column A\"]')\n          );\n          await assignButton.click();\n        });\n\n        describe('with confirmation to start processing', () => {\n          beforeEach(async () => {\n            const nextButton = await getDriver().findElement(\n              By.xpath('//button[text() = \"Import\"]')\n            );\n\n            await nextButton.click();\n\n            await getDriver().wait(\n              until.elementLocated(\n                By.xpath(\n                  '//button[@aria-label = \"Go to previous step\"]/../*[contains(., \"Import\")]'\n                )\n              ),\n              200\n            );\n          });\n\n          it('sets focus on next heading', async () => {\n            const focusedHeading = await getDriver().switchTo().activeElement();\n            expect(await focusedHeading.getText()).to.equal('Import');\n          });\n\n          it('does not finish until dataHandler returns', async () => {\n            await getDriver().sleep(300);\n\n            const focusedHeading = await getDriver().switchTo().activeElement();\n            expect(await focusedHeading.getText()).to.equal('Import');\n          });\n\n          describe('after dataHandler is complete', () => {\n            beforeEach(async () => {\n              await getDriver().executeScript(\n                'window.TEST_DATA_HANDLER_RESOLVE()'\n              );\n              await getDriver().wait(\n                until.elementLocated(By.xpath('//*[contains(., \"Complete\")]')),\n                200\n              );\n            });\n\n            it('has active focus on completion message', async () => {\n              const focusedHeading = await getDriver()\n                .switchTo()\n                .activeElement();\n              expect(await focusedHeading.getText()).to.equal('Complete');\n            });\n\n            it('produces parsed data with correct fields', async () => {\n              const parsedData = await getDriver().executeScript(\n                'return window.TEST_DATA_HANDLER_ROWS'\n              );\n              const chunkInfo = await getDriver().executeScript(\n                'return window.TEST_DATA_HANDLER_INFO'\n              );\n\n              expect(parsedData).to.deep.equal([\n                { fieldA: 'AAAA' },\n                { fieldA: 'EEEE' }\n              ]);\n              expect(chunkInfo).to.deep.equal({ startIndex: 0 });\n            });\n\n            it('does not show any interactable buttons', async () => {\n              const anyButtons = await getDriver().findElements(\n                By.xpath('//button')\n              );\n\n              expect(anyButtons.length).to.equal(1);\n              expect(await anyButtons[0].getAttribute('aria-label')).to.equal(\n                'Go to previous step'\n              );\n              expect(await anyButtons[0].getAttribute('disabled')).to.equal(\n                'true'\n              );\n            });\n          });\n        });\n      });\n    });\n  });\n}).timeout(testTimeoutMs);\n"
  },
  {
    "path": "test/bom.test.ts",
    "content": "import { expect } from 'chai';\nimport path from 'path';\n\nimport { runTestServer } from './testServer';\nimport { runDriver } from './webdriver';\nimport { runUI, uiHelperSetup } from './uiSetup';\nimport { ImportInfo } from '../src/components/ImporterProps';\n\ntype RawWindow = Record<string, unknown>;\n\n// extra timeout allowance on CI\nconst testTimeoutMs = process.env.CI ? 20000 : 10000;\n\ndescribe('importer with input containing BOM character', () => {\n  const appUrl = runTestServer();\n  const getDriver = runDriver();\n  const initUI = runUI(getDriver);\n  const {\n    uploadFile,\n    getDisplayedPreviewData,\n    advanceToFieldStepAndFinish\n  } = uiHelperSetup(getDriver);\n\n  beforeEach(async () => {\n    await getDriver().get(appUrl);\n\n    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {\n      ReactDOM.render(\n        React.createElement(\n          ReactCSVImporter,\n          {\n            onStart: (info) => {\n              ((window as unknown) as RawWindow).TEST_ON_START_INFO = info;\n            },\n            dataHandler: (rows, info) => {\n              ((window as unknown) as RawWindow).TEST_DATA_HANDLER_ROWS = rows;\n              ((window as unknown) as RawWindow).TEST_DATA_HANDLER_INFO = info;\n            }\n          },\n          [\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldA',\n              label: 'Field A'\n            }),\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldB',\n              label: 'Field B',\n              optional: true\n            })\n          ]\n        ),\n        document.getElementById('root')\n      );\n    });\n  });\n\n  describe('at preview stage', () => {\n    beforeEach(async () => {\n      await uploadFile(path.resolve(__dirname, './fixtures/bom.csv'));\n    });\n\n    it('shows correctly parsed preview table', async () => {\n      expect(await getDisplayedPreviewData()).to.deep.equal([\n        ['Date', 'Open', 'High', 'Low', 'Close', 'Adj Close', 'Volume'],\n        [\n          '2019-09-16',\n          '299.839996',\n          '301.140015',\n          '299.450012',\n          '300.160004',\n          '294.285339',\n          '58191200'\n        ]\n      ]);\n    });\n\n    describe('after accepting and assigning fields', () => {\n      beforeEach(async () => {\n        await advanceToFieldStepAndFinish();\n      });\n\n      it('reports correct import info', async () => {\n        const importInfo = await getDriver().executeScript(\n          'return window.TEST_ON_START_INFO'\n        );\n\n        expect(importInfo).to.have.property('preview');\n\n        const { preview } = importInfo as ImportInfo;\n        expect(preview).to.have.property('columns');\n        expect(preview.columns).to.be.an('array');\n\n        expect(preview.columns.map((item) => item.header)).to.deep.equal([\n          'Date', // should not have BOM prefix\n          'Open',\n          'High',\n          'Low',\n          'Close',\n          'Adj Close',\n          'Volume'\n        ]);\n      });\n\n      it('produces parsed data with correct fields', async () => {\n        const parsedData = await getDriver().executeScript(\n          'return window.TEST_DATA_HANDLER_ROWS'\n        );\n        const chunkInfo = await getDriver().executeScript(\n          'return window.TEST_DATA_HANDLER_INFO'\n        );\n\n        expect(parsedData).to.deep.equal([{ fieldA: '2019-09-16' }]);\n        expect(chunkInfo).to.deep.equal({ startIndex: 0 });\n      });\n    });\n  });\n}).timeout(testTimeoutMs);\n"
  },
  {
    "path": "test/customConfig.test.ts",
    "content": "import { By, until } from 'selenium-webdriver';\nimport { expect } from 'chai';\nimport path from 'path';\n\nimport { runTestServer } from './testServer';\nimport { runDriver } from './webdriver';\nimport { runUI } from './uiSetup';\n\n// extra timeout allowance on CI\nconst testTimeoutMs = process.env.CI ? 20000 : 10000;\n\ndescribe('importer with custom Papa Parse config', () => {\n  const appUrl = runTestServer();\n  const getDriver = runDriver();\n  const initUI = runUI(getDriver);\n\n  beforeEach(async () => {\n    await getDriver().get(appUrl);\n\n    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {\n      ReactDOM.render(\n        React.createElement(\n          ReactCSVImporter,\n          {\n            delimiter: '!', // not a normal guessable delimiter for Papa Parse\n            dataHandler: (rows, info) => {\n              ((window as unknown) as Record<\n                string,\n                unknown\n              >).TEST_DATA_HANDLER_ROWS = rows;\n              ((window as unknown) as Record<\n                string,\n                unknown\n              >).TEST_DATA_HANDLER_INFO = info;\n            }\n          },\n          [\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldA',\n              label: 'Field A'\n            }),\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldB',\n              label: 'Field B',\n              optional: true\n            })\n          ]\n        ),\n        document.getElementById('root')\n      );\n    });\n  });\n\n  describe('at preview stage', () => {\n    beforeEach(async () => {\n      const filePath = path.resolve(\n        __dirname,\n        './fixtures/customDelimited.txt'\n      );\n\n      const fileInput = await getDriver().findElement(By.xpath('//input'));\n      await fileInput.sendKeys(filePath);\n\n      await getDriver().wait(\n        until.elementLocated(By.xpath('//*[contains(., \"Raw File Contents\")]')),\n        300 // extra time\n      );\n    });\n\n    it('shows correctly parsed preview table', async () => {\n      const tablePreview = await getDriver().findElement(By.xpath('//table'));\n\n      // header row\n      const tableCols = await tablePreview.findElements(\n        By.xpath('thead/tr/th')\n      );\n      const tableColStrings = await tableCols.reduce(\n        async (acc, col) => [...(await acc), await col.getText()],\n        Promise.resolve([] as string[])\n      );\n      expect(tableColStrings).to.deep.equal(['val1', 'val2']);\n\n      // first data row\n      const firstDataCells = await tablePreview.findElements(\n        By.xpath('tbody/tr[1]/td')\n      );\n      const firstDataCellStrings = await firstDataCells.reduce(\n        async (acc, col) => [...(await acc), await col.getText()],\n        Promise.resolve([] as string[])\n      );\n      expect(firstDataCellStrings).to.deep.equal(['val3', 'val4']);\n    });\n\n    describe('after accepting and assigning fields', () => {\n      beforeEach(async () => {\n        const previewNextButton = await getDriver().findElement(\n          By.xpath('//button[text() = \"Choose columns\"]')\n        );\n\n        await previewNextButton.click();\n\n        await getDriver().wait(\n          until.elementLocated(By.xpath('//*[contains(., \"Select Columns\")]')),\n          300 // extra time\n        );\n\n        // start the keyboard-based selection mode\n        const focusedHeading = await getDriver().switchTo().activeElement();\n        await focusedHeading.sendKeys('\\t'); // tab to next element\n\n        const selectButton = await getDriver().findElement(\n          By.xpath('//button[@aria-label = \"Select column for assignment\"][1]')\n        );\n        await selectButton.sendKeys('\\n'); // cannot use click\n\n        await getDriver().wait(\n          until.elementLocated(\n            By.xpath('//*[contains(., \"Assigning column A\")]')\n          ),\n          200\n        );\n\n        const assignButton = await getDriver().findElement(\n          By.xpath('//button[@aria-label = \"Assign column A\"]')\n        );\n        await assignButton.click();\n\n        const fieldsNextButton = await getDriver().findElement(\n          By.xpath('//button[text() = \"Import\"]')\n        );\n\n        await fieldsNextButton.click();\n\n        await getDriver().wait(\n          until.elementLocated(\n            By.xpath(\n              '//button[@aria-label = \"Go to previous step\"]/../*[contains(., \"Import\")]'\n            )\n          ),\n          200\n        );\n\n        await getDriver().wait(\n          until.elementLocated(By.xpath('//*[contains(., \"Complete\")]')),\n          200\n        );\n      });\n\n      it('produces parsed data with correct fields', async () => {\n        const parsedData = await getDriver().executeScript(\n          'return window.TEST_DATA_HANDLER_ROWS'\n        );\n        const chunkInfo = await getDriver().executeScript(\n          'return window.TEST_DATA_HANDLER_INFO'\n        );\n\n        expect(parsedData).to.deep.equal([{ fieldA: 'val3' }]);\n        expect(chunkInfo).to.deep.equal({ startIndex: 0 });\n      });\n    });\n  });\n}).timeout(testTimeoutMs);\n"
  },
  {
    "path": "test/encoding.test.ts",
    "content": "import { expect } from 'chai';\nimport path from 'path';\n\nimport { runTestServer } from './testServer';\nimport { runDriver } from './webdriver';\nimport { runUI, uiHelperSetup } from './uiSetup';\n\ntype RawWindow = Record<string, unknown>;\n\n// extra timeout allowance on CI\nconst testTimeoutMs = process.env.CI ? 20000 : 10000;\n\ndescribe('importer with custom encoding setting', () => {\n  const appUrl = runTestServer();\n  const getDriver = runDriver();\n  const initUI = runUI(getDriver);\n  const {\n    uploadFile,\n    getDisplayedPreviewData,\n    advanceToFieldStepAndFinish\n  } = uiHelperSetup(getDriver);\n\n  beforeEach(async () => {\n    await getDriver().get(appUrl);\n\n    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {\n      ReactDOM.render(\n        React.createElement(\n          ReactCSVImporter,\n          {\n            encoding: 'windows-1250', // encoding incompatible with UTF-8\n            delimiter: ',',\n            dataHandler: (rows, info) => {\n              ((window as unknown) as RawWindow).TEST_DATA_HANDLER_ROWS = rows;\n              ((window as unknown) as RawWindow).TEST_DATA_HANDLER_INFO = info;\n            }\n          },\n          [\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldA',\n              label: 'Field A'\n            }),\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldB',\n              label: 'Field B',\n              optional: true\n            })\n          ]\n        ),\n        document.getElementById('root')\n      );\n    });\n  });\n\n  describe('at preview stage', () => {\n    beforeEach(async () => {\n      await uploadFile(\n        path.resolve(__dirname, './fixtures/encodingWindows1250.csv')\n      );\n    });\n\n    it('shows correctly parsed preview table', async () => {\n      expect(await getDisplayedPreviewData()).to.deep.equal([\n        ['value1', 'value2'],\n        ['Montréal', 'Köppen']\n      ]);\n    });\n\n    describe('after accepting and assigning fields', () => {\n      beforeEach(async () => {\n        await advanceToFieldStepAndFinish();\n      });\n\n      it('produces parsed data with correct fields', async () => {\n        const parsedData = await getDriver().executeScript(\n          'return window.TEST_DATA_HANDLER_ROWS'\n        );\n        const chunkInfo = await getDriver().executeScript(\n          'return window.TEST_DATA_HANDLER_INFO'\n        );\n\n        expect(parsedData).to.deep.equal([{ fieldA: 'Montréal' }]);\n        expect(chunkInfo).to.deep.equal({ startIndex: 0 });\n      });\n    });\n  });\n}).timeout(testTimeoutMs);\n"
  },
  {
    "path": "test/fixtures/bom.csv",
    "content": "﻿Date,Open,High,Low,Close,Adj Close,Volume\r\n2019-09-16,299.839996,301.140015,299.450012,300.160004,294.285339,58191200\r\n"
  },
  {
    "path": "test/fixtures/customDelimited.txt",
    "content": "val1!val2\nval3!val4\n"
  },
  {
    "path": "test/fixtures/encodingWindows1250.csv",
    "content": "value1,value2\r\nMontral,Kppen\r\n"
  },
  {
    "path": "test/fixtures/noeof.csv",
    "content": "ColA,ColB,ColC,ColD\nAAAA,BBBB,CCCC,DDDD\nEEEE,FFFF,GGGG,HHHH"
  },
  {
    "path": "test/fixtures/simple.csv",
    "content": "﻿ColA,ColB,ColC,ColD\r\nAAAA,BBBB,CCCC,DDDD\r\nEEEE,FFFF,GGGG,HHHH\r\n"
  },
  {
    "path": "test/noeof.test.ts",
    "content": "import { expect } from 'chai';\nimport path from 'path';\n\nimport { runTestServer } from './testServer';\nimport { runDriver } from './webdriver';\nimport { runUI, uiHelperSetup } from './uiSetup';\n\n// extra timeout allowance on CI\nconst testTimeoutMs = process.env.CI ? 20000 : 10000;\n\ndescribe('importer with input not terminated by EOL character at end of file', () => {\n  const appUrl = runTestServer();\n  const getDriver = runDriver();\n  const initUI = runUI(getDriver);\n  const {\n    uploadFile,\n    getDisplayedPreviewData,\n    advanceToFieldStepAndFinish\n  } = uiHelperSetup(getDriver);\n\n  beforeEach(async () => {\n    await getDriver().get(appUrl);\n\n    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {\n      ReactDOM.render(\n        React.createElement(\n          ReactCSVImporter,\n          {\n            dataHandler: (rows, info) => {\n              const rawWin = window as any; // eslint-disable-line @typescript-eslint/no-explicit-any\n              rawWin.TEST_DATA_HANDLER_ROWS = (\n                rawWin.TEST_DATA_HANDLER_ROWS || []\n              ).concat(rows);\n              rawWin.TEST_DATA_HANDLER_INFO = info;\n            }\n          },\n          [\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldA',\n              label: 'Field A'\n            }),\n            React.createElement(ReactCSVImporterField, {\n              name: 'fieldB',\n              label: 'Field B',\n              optional: true\n            })\n          ]\n        ),\n        document.getElementById('root')\n      );\n    });\n  });\n\n  describe('at preview stage', () => {\n    beforeEach(async () => {\n      await uploadFile(path.resolve(__dirname, './fixtures/noeof.csv'));\n    });\n\n    it('shows correctly parsed preview table', async () => {\n      expect(await getDisplayedPreviewData()).to.deep.equal([\n        ['ColA', 'ColB', 'ColC', 'ColD'],\n        ['AAAA', 'BBBB', 'CCCC', 'DDDD']\n      ]);\n    });\n\n    describe('after accepting and assigning fields', () => {\n      beforeEach(async () => {\n        await advanceToFieldStepAndFinish();\n      });\n\n      it('produces parsed data with correct fields', async () => {\n        // await getDriver().sleep(10000);\n\n        const parsedData = await getDriver().executeScript(\n          'return window.TEST_DATA_HANDLER_ROWS'\n        );\n        const chunkInfo = await getDriver().executeScript(\n          'return window.TEST_DATA_HANDLER_INFO'\n        );\n\n        expect(parsedData).to.deep.equal([\n          { fieldA: 'AAAA' },\n          { fieldA: 'EEEE' }\n        ]);\n\n        // chunk start may be 1 because the parser \"flushes\" the last line separately?\n        expect(chunkInfo).to.deep.equal({ startIndex: 1 });\n      });\n    });\n  });\n}).timeout(testTimeoutMs);\n"
  },
  {
    "path": "test/public/index.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />\n    <link rel=\"icon\" href=\"data:image/x-icon;,\" />\n\n    <link rel=\"stylesheet\" href=\"/index.css\" />\n    <script src=\"/index.js\"></script>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "test/testServer.ts",
    "content": "import path from 'path';\nimport webpack from 'webpack';\nimport WebpackDevServer from 'webpack-dev-server';\n\nconst TEST_SERVER_PORT = 8090;\n\n// @todo use pre-built dist folder instead (to properly test production artifacts)\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst appWebpackConfig = require('../webpack.config');\n\nexport function runTestServer(): string {\n  let testDevServer: WebpackDevServer | null = null; // internal handle\n\n  const serverUrl = `http://localhost:${TEST_SERVER_PORT}`;\n\n  before(async function () {\n    // override config to allow direct in-browser usage with test code\n    const webpackConfig = {\n      ...appWebpackConfig,\n\n      module: {\n        ...appWebpackConfig.module,\n\n        rules: [\n          ...appWebpackConfig.module.rules,\n\n          {\n            test: require.resolve('react'),\n            loader: 'expose-loader',\n            options: {\n              exposes: ['React']\n            }\n          },\n          {\n            test: require.resolve('react-dom'),\n            loader: 'expose-loader',\n            options: {\n              exposes: ['ReactDOM']\n            }\n          }\n        ]\n      },\n\n      output: {\n        ...appWebpackConfig.output,\n        publicPath: '/',\n\n        // browser-friendly settings\n        libraryTarget: 'global',\n        library: 'ReactCSVImporter'\n      },\n\n      // ensure everything is included instead of generating require() statements\n      externals: {},\n\n      mode: 'production',\n      watch: false\n    };\n\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const compiler = webpack(webpackConfig as any);\n\n    const devServer = new WebpackDevServer(compiler, {\n      contentBase: path.resolve(__dirname, './public'), // static test helper content\n      hot: false,\n      liveReload: false,\n      noInfo: true,\n      stats: 'errors-only'\n    });\n\n    // store reference for later cleanup\n    testDevServer = devServer;\n\n    const serverListenPromise = new Promise<void>((resolve, reject) => {\n      devServer.listen(TEST_SERVER_PORT, 'localhost', function (err) {\n        if (err) {\n          reject(err);\n        } else {\n          resolve();\n        }\n      });\n    });\n\n    const serverCompilationPromise = new Promise<void>((resolve) => {\n      compiler.hooks.done.tap('_', () => {\n        resolve();\n      });\n    });\n\n    await Promise.all([serverListenPromise, serverCompilationPromise]);\n  });\n\n  after(async function () {\n    const devServer = testDevServer;\n    testDevServer = null;\n\n    if (!devServer) {\n      throw new Error('dev server not initialized');\n    }\n\n    // wait for server to fully close\n    await new Promise<void>((resolve) => {\n      devServer.close(() => {\n        resolve();\n      });\n    });\n  });\n\n  return serverUrl;\n}\n"
  },
  {
    "path": "test/uiSetup.ts",
    "content": "import { By, until, ThenableWebDriver } from 'selenium-webdriver';\nimport ReactModule from 'react';\nimport ReactDOMModule from 'react-dom';\n\nimport {\n  ImporterProps,\n  ImporterFieldProps\n} from '../src/components/ImporterProps';\n\nexport type ScriptBody = (\n  r: typeof ReactModule,\n  rd: typeof ReactDOMModule,\n  im: (\n    props: ImporterProps<Record<string, unknown>>\n  ) => ReactModule.ReactElement,\n  imf: (props: ImporterFieldProps) => ReactModule.ReactElement\n) => void;\n\nexport function runUI(\n  getDriver: () => ThenableWebDriver\n): (script: ScriptBody) => Promise<void> {\n  async function runScript(script: ScriptBody) {\n    await getDriver().executeScript(\n      `(${script.toString()})(React, ReactDOM, ReactCSVImporter.Importer, ReactCSVImporter.ImporterField)`\n    );\n  }\n\n  // always clean up\n  afterEach(async () => {\n    await runScript((React, ReactDOM) => {\n      ReactDOM.unmountComponentAtNode(\n        document.getElementById('root') || document.body\n      );\n    });\n  });\n\n  return async function initUI(script: ScriptBody) {\n    await runScript(script);\n\n    await getDriver().wait(\n      until.elementLocated(By.xpath('//span[contains(., \"Drag-and-drop\")]')),\n      300 // a little extra time\n    );\n  };\n}\n\n// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types\nexport function uiHelperSetup(getDriver: () => ThenableWebDriver) {\n  return {\n    async uploadFile(filePath: string) {\n      const fileInput = await getDriver().findElement(By.xpath('//input'));\n      await fileInput.sendKeys(filePath);\n\n      await getDriver().wait(\n        until.elementLocated(By.xpath('//*[contains(., \"Raw File Contents\")]')),\n        300 // extra time\n      );\n    },\n\n    async getDisplayedPreviewData() {\n      const tablePreview = await getDriver().findElement(By.xpath('//table'));\n\n      // header row\n      const tableCols = await tablePreview.findElements(\n        By.xpath('thead/tr/th')\n      );\n      const tableColStrings = await tableCols.reduce(\n        async (acc, col) => [...(await acc), await col.getText()],\n        Promise.resolve([] as string[])\n      );\n\n      // first data row\n      const firstDataCells = await tablePreview.findElements(\n        By.xpath('tbody/tr[1]/td')\n      );\n      const firstDataCellStrings = await firstDataCells.reduce(\n        async (acc, col) => [...(await acc), await col.getText()],\n        Promise.resolve([] as string[])\n      );\n\n      return [tableColStrings, firstDataCellStrings];\n    },\n\n    async advanceToFieldStepAndFinish() {\n      const previewNextButton = await getDriver().findElement(\n        By.xpath('//button[text() = \"Choose columns\"]')\n      );\n\n      await previewNextButton.click();\n\n      await getDriver().wait(\n        until.elementLocated(By.xpath('//*[contains(., \"Select Columns\")]')),\n        300 // extra time\n      );\n\n      // start the keyboard-based selection mode\n      const focusedHeading = await getDriver().switchTo().activeElement();\n      await focusedHeading.sendKeys('\\t'); // tab to next element\n\n      const selectButton = await getDriver().findElement(\n        By.xpath('//button[@aria-label = \"Select column for assignment\"][1]')\n      );\n      await selectButton.sendKeys('\\n'); // cannot use click\n\n      await getDriver().wait(\n        until.elementLocated(\n          By.xpath('//*[contains(., \"Assigning column A\")]')\n        ),\n        200\n      );\n\n      const assignButton = await getDriver().findElement(\n        By.xpath('//button[@aria-label = \"Assign column A\"]')\n      );\n      await assignButton.click();\n\n      const fieldsNextButton = await getDriver().findElement(\n        By.xpath('//button[text() = \"Import\"]')\n      );\n\n      await fieldsNextButton.click();\n\n      await getDriver().wait(\n        until.elementLocated(\n          By.xpath(\n            '//button[@aria-label = \"Go to previous step\"]/../*[contains(., \"Import\")]'\n          )\n        ),\n        200\n      );\n\n      await getDriver().wait(\n        until.elementLocated(By.xpath('//*[contains(., \"Complete\")]')),\n        200\n      );\n    }\n  };\n}\n"
  },
  {
    "path": "test/webdriver.ts",
    "content": "import * as path from 'path';\nimport * as child_process from 'child_process';\nimport { Builder, ThenableWebDriver } from 'selenium-webdriver';\nimport * as chrome from 'selenium-webdriver/chrome';\n\nasync function getGlobalChromedriverPath() {\n  const yarnGlobalPath = await new Promise<string>((resolve, reject) => {\n    child_process.exec('yarn global dir', { timeout: 8000 }, (err, result) => {\n      if (err) {\n        reject(err);\n      } else {\n        resolve(result.trim());\n      }\n    });\n  });\n\n  return path.resolve(\n    yarnGlobalPath,\n    './node_modules/chromedriver/lib/chromedriver',\n    process.platform === 'win32' ? './chromedriver.exe' : './chromedriver'\n  );\n}\n\nexport function runDriver(): () => ThenableWebDriver {\n  let webdriver: ThenableWebDriver | null = null;\n\n  // same webdriver instance serves all the tests in the suite\n  before(async function () {\n    const chromedriverPath = await getGlobalChromedriverPath();\n\n    const service = new chrome.ServiceBuilder(chromedriverPath).build();\n    chrome.setDefaultService(service);\n\n    webdriver = new Builder()\n      .forBrowser('chrome')\n      .setChromeOptions(\n        process.env.CI ? new chrome.Options().headless() : new chrome.Options()\n      )\n      .build();\n  });\n\n  after(async function () {\n    if (!webdriver) {\n      throw new Error(\n        'cannot clean up webdriver because it was not initialized'\n      );\n    }\n\n    await webdriver.quit();\n\n    // complete cleanup\n    webdriver = null;\n  });\n\n  // expose singleton getter\n  return () => {\n    if (!webdriver) {\n      throw new Error('webdriver not initialized');\n    }\n\n    return webdriver;\n  };\n}\n"
  },
  {
    "path": "tsconfig.base.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es6\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"es6\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"es6\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"./tsconfig.base.json\",\n  \"include\": [\"src\", \"test\"]\n}\n"
  },
  {
    "path": "webpack.config.js",
    "content": "const path = require('path');\nconst webpack = require('webpack');\nconst { CleanWebpackPlugin } = require('clean-webpack-plugin');\nconst MiniCssExtractPlugin = require('mini-css-extract-plugin');\n\nmodule.exports = {\n  entry: { index: './src/index.ts' },\n  output: {\n    path: path.resolve(__dirname, 'dist'),\n    libraryTarget: 'commonjs2'\n  },\n  module: {\n    rules: [\n      {\n        test: /\\.tsx?$/,\n        use: [\n          {\n            loader: 'ts-loader',\n            options: {\n              configFile: 'tsconfig.base.json',\n              compilerOptions: {\n                noEmit: false\n              }\n            }\n          }\n        ],\n        exclude: /node_modules/\n      },\n      {\n        test: /\\.scss$/,\n        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']\n      }\n    ]\n  },\n  externals: {\n    papaparse: 'papaparse',\n    react: 'react',\n    'react-dom': 'react-dom',\n    'react-dropzone': 'react-dropzone',\n    'react-use-gesture': 'react-use-gesture'\n  },\n  resolve: {\n    extensions: ['.tsx', '.ts', '.js']\n  },\n  devtool: 'cheap-source-map',\n  optimization: {\n    minimize: false\n  },\n  plugins: [new MiniCssExtractPlugin(), new CleanWebpackPlugin()]\n};\n"
  }
]