Full Code of beamworks/react-csv-importer for AI

master 775a75cce773 cached
80 files
163.9 KB
43.4k tokens
50 symbols
1 requests
Download .txt
Repository: beamworks/react-csv-importer
Branch: master
Commit: 775a75cce773
Files: 80
Total size: 163.9 KB

Directory structure:
gitextract_2gw30632/

├── .editorconfig
├── .github/
│   └── workflows/
│       └── test.yml
├── .gitignore
├── .prettierrc
├── .storybook/
│   ├── main.js
│   └── preview.js
├── LICENSE.md
├── README.md
├── demo-sandbox/
│   ├── .gitignore
│   ├── .prettierrc
│   ├── index.css
│   ├── index.jsx
│   └── package.json
├── package.json
├── src/
│   ├── .eslintrc.json
│   ├── .stylelintrc
│   ├── components/
│   │   ├── IconButton.scss
│   │   ├── IconButton.tsx
│   │   ├── Importer.scss
│   │   ├── Importer.stories.tsx
│   │   ├── Importer.tsx
│   │   ├── ImporterField.tsx
│   │   ├── ImporterFrame.scss
│   │   ├── ImporterFrame.tsx
│   │   ├── ImporterProps.ts
│   │   ├── ProgressDisplay.scss
│   │   ├── ProgressDisplay.tsx
│   │   ├── TextButton.scss
│   │   ├── TextButton.tsx
│   │   ├── fields-step/
│   │   │   ├── ColumnDragCard.scss
│   │   │   ├── ColumnDragCard.tsx
│   │   │   ├── ColumnDragObject.scss
│   │   │   ├── ColumnDragObject.tsx
│   │   │   ├── ColumnDragSourceArea.scss
│   │   │   ├── ColumnDragSourceArea.tsx
│   │   │   ├── ColumnDragState.tsx
│   │   │   ├── ColumnDragTargetArea.scss
│   │   │   ├── ColumnDragTargetArea.tsx
│   │   │   ├── ColumnPreview.tsx
│   │   │   └── FieldsStep.tsx
│   │   └── file-step/
│   │       ├── FileSelector.scss
│   │       ├── FileSelector.tsx
│   │       ├── FileStep.scss
│   │       ├── FileStep.tsx
│   │       ├── FormatDataRowPreview.scss
│   │       ├── FormatDataRowPreview.tsx
│   │       ├── FormatErrorMessage.scss
│   │       ├── FormatErrorMessage.tsx
│   │       ├── FormatRawPreview.scss
│   │       └── FormatRawPreview.tsx
│   ├── index.ts
│   ├── locale/
│   │   ├── ImporterLocale.ts
│   │   ├── LocaleContext.tsx
│   │   ├── index.ts
│   │   ├── locale_daDK.ts
│   │   ├── locale_deDE.ts
│   │   ├── locale_enUS.ts
│   │   ├── locale_itIT.ts
│   │   ├── locale_ptBR.ts
│   │   └── locale_trTR.ts
│   ├── parser.ts
│   └── theme.scss
├── test/
│   ├── .eslintrc.json
│   ├── basics.test.ts
│   ├── bom.test.ts
│   ├── customConfig.test.ts
│   ├── encoding.test.ts
│   ├── fixtures/
│   │   ├── bom.csv
│   │   ├── customDelimited.txt
│   │   ├── encodingWindows1250.csv
│   │   ├── noeof.csv
│   │   └── simple.csv
│   ├── noeof.test.ts
│   ├── public/
│   │   └── index.html
│   ├── testServer.ts
│   ├── uiSetup.ts
│   └── webdriver.ts
├── tsconfig.base.json
├── tsconfig.json
└── webpack.config.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
[*]
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 2


================================================
FILE: .github/workflows/test.yml
================================================
name: e2e tests

on:
  push:
    branches: [master]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [16.x]

    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
      - run: yarn --frozen-lockfile
      - run: yarn test-prep
      - run: yarn test


================================================
FILE: .gitignore
================================================
/node_modules
/dist


================================================
FILE: .prettierrc
================================================
{
  "printWidth": 80,
  "singleQuote": true,
  "trailingComma": "none"
}


================================================
FILE: .storybook/main.js
================================================
module.exports = {
  stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/preset-scss'
  ]
};


================================================
FILE: .storybook/preview.js
================================================

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
}

================================================
FILE: LICENSE.md
================================================
MIT License

Copyright (c) 2020 Beamworks Enterprise Software Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# React CSV Importer

[![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)

This library combines an uploader + CSV parser + raw file preview + UI for custom user column
mapping, all in one.

Use this to provide a typical bulk data import experience:

- 📤 drag-drop or select a file for upload
- 👓 preview the raw uploaded data
- ✏ pick which columns to import
- ⏳ wait for backend logic to finish processing data

![React CSV Importer usage demo](https://github.com/beamworks/react-csv-importer/raw/59f967c13bbbd20eb2a663538797dd718f9bc57e/package-core/react-csv-importer-demo-20200915.gif)

[Try it in the live code sandbox](https://codesandbox.io/s/github/beamworks/react-csv-importer/tree/master/demo-sandbox)

### Feature summary:

- raw file preview
- drag-drop UI to remap input columns as needed
- i18n (EN, DA, DE, IT, PT, TR or custom)
- screen reader accessibility (yes, really!)
- keyboard a11y
- standalone CSS stylesheet (no frameworks required)
- existing parser implementation: Papa Parse CSV
- TypeScript support

### Enterprise-level data file handling:

- 1GB+ CSV file size (true streaming support without crashing browser)
- automatically strip leading BOM character in data
- async parsing logic (pause file read while your app makes backend updates)
- fixes a [multibyte streaming issue in PapaParse](https://github.com/mholt/PapaParse/issues/908)

## Install

```sh
# using NPM
npm install --save react-csv-importer

# using Yarn
yarn add react-csv-importer
```

Make sure that the bundled CSS stylesheet (`/dist/index.css`) is present in your app's page or bundle.

This 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.

## How It Works

Render 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.

Large 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.

Instead 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.

## Example Usage

```js
import { Importer, ImporterField } from 'react-csv-importer';

// include the widget CSS file whichever way your bundler supports it
import 'react-csv-importer/dist/index.css';

// in your component code:
<Importer
  dataHandler={async (rows, { startIndex }) => {
    // required, may be called several times
    // receives a list of parsed objects based on defined fields and user column mapping;
    // (if this callback returns a promise, the widget will wait for it before parsing more data)
    for (row of rows) {
      await myAppMethod(row);
    }
  }}
  defaultNoHeader={false} // optional, keeps "data has headers" checkbox off by default
  restartable={false} // optional, lets user choose to upload another file when import is complete
  onStart={({ file, preview, fields, columnFields }) => {
    // optional, invoked when user has mapped columns and started import
    prepMyAppForIncomingData();
  }}
  onComplete={({ file, preview, fields, columnFields }) => {
    // optional, invoked right after import is done (but user did not dismiss/reset the widget yet)
    showMyAppToastNotification();
  }}
  onClose={({ file, preview, fields, columnFields }) => {
    // optional, if this is specified the user will see a "Finish" button after import is done,
    // which will call this when clicked
    goToMyAppNextPage();
  }}

  // CSV options passed directly to PapaParse if specified:
  // delimiter={...}
  // newline={...}
  // quoteChar={...}
  // escapeChar={...}
  // comments={...}
  // skipEmptyLines={...}
  // delimitersToGuess={...}
  // chunkSize={...} // defaults to 10000
  // encoding={...} // defaults to utf-8, see FileReader API
>
  <ImporterField name="name" label="Name" />
  <ImporterField name="email" label="Email" />
  <ImporterField name="dob" label="Date of Birth" optional />
  <ImporterField name="postalCode" label="Postal Code" optional />
</Importer>;
```

In 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.

The `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:

```js
{
  rawData: '...', // raw string contents of first file chunk
  columns: [ // array of preview columns, e.g.:
    { index: 0, header: 'Date', values: [ '2020-09-20', '2020-09-25' ] },
    { index: 1, header: 'Name', values: [ 'Alice', 'Bob' ] }
  ],
  skipHeaders: false, // true when user selected that data has no headers
  parseWarning: undefined, // any non-blocking warning object produced by Papa Parse
}
```

Importer 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.

For more, please see [storybook examples](src/components/Importer.stories.tsx).

## Internationalization (i18n) and Localization (l10n)

You can swap the text used in the UI to a different locale.

```
import { Importer, deDE } from 'react-csv-importer';

// provide the locale to main UI
<Importer
  locale={deDE}
  // normal props, etc
/>
```

These locales are provided as part of the NPM module:

- `en-US`
- `de-DE`
- `it-IT`
- `pt-BR`
- `da-DK`
- `tr-TR`

You 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.

## Dependencies

- [Papa Parse](https://www.papaparse.com/) for CSV parsing
- [react-dropzone](https://react-dropzone.js.org/) for file upload
- [@use-gesture/react](https://github.com/pmndrs/use-gesture) for drag-and-drop

## Local Development

Perform local `git clone`, etc. Then ensure modules are installed:

```sh
yarn
```

To start Storybook to have a hot-reloaded local sandbox:

```sh
yarn storybook
```

To run the end-to-end test suite:

```sh
yarn test
```

You 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!

## Changes

- 0.8.1
  - fix double-start issue for React 18 dev mode
- 0.8.0
  - 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))
  - refactor to work with later versions of @use-gesture/react (thanks [**@dbismut**](https://github.com/dbismut))
  - upgrade to newer version of react-dropzone
  - rename assumeNoHeaders to defaultNoHeader (with deprecation warning)
  - rename processChunk to dataHandler (with deprecation warning)
  - expose display width customization (`displayColumnPageSize`, `displayFieldRowSize`)
  - bug fixes for button type and labels
- 0.7.1
  - fix peerDependencies for React 18+ (thanks [**@timarney**](https://github.com/timarney))
  - hide Finish button by default
  - button label tweaks
- 0.7.0
  - add i18n (thanks [**@tstehr**](https://github.com/tstehr) and [**@Valodim**](https://github.com/Valodim))
- 0.6.0
  - improve multibyte stream parsing safety
  - support all browser encodings via TextDecoder
  - remove readable-web-to-node-stream dependency
  - bug fix for preview of short no-EOL files
- 0.5.2
  - update readable-web-to-node-stream to have stream shim
  - use npm prepare script for easier fork installs
- 0.5.1
  - correctly use custom Papa Parse config for the main processing stream
  - drag-drop fixes on scrolled pages
  - bug fixes for older Safari, mobile issues
- 0.5.0
  - report file preview to callbacks and render-prop
  - report startIndex in processChunk callback
- 0.4.1
  - clearer error display
  - add more information about ongoing import
- 0.4.0
  - auto-assign column headers
- 0.3.0
  - allow passing PapaParse config options
- 0.2.3
  - tweak TS compilation targets
  - live editable sandbox link in docs
- 0.2.2
  - empty file checks
  - fix up package metadata
  - extra docs
- 0.2.1
  - update index.d.ts generation
- 0.2.0
  - bundling core package using Webpack
  - added source maps
- 0.1.0
  - first beta release
  - true streaming support using shim for PapaParse
  - lifecycle hooks receive info about the import


================================================
FILE: demo-sandbox/.gitignore
================================================
/node_modules

# ignore this lockfile to keep versioning simple
/yarn.lock


================================================
FILE: demo-sandbox/.prettierrc
================================================
{
  "printWidth": 80,
  "singleQuote": false
}


================================================
FILE: demo-sandbox/index.css
================================================
body {
  font-family: Arial, Helvetica, sans-serif;
  padding: 1em;
  background: #e0f4f8;
  background-image: radial-gradient(#d0e0e0 1px, transparent 0);
  background-size: 24px 24px;
}

h1 {
  color: #304040;
}


================================================
FILE: demo-sandbox/index.jsx
================================================
import React from "react";
import ReactDOM from "react-dom";
import { Importer, ImporterField } from "react-csv-importer";

// theme CSS for React CSV Importer
import "react-csv-importer/dist/index.css";

// basic styling and font for sandbox window
import "./index.css";

// sample importer usage snippet, play around with the settings and try it out!
// (open console output to see sample results)
ReactDOM.render(
  <div>
    <h1>React CSV Importer sandbox</h1>

    <Importer
      dataHandler={async (rows) => {
        // required, receives a list of parsed objects based on defined fields and user column mapping;
        // may be called several times if file is large
        // (if this callback returns a promise, the widget will wait for it before parsing more data)
        console.log("received batch of rows", rows);

        // mock timeout to simulate processing
        await new Promise((resolve) => setTimeout(resolve, 500));
      }}
      chunkSize={10000} // optional, internal parsing chunk size in bytes
      defaultNoHeader={false} // optional, keeps "data has headers" checkbox off by default
      restartable={false} // optional, lets user choose to upload another file when import is complete
      onStart={({ file, fields }) => {
        // optional, invoked when user has mapped columns and started import
        console.log("starting import of file", file, "with fields", fields);
      }}
      onComplete={({ file, fields }) => {
        // optional, invoked right after import is done (but user did not dismiss/reset the widget yet)
        console.log("finished import of file", file, "with fields", fields);
      }}
      onClose={() => {
        // optional, invoked when import is done and user clicked "Finish"
        // (if this is not specified, the widget lets the user upload another file)
        console.log("importer dismissed");
      }}
    >
      <ImporterField name="name" label="Name" />
      <ImporterField name="email" label="Email" />
      <ImporterField name="dob" label="Date of Birth" optional />
      <ImporterField name="postalCode" label="Postal Code" optional />
    </Importer>
  </div>,
  document.getElementById("root")
);


================================================
FILE: demo-sandbox/package.json
================================================
{
  "name": "demo-sandbox",
  "version": "0.0.1",
  "description": "Sample react-csv-importer usage snippet",
  "main": "index.jsx",
  "author": "Nick Matantsev <nick.matantsev@beamworks.io>",
  "license": "ISC",
  "dependencies": {
    "react": "^18.0.0",
    "react-csv-importer": "^0.8.1",
    "react-dom": "^18.0.0"
  }
}


================================================
FILE: package.json
================================================
{
  "name": "react-csv-importer",
  "version": "0.8.1",
  "description": "React CSV import widget with user-customizable mapping",
  "keywords": [
    "react",
    "csv",
    "upload",
    "parser",
    "import",
    "preview",
    "raw preview",
    "TextDecoder",
    "papa parse",
    "papaparse"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/beamworks/react-csv-importer"
  },
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist/**"
  ],
  "scripts": {
    "prepare": "webpack --mode production && dts-bundle-generator -o dist/index.d.ts src/index.ts",
    "lint": "eslint --max-warnings=0 --ext ts --ext tsx src",
    "lint-fix": "eslint --max-warnings=0 --ext ts --ext tsx src --fix",
    "stylelint": "stylelint \"src/**/*.scss\"",
    "stylelint-fix": "stylelint \"src/**/*.scss\" --fix",
    "test-prep": "yarn global add chromedriver@latest",
    "test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} mocha --require ts-node/register --timeout 30000 test/**/*.test.ts",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook",
    "dist": "yarn prepare"
  },
  "author": "Nick Matantsev <nick.matantsev@beamworks.io>",
  "license": "MIT",
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "src/**/*.{ts,tsx}": "eslint --max-warnings=0",
    "src/**/*.scss": "stylelint",
    "test/**/*.{js,ts}": "eslint --max-warnings=0"
  },
  "devDependencies": {
    "@babel/core": "^7.11.6",
    "@storybook/addon-actions": "^6.0.21",
    "@storybook/addon-essentials": "^6.0.21",
    "@storybook/addon-links": "^6.0.21",
    "@storybook/preset-scss": "^1.0.2",
    "@storybook/react": "^6.0.21",
    "@types/chai": "^4.2.14",
    "@types/mocha": "^8.2.0",
    "@types/papaparse": "^5.2.2",
    "@types/react": "^16.9.49",
    "@types/react-dom": "^16.9.8",
    "@types/selenium-webdriver": "^4.0.11",
    "@types/webpack-dev-server": "^3.11.1",
    "@typescript-eslint/eslint-plugin": "^4.1.0",
    "@typescript-eslint/parser": "^4.1.0",
    "babel-loader": "^8.1.0",
    "chai": "^4.2.0",
    "clean-webpack-plugin": "^3.0.0",
    "cross-env": "^7.0.3",
    "css-loader": "^4.3.0",
    "dotenv-webpack": "^2.0.0",
    "dts-bundle-generator": "^6.0.0",
    "eslint": "^7.8.1",
    "eslint-config-prettier": "^6.11.0",
    "eslint-plugin-prettier": "^3.1.4",
    "eslint-plugin-react": "^7.20.6",
    "eslint-plugin-react-hooks": "^4.1.0",
    "expose-loader": "^1.0.3",
    "file-loader": "^6.1.0",
    "husky": "^4.3.0",
    "lint-staged": "^10.3.0",
    "mini-css-extract-plugin": "^0.11.1",
    "mocha": "^8.2.1",
    "prettier": "^2.1.1",
    "react": "^16.8.3",
    "react-dom": "^16.8.3",
    "react-is": "^16.13.1",
    "rimraf": "^3.0.2",
    "sass": "^1.26.10",
    "sass-loader": "^10.0.2",
    "selenium-webdriver": "^4.0.0-alpha.8",
    "style-loader": "^1.2.1",
    "stylelint": "^13.7.0",
    "stylelint-config-standard": "^20.0.0",
    "stylelint-order": "^4.1.0",
    "stylelint-scss": "^3.18.0",
    "ts-loader": "^8.0.3",
    "ts-node": "^9.1.1",
    "typescript": "^4.0.2",
    "webpack": "^4.44.1",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.7.0"
  },
  "peerDependencies": {
    "react": "^16.8.0 || >=17.0.0",
    "react-dom": "^16.8.0 || >=17.0.0"
  },
  "dependencies": {
    "@use-gesture/react": "^10.2.11",
    "papaparse": "^5.3.0",
    "react-dropzone": "^12.1.0"
  }
}


================================================
FILE: src/.eslintrc.json
================================================
{
  "env": {
    "browser": true,
    "es6": true
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  },
  "parser": "@typescript-eslint/parser",
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint",
    "prettier/react"
  ],
  "plugins": ["@typescript-eslint", "react", "react-hooks"],
  "rules": {
    "react/prop-types": "off"
  }
}


================================================
FILE: src/.stylelintrc
================================================
{
  "extends": "stylelint-config-standard",
  "plugins": [
    "stylelint-scss",
    "stylelint-order"
  ],
  "rules": {
    "at-rule-no-unknown": null,
    "scss/at-rule-no-unknown": true,
    "declaration-empty-line-before": [ "always", { "except": "first-nested", "ignore": [ "after-declaration", "after-comment" ] } ],
    "no-descending-specificity": null,
    "order/order": [
      { "type": "at-rule", "name": "include" },
      "custom-properties",
      "declarations",
      { "type": "at-rule", "name": "media", "hasBlock": true },
      "rules"
    ],
    "order/properties-order": [
      "content",
      "position",
      {
        "groupName": "layoutChildOptions",
        "properties": [
          "top",
          "right",
          "bottom",
          "left",
          "z-index",
          "clear",
          "float",
          "align-self",
          "flex",
          "flex-basis",
          "flex-grow",
          "flex-shrink",
          "order"
        ]
      },
      "display",
      "visibility",
      "appearance",
      {
        "groupName": "layoutContainerOptions",
        "properties": [
          "table-layout",
          "flex-direction",
          "flex-flow",
          "flex-wrap",
          "align-content",
          "align-items",
          "justify-content"
        ]
      },
      {
        "groupName": "blockOuter",
        "properties": [
          "margin",
          "margin-top",
          "margin-right",
          "margin-bottom",
          "margin-left"
        ]
      },
      {
        "groupName": "blockSize",
        "properties": [
          "box-sizing",
          "max-width",
          "max-height",
          "min-width",
          "min-height",
          "width",
          "height"
        ]
      },
      {
        "groupName": "blockInner",
        "properties": [
          "border",
          "border-top",
          "border-right",
          "border-bottom",
          "border-left",
          "padding",
          "padding-top",
          "padding-right",
          "padding-bottom",
          "padding-left",
          "overflow",
          "overflow-x",
          "overflow-y",
          "border-radius",
          "border-top-left-radius",
          "border-top-right-radius",
          "border-bottom-right-radius",
          "border-bottom-left-radius",
          "background",
          "background-attachment",
          "background-blend-mode",
          "background-color",
          "background-image",
          "background-position",
          "background-repeat",
          "background-size",
          "box-shadow"
        ]
      },
      {
        "groupName": "typography",
        "properties": [
          "text-align",
          "text-indent",
          "list-style",
          "list-style-position",
          "line-height",
          "font-family",
          "font-size",
          "font-style",
          "font-weight",
          "letter-spacing",
          "color",
          "text-decoration",
          "text-overflow",
          "text-transform"
        ]
      },
      {
        "groupName": "transform",
        "properties": [
          "transform",
          "transform-origin",
          "transform-perspective"
        ]
      },
      {
        "groupName": "compositing",
        "properties": [
          "clip",
          "fill",
          "mix-blend-mode",
          "opacity"
        ]
      },
      {
        "groupName": "animation",
        "properties": [
          "transition",
          "animation",
          "animation-name",
          "animation-timing-function",
          "animation-delay",
          "animation-duration",
          "animation-direction",
          "animation-fill-mode",
          "animation-iteration-count",
          "animation-play-state",
          "will-change"
        ]
      },
      "cursor"
    ]
  }
}


================================================
FILE: src/components/IconButton.scss
================================================
@import '../theme.scss';

.CSVImporter_IconButton {
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0; // override default
  width: 3em;
  height: 3em;
  border: 0;
  padding: 0;
  border-radius: 50%;
  background: transparent;
  font-size: inherit;
  color: $fgColor;
  cursor: pointer;

  &:hover:not(:disabled) {
    background: rgba($controlBorderColor, 0.25);
  }

  &:disabled {
    cursor: default;
  }

  &[data-small='true'] {
    width: 2em;
    height: 2em;
  }

  &[data-focus-only='true'] {
    opacity: 0;
    pointer-events: none;

    &:focus {
      opacity: 1;
    }
  }

  > span {
    display: block;
    width: 1.75em;
    height: 1.75em;
    background-position: 50% 50%;
    background-repeat: no-repeat;
    background-size: cover;

    &[data-type='back'] {
      // MUI ChevronLeft
      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE1LjQxIDcuNDFMMTQgNmwtNiA2IDYgNiAxLjQxLTEuNDFMMTAuODMgMTJ6Ij48L3BhdGg+PC9zdmc+');
    }

    &[data-type='forward'] {
      // MUI ChevronRight
      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiI+PC9wYXRoPjwvc3ZnPg==');
    }

    &[data-type='replay'] {
      // MUI Replay
      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDVWMUw3IDZsNSA1VjdjMy4zMSAwIDYgMi42OSA2IDZzLTIuNjkgNi02IDYtNi0yLjY5LTYtNkg0YzAgNC40MiAzLjU4IDggOCA4czgtMy41OCA4LTgtMy41OC04LTgtOHoiPjwvcGF0aD48L3N2Zz4=');
    }

    &[data-type='arrowBack'] {
      // MUI ArrowBack
      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIwIDExSDcuODNsNS41OS01LjU5TDEyIDRsLTggOCA4IDggMS40MS0xLjQxTDcuODMgMTNIMjB2LTJ6Ij48L3BhdGg+PC9zdmc+');
    }

    &[data-type='close'] {
      // MUI Close
      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE5IDYuNDFMMTcuNTkgNSAxMiAxMC41OSA2LjQxIDUgNSA2LjQxIDEwLjU5IDEyIDUgMTcuNTkgNi40MSAxOSAxMiAxMy40MSAxNy41OSAxOSAxOSAxNy41OSAxMy40MSAxMnoiPjwvcGF0aD48L3N2Zz4=');
    }
  }

  &:disabled > span {
    opacity: 0.25;
  }

  &[data-small='true'] > span {
    font-size: 0.75em;
  }
}


================================================
FILE: src/components/IconButton.tsx
================================================
import React from 'react';

import './IconButton.scss';

export const IconButton: React.FC<{
  label: string;
  type: 'back' | 'forward' | 'replay' | 'arrowBack' | 'close';
  small?: boolean;
  focusOnly?: boolean;
  disabled?: boolean;
  onClick?: () => void;
}> = ({ type, label, small, focusOnly, disabled, onClick }) => {
  return (
    <button
      className="CSVImporter_IconButton"
      type="button" // avoid triggering form submit
      aria-label={label}
      disabled={disabled}
      onClick={onClick}
      data-small={!!small}
      data-focus-only={!!focusOnly}
    >
      <span data-type={type} />
    </button>
  );
};


================================================
FILE: src/components/Importer.scss
================================================
.CSVImporter_Importer {
  // base styling for all content
  box-sizing: border-box;
  line-height: 1.4;

  * {
    box-sizing: border-box;
  }
}

// prevent text selection while dragging on mobile
// (must be on body per https://www.reddit.com/r/webdev/comments/g1wvsb/ios_safari_how_to_disable_longpress_text_selection/)
body.CSVImporter_dragging {
  -webkit-user-select: none; // needed for Safari
  user-select: none;
}


================================================
FILE: src/components/Importer.stories.tsx
================================================
import React, { useState } from 'react';
import { Story, Meta } from '@storybook/react';

import { ImporterProps } from './ImporterProps';
import { Importer, ImporterField } from './Importer';
import { deDE } from '../locale';

export default {
  title: 'Importer',
  component: Importer,
  parameters: {
    actions: { argTypesRegex: '^on.*|dataHandler' }
  }
} as Meta;

type SampleImporterProps = ImporterProps<{ fieldA: string }>;

export const Main: Story<SampleImporterProps> = (args: SampleImporterProps) => {
  return (
    <Importer {...args}>
      <ImporterField name="fieldA" label="Field A" />
      <ImporterField name="fieldB" label="Field B" optional />
    </Importer>
  );
};

export const LocaleDE: Story<SampleImporterProps> = (
  args: SampleImporterProps
) => {
  return (
    <Importer {...args} locale={deDE}>
      <ImporterField name="fieldA" label="Field A" />
      <ImporterField name="fieldB" label="Field B" optional />
    </Importer>
  );
};

export const Timesheet: Story<SampleImporterProps> = (
  args: SampleImporterProps
) => {
  return (
    <Importer {...args}>
      <ImporterField name="date" label="Date" />
      <ImporterField name="clientName" label="Client" />
      <ImporterField name="projectName" label="Project" />
      <ImporterField name="projectCode" label="Project Code" optional />
      <ImporterField name="taskName" label="Task" />
      <ImporterField name="notes" label="Notes" optional />
    </Importer>
  );
};

export const CustomDelimiterConfig: Story<SampleImporterProps> = (
  args: SampleImporterProps
) => {
  return (
    <Importer {...args}>
      <ImporterField name="fieldA" label="Field A" />
      <ImporterField name="fieldB" label="Field B" />
    </Importer>
  );
};

CustomDelimiterConfig.args = {
  delimiter: '!' // use a truly unusual delimiter that PapaParse would not guess normally
};

export const InsideScrolledPage: Story<SampleImporterProps> = (
  args: SampleImporterProps
) => {
  return (
    <div>
      Scroll below
      <div style={{ paddingTop: '120vh' }}></div>
      <Importer {...args}>
        <ImporterField name="fieldA" label="Field A" />
        <ImporterField name="fieldB" label="Field B" optional />
      </Importer>
    </div>
  );
};

export const CustomWidth: Story<SampleImporterProps> = (
  args: SampleImporterProps
) => {
  return (
    <div style={{ width: '20rem' }}>
      <Importer {...args}>
        <ImporterField name="fieldA" label="Field A" />
        <ImporterField name="fieldB" label="Field B" optional />
      </Importer>
    </div>
  );
};

CustomWidth.args = {
  displayColumnPageSize: 2, // fewer columns for e.g. a narrower display
  displayFieldRowSize: 3 // fewer columns for e.g. a narrower display
};

export const RenderProp: Story<SampleImporterProps> = (
  args: SampleImporterProps
) => {
  return (
    <Importer {...args}>
      {({ preview }) => {
        return (
          <>
            <ImporterField name="coreFieldA" label="Field A" />
            <ImporterField name="coreFieldB" label="Field B" />

            {preview &&
              preview.columns.map(({ header, index }) =>
                header ? (
                  <ImporterField
                    key={index}
                    name={`uploaded_${header}`}
                    label={`Field ${header}`}
                  />
                ) : null
              )}
          </>
        );
      }}
    </Importer>
  );
};

const PresetSelector: React.FC<{
  children: (fieldContent: React.ReactNode) => React.ReactElement;
}> = ({ children }) => {
  const [selection, setSelection] = useState('Person');

  return (
    <div>
      <div style={{ marginBottom: '1rem' }}>
        <select
          style={{ fontSize: '150%' }}
          value={selection}
          onChange={(event) => setSelection(event.target.value)}
        >
          <option>Person</option>
          <option>Car</option>
        </select>
      </div>

      {children(
        selection === 'Person' ? (
          <>
            <ImporterField name="person_name" label="Preset A: Person Name" />
            <ImporterField name="person_age" label="Preset A: Person Age" />
          </>
        ) : (
          <>
            <ImporterField name="car_make" label="Preset B: Car Make" />
            <ImporterField name="car_model" label="Preset B: Car Model" />
          </>
        )
      )}
    </div>
  );
};

export const ChooseFieldPresets: Story<SampleImporterProps> = (
  args: SampleImporterProps
) => {
  return (
    <PresetSelector>
      {(fields) => <Importer {...args}>{fields}</Importer>}
    </PresetSelector>
  );
};


================================================
FILE: src/components/Importer.tsx
================================================
import React, { useMemo, useState, useEffect } from 'react';

import { BaseRow } from '../parser';
import { FileStep, FileStepState } from './file-step/FileStep';
import { generatePreviewColumns } from './fields-step/ColumnPreview';
import { FieldsStep, FieldsStepState } from './fields-step/FieldsStep';
import { ProgressDisplay } from './ProgressDisplay';
import { ImporterFilePreview, ImporterProps } from './ImporterProps';

// re-export from a central spot
export { ImporterField } from './ImporterField';
import { useFieldDefinitions } from './ImporterField';

import './Importer.scss';
import { LocaleContext } from '../locale/LocaleContext';
import { enUS } from '../locale';

export function Importer<Row extends BaseRow>(
  props: ImporterProps<Row>
): React.ReactElement {
  const {
    dataHandler,
    processChunk,
    defaultNoHeader,
    assumeNoHeaders,
    restartable,
    displayFieldRowSize,
    displayColumnPageSize,
    onStart,
    onComplete,
    onClose,
    children: content,
    locale: userLocale,
    ...customPapaParseConfig
  } = props;

  // helper to combine our displayed content and the user code that provides field definitions
  const [fields, userFieldContentWrapper] = useFieldDefinitions();

  const [fileState, setFileState] = useState<FileStepState | null>(null);
  const [fileAccepted, setFileAccepted] = useState<boolean>(false);

  const [fieldsState, setFieldsState] = useState<FieldsStepState | null>(null);
  const [fieldsAccepted, setFieldsAccepted] = useState<boolean>(false);

  // reset field assignments when file changes
  const activeFile = fileState && fileState.file;
  useEffect(() => {
    if (activeFile) {
      setFieldsState(null);
    }
  }, [activeFile]);

  const externalPreview = useMemo<ImporterFilePreview | null>(() => {
    // generate stable externally-visible data objects
    const externalColumns =
      fileState &&
      generatePreviewColumns(fileState.firstRows, fileState.hasHeaders);
    return (
      fileState &&
      externalColumns && {
        rawData: fileState.firstChunk,
        columns: externalColumns,
        skipHeaders: !fileState.hasHeaders,
        parseWarning: fileState.parseWarning
      }
    );
  }, [fileState]);

  // fall back to enUS if no locale provided
  const locale = userLocale ?? enUS;

  if (!fileAccepted || fileState === null || externalPreview === null) {
    return (
      <LocaleContext.Provider value={locale}>
        <div className="CSVImporter_Importer">
          <FileStep
            customConfig={customPapaParseConfig}
            defaultNoHeader={defaultNoHeader ?? assumeNoHeaders}
            prevState={fileState}
            onChange={(parsedPreview) => {
              setFileState(parsedPreview);
            }}
            onAccept={() => {
              setFileAccepted(true);
            }}
          />
        </div>
      </LocaleContext.Provider>
    );
  }

  if (!fieldsAccepted || fieldsState === null) {
    return (
      <LocaleContext.Provider value={locale}>
        <div className="CSVImporter_Importer">
          <FieldsStep
            fileState={fileState}
            fields={fields}
            prevState={fieldsState}
            displayFieldRowSize={displayFieldRowSize}
            displayColumnPageSize={displayColumnPageSize}
            onChange={(state) => {
              setFieldsState(state);
            }}
            onAccept={() => {
              setFieldsAccepted(true);
            }}
            onCancel={() => {
              // keep existing preview data and assignments
              setFileAccepted(false);
            }}
          />

          {userFieldContentWrapper(
            // render the provided child content that defines the fields
            typeof content === 'function'
              ? content({
                  file: fileState && fileState.file,
                  preview: externalPreview
                })
              : content
          )}
        </div>
      </LocaleContext.Provider>
    );
  }

  return (
    <LocaleContext.Provider value={locale}>
      <div className="CSVImporter_Importer">
        <ProgressDisplay
          fileState={fileState}
          fieldsState={fieldsState}
          externalPreview={externalPreview}
          // @todo remove assertion after upgrading to TS 4.1+
          dataHandler={dataHandler ?? processChunk!} // eslint-disable-line @typescript-eslint/no-non-null-assertion
          onStart={onStart}
          onRestart={
            restartable
              ? () => {
                  // reset all state
                  setFileState(null);
                  setFileAccepted(false);
                  setFieldsState(null);
                  setFieldsAccepted(false);
                }
              : undefined
          }
          onComplete={onComplete}
          onClose={onClose}
        />
      </div>
    </LocaleContext.Provider>
  );
}


================================================
FILE: src/components/ImporterField.tsx
================================================
import React, { useMemo, useState, useEffect, useContext } from 'react';

import { ImporterFieldProps } from './ImporterProps';

export interface Field {
  name: string;
  label: string;
  isOptional: boolean;
}

// internal context for registering field definitions
type FieldDef = Field & { instanceId: symbol };
type FieldListSetter = (prev: FieldDef[]) => FieldDef[];

const FieldDefinitionContext = React.createContext<
  ((setter: FieldListSetter) => void) | null
>(null);

// internal helper to allow user code to provide field definitions
export function useFieldDefinitions(): [
  Field[],
  (content: React.ReactNode) => React.ReactElement
] {
  const [fields, setFields] = useState<FieldDef[]>([]);

  const userFieldContentWrapper = (content: React.ReactNode) => (
    <FieldDefinitionContext.Provider value={setFields}>
      {content}
    </FieldDefinitionContext.Provider>
  );

  return [fields, userFieldContentWrapper];
}

// defines a field to be filled from file column during import
export const ImporterField: React.FC<ImporterFieldProps> = ({
  name,
  label,
  optional
}) => {
  // make unique internal ID (this is never rendered in HTML and does not affect SSR)
  const instanceId = useMemo(() => Symbol('internal unique field ID'), []);
  const fieldSetter = useContext(FieldDefinitionContext);

  // update central list as needed
  useEffect(() => {
    if (!fieldSetter) {
      console.error('importer field must be a child of importer'); // @todo
      return;
    }

    fieldSetter((prev) => {
      const copy = [...prev];
      const existingIndex = copy.findIndex(
        (item) => item.instanceId === instanceId
      );

      // add or update the field definition instance in-place
      // (using internal field instance ID helps gracefully tolerate duplicates, renames, etc)
      const newField = {
        instanceId,
        name,
        label,
        isOptional: !!optional
      };
      if (existingIndex === -1) {
        copy.push(newField);
      } else {
        copy[existingIndex] = newField;
      }

      return copy;
    });
  }, [instanceId, fieldSetter, name, label, optional]);

  // on component unmount, remove this field from list by ID
  useEffect(() => {
    if (!fieldSetter) {
      console.error('importer field must be a child of importer'); // @todo
      return;
    }

    return () => {
      fieldSetter((prev) =>
        prev.filter((field) => field.instanceId !== instanceId)
      );
    };
  }, [instanceId, fieldSetter]);

  return null;
};


================================================
FILE: src/components/ImporterFrame.scss
================================================
@import '../theme.scss';

// @todo use em instead of rem
.CSVImporter_ImporterFrame {
  border: 1px solid $controlBorderColor;
  padding: 1.2em;
  border-radius: $borderRadius;
  background: $controlBgColor;

  &__header {
    display: flex;
    align-items: center;
    margin-top: -1em; // cancel out button padding
    margin-bottom: 0.2em;
    margin-left: -1em;
  }

  &__headerTitle {
    padding-bottom: 0.1em; // centering nudge
    overflow: hidden;
    font-size: $titleFontSize;
    color: $textColor;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  &__headerCrumbSeparator {
    flex: none;
    display: flex; // for correct icon alignment
    margin-right: 0.5em;
    margin-left: 0.5em;
    font-size: 1.2em;
    opacity: 0.5;

    > span {
      display: block;
      width: 1em;
      height: 1em;
      // MUI ChevronRight
      background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiI+PC9wYXRoPjwvc3ZnPg==');
      background-position: 50% 50%;
      background-repeat: no-repeat;
      background-size: cover;
    }
  }

  &__headerSubtitle {
    flex: none;
    padding-bottom: 0.1em; // centering nudge
    font-size: $titleFontSize;
    color: $textColor;
  }

  &__footer {
    display: flex;
    align-items: center;

    margin-top: 1.2em;
  }

  &__footerFill {
    flex: 1 1 0;
  }

  &__footerError {
    flex: none;
    line-height: 0.8; // in case of line break
    color: $errorTextColor;
    word-break: break-word;
  }

  &__footerSecondary {
    flex: none;
    display: flex; // for more consistent button alignment
    margin-left: 1em;
  }

  &__footerNext {
    flex: none;
    display: flex; // for more consistent button alignment
    margin-left: 1em;
  }
}


================================================
FILE: src/components/ImporterFrame.tsx
================================================
import React, { useRef, useEffect } from 'react';

import { TextButton } from './TextButton';
import { IconButton } from './IconButton';

import './ImporterFrame.scss';
import { useLocale } from '../locale/LocaleContext';

export const ImporterFrame: React.FC<{
  fileName: string;
  subtitle?: string; // @todo allow multiple crumbs
  secondaryDisabled?: boolean;
  secondaryLabel?: string;
  nextDisabled?: boolean;
  nextLabel: string | false;
  error?: string | null;
  onSecondary?: () => void;
  onNext: () => void;
  onCancel?: () => void;
}> = ({
  fileName,
  subtitle,
  secondaryDisabled,
  secondaryLabel,
  nextDisabled,
  nextLabel,
  error,
  onSecondary,
  onNext,
  onCancel,
  children
}) => {
  const titleRef = useRef<HTMLDivElement>(null);
  const subtitleRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (subtitleRef.current) {
      subtitleRef.current.focus();
    } else if (titleRef.current) {
      titleRef.current.focus();
    }
  }, []);

  const l10n = useLocale('general');

  return (
    <div className="CSVImporter_ImporterFrame">
      <div className="CSVImporter_ImporterFrame__header">
        <IconButton
          label={l10n.goToPreviousStepTooltip}
          type="arrowBack"
          disabled={!onCancel}
          onClick={onCancel}
        />

        <div
          className="CSVImporter_ImporterFrame__headerTitle"
          tabIndex={-1}
          ref={titleRef}
        >
          {fileName}
        </div>

        {subtitle ? (
          <>
            <div className="CSVImporter_ImporterFrame__headerCrumbSeparator">
              <span />
            </div>
            <div
              className="CSVImporter_ImporterFrame__headerSubtitle"
              tabIndex={-1}
              ref={subtitleRef}
            >
              {subtitle}
            </div>
          </>
        ) : null}
      </div>

      {children}

      <div className="CSVImporter_ImporterFrame__footer">
        <div className="CSVImporter_ImporterFrame__footerFill" />

        {error ? (
          <div className="CSVImporter_ImporterFrame__footerError" role="status">
            {error}
          </div>
        ) : null}

        {secondaryLabel ? (
          <div className="CSVImporter_ImporterFrame__footerSecondary">
            <TextButton disabled={!!secondaryDisabled} onClick={onSecondary}>
              {secondaryLabel}
            </TextButton>
          </div>
        ) : null}

        {nextLabel !== false ? (
          <div className="CSVImporter_ImporterFrame__footerNext">
            <TextButton disabled={!!nextDisabled} onClick={onNext}>
              {nextLabel}
            </TextButton>
          </div>
        ) : null}
      </div>
    </div>
  );
};


================================================
FILE: src/components/ImporterProps.ts
================================================
import React from 'react';
import { ImporterLocale } from '../locale';
import { CustomizablePapaParseConfig, ParseCallback, BaseRow } from '../parser';

// information for displaying a spreadsheet-style column
export interface ImporterPreviewColumn {
  index: number; // 0-based position inside spreadsheet
  header?: string; // header, if present
  values: string[]; // row values after the header
}

export interface ImporterFilePreview {
  rawData: string; // raw first data chunk consumed by parser for preview
  columns: ImporterPreviewColumn[]; // per-column parsed preview
  skipHeaders: boolean; // true if user has indicated that file has no headers
  parseWarning?: Papa.ParseError; // any non-blocking PapaParse message
}

// separate props definition to safely include in tests
export interface ImportInfo {
  file: File;
  preview: ImporterFilePreview;
  fields: string[]; // list of fields that user has assigned
  columnFields: (string | undefined)[]; // per-column list of field names (or undefined if unassigned)
}

export type ImporterContentRenderProp = (info: {
  file: File | null;
  preview: ImporterFilePreview | null;
}) => React.ReactNode;

export interface ImporterFieldProps {
  name: string;
  label: string;
  optional?: boolean;
}

export type ImporterDataHandlerProps<Row extends BaseRow> =
  | {
      dataHandler: ParseCallback<Row>;
      processChunk?: undefined; // for ease of rest-spread
    }
  | {
      /**
       * @deprecated renamed to `dataHandler`
       */
      processChunk: ParseCallback<Row>;
      dataHandler?: undefined; // disambiguate from newer naming
    };

export type ImporterProps<Row extends BaseRow> = ImporterDataHandlerProps<
  Row
> & {
  defaultNoHeader?: boolean;
  /**
   * @deprecated renamed to `defaultNoHeader`
   */
  assumeNoHeaders?: boolean;

  displayColumnPageSize?: number;
  displayFieldRowSize?: number;

  restartable?: boolean;
  onStart?: (info: ImportInfo) => void;
  onComplete?: (info: ImportInfo) => void;
  onClose?: (info: ImportInfo) => void;
  children?: ImporterContentRenderProp | React.ReactNode;
  locale?: ImporterLocale;
} & CustomizablePapaParseConfig;


================================================
FILE: src/components/ProgressDisplay.scss
================================================
@import '../theme.scss';

.CSVImporter_ProgressDisplay {
  padding: 2em;

  &__status {
    text-align: center;
    font-size: $titleFontSize;
    color: $textColor;

    &.-pending {
      color: $textSecondaryColor;
    }
  }

  &__count {
    text-align: right;
    font-size: 1em;
    color: $textSecondaryColor;

    > var {
      display: inline-block;
      width: 1px;
      height: 1px;
      overflow: hidden;
      opacity: 0;
    }
  }

  &__progressBar {
    position: relative; // for indicator
    width: 100%;
    height: 0.5em;
    background: $fillColor;
  }

  &__progressBarIndicator {
    position: absolute;
    top: 0;
    left: 0;
    width: 0; // dynamically set in code
    height: 100%;
    background: $textColor;

    transition: width 0.2s ease-out;
  }
}


================================================
FILE: src/components/ProgressDisplay.tsx
================================================
import React, { useState, useEffect, useMemo, useRef } from 'react';

import { processFile, ParseCallback, BaseRow } from '../parser';
import { FileStepState } from './file-step/FileStep';
import { FieldsStepState } from './fields-step/FieldsStep';
import { ImporterFilePreview, ImportInfo } from './ImporterProps';
import { ImporterFrame } from './ImporterFrame';

import './ProgressDisplay.scss';
import { useLocale } from '../locale/LocaleContext';

// compute actual UTF-8 bytes used by a string
// (inspired by https://stackoverflow.com/questions/10576905/how-to-convert-javascript-unicode-notation-code-to-utf-8)
function countUTF8Bytes(item: string) {
  // re-encode into UTF-8
  const escaped = encodeURIComponent(item);

  // convert byte escape sequences into single characters
  const normalized = escaped.replace(/%\d\d/g, '_');

  return normalized.length;
}

export function ProgressDisplay<Row extends BaseRow>({
  fileState,
  fieldsState,
  externalPreview,
  dataHandler,
  onStart,
  onComplete,
  onRestart,
  onClose
}: React.PropsWithChildren<{
  fileState: FileStepState;
  fieldsState: FieldsStepState;
  externalPreview: ImporterFilePreview;
  dataHandler: ParseCallback<Row>;
  onStart?: (info: ImportInfo) => void;
  onComplete?: (info: ImportInfo) => void;
  onRestart?: () => void;
  onClose?: (info: ImportInfo) => void;
}>): React.ReactElement {
  const [progressCount, setProgressCount] = useState(0);
  const [isComplete, setIsComplete] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [isDismissed, setIsDismissed] = useState(false); // prevents double-clicking finish

  // info object exposed to the progress callbacks
  const importInfo = useMemo<ImportInfo>(() => {
    const fieldList = Object.keys(fieldsState.fieldAssignments);

    const columnSparseList: (string | undefined)[] = [];
    fieldList.forEach((field) => {
      const col = fieldsState.fieldAssignments[field];
      if (col !== undefined) {
        columnSparseList[col] = field;
      }
    });

    return {
      file: fileState.file,
      preview: externalPreview,
      fields: fieldList,
      columnFields: [...columnSparseList]
    };
  }, [fileState, fieldsState, externalPreview]);

  // estimate number of rows
  const estimatedRowCount = useMemo(() => {
    // sum up sizes of all the parsed preview rows and get estimated average
    const totalPreviewRowBytes = fileState.firstRows.reduce(
      (prevCount, row) => {
        const rowBytes = row.reduce((prev, item) => {
          return prev + countUTF8Bytes(item) + 1; // add a byte for separator or newline
        }, 0);

        return prevCount + rowBytes;
      },
      0
    );

    const averagePreviewRowSize =
      totalPreviewRowBytes / fileState.firstRows.length;

    // divide file size by estimated row size (or fall back to a sensible amount)
    return averagePreviewRowSize > 1
      ? fileState.file.size / averagePreviewRowSize
      : 100;
  }, [fileState]);

  // notify on start of processing
  // (separate effect in case of errors)
  const onStartRef = useRef(onStart); // wrap in ref to avoid re-triggering (only first instance is needed)
  useEffect(() => {
    if (onStartRef.current) {
      onStartRef.current(importInfo);
    }
  }, [importInfo]);

  // notify on end of processing
  // (separate effect in case of errors)
  const onCompleteRef = useRef(onComplete); // wrap in ref to avoid re-triggering
  onCompleteRef.current = onComplete;
  useEffect(() => {
    if (isComplete && onCompleteRef.current) {
      onCompleteRef.current(importInfo);
    }
  }, [importInfo, isComplete]);

  // ensure status gets focus when complete, in case status role is not read out
  const statusRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if ((isComplete || error) && statusRef.current) {
      statusRef.current.focus();
    }
  }, [isComplete, error]);

  // trigger processing from an effect to mitigate React 18 double-run in dev
  const [ready, setReady] = useState(false);
  useEffect(() => {
    setReady(true);
  }, []);

  // perform main async parse
  const dataHandlerRef = useRef(dataHandler); // wrap in ref to avoid re-triggering
  const asyncLockRef = useRef<number>(0);
  useEffect(() => {
    // avoid running on first render due to React 18 double-run
    if (!ready) {
      return;
    }

    const oplock = asyncLockRef.current;

    processFile(
      { ...fileState, fieldAssignments: fieldsState.fieldAssignments },
      (deltaCount) => {
        // ignore if stale
        if (oplock !== asyncLockRef.current) {
          return; // @todo signal abort
        }

        setProgressCount((prev) => prev + deltaCount);
      },
      dataHandlerRef.current
    ).then(
      () => {
        // ignore if stale
        if (oplock !== asyncLockRef.current) {
          return;
        }

        setIsComplete(true);
      },
      (error) => {
        // ignore if stale
        if (oplock !== asyncLockRef.current) {
          return;
        }

        setError(error);
      }
    );

    return () => {
      // invalidate current oplock on change or unmount
      asyncLockRef.current += 1;
    };
  }, [ready, fileState, fieldsState]);

  // simulate asymptotic progress percentage
  const progressPercentage = useMemo(() => {
    if (isComplete) {
      return 100;
    }

    // inputs hand-picked so that correctly estimated total is about 75% of the bar
    const progressPower = 2.5 * (progressCount / estimatedRowCount);
    const progressLeft = 0.5 ** progressPower;

    // convert to .1 percent precision for smoother bar display
    return Math.floor(1000 - 1000 * progressLeft) / 10;
  }, [estimatedRowCount, progressCount, isComplete]);

  const l10n = useLocale('progressStep');

  return (
    <ImporterFrame
      fileName={fileState.file.name}
      subtitle={l10n.stepSubtitle}
      error={error && (error.message || String(error))}
      secondaryDisabled={!isComplete || isDismissed}
      secondaryLabel={onRestart && onClose ? l10n.uploadMoreButton : undefined}
      onSecondary={onRestart && onClose ? onRestart : undefined}
      nextDisabled={!isComplete || isDismissed}
      nextLabel={
        !!(onClose || onRestart) &&
        (onClose ? l10n.finishButton : l10n.uploadMoreButton)
      }
      onNext={() => {
        if (onClose) {
          setIsDismissed(true);
          onClose(importInfo);
        } else if (onRestart) {
          onRestart();
        }
      }}
    >
      <div className="CSVImporter_ProgressDisplay">
        {isComplete || error ? (
          <div
            className="CSVImporter_ProgressDisplay__status"
            role="status"
            tabIndex={-1}
            ref={statusRef}
          >
            {error ? l10n.statusError : l10n.statusComplete}
          </div>
        ) : (
          <div
            className="CSVImporter_ProgressDisplay__status -pending"
            role="status"
          >
            {l10n.statusPending}
          </div>
        )}

        <div className="CSVImporter_ProgressDisplay__count" role="text">
          <var>{l10n.processedRowsLabel}</var> {progressCount}
        </div>

        <div className="CSVImporter_ProgressDisplay__progressBar">
          <div
            className="CSVImporter_ProgressDisplay__progressBarIndicator"
            style={{ width: `${progressPercentage}%` }}
          />
        </div>
      </div>
    </ImporterFrame>
  );
}


================================================
FILE: src/components/TextButton.scss
================================================
@import '../theme.scss';

.CSVImporter_TextButton {
  display: block;
  margin: 0; // override default
  border: 1px solid $controlBorderColor;
  padding: 0.4em 1em 0.5em;
  border-radius: $borderRadius;
  background: $fillColor;
  font-size: inherit;
  color: $fgColor;
  cursor: pointer;

  &:hover:not(:disabled) {
    background: darken($fillColor, 10%);
  }

  &:disabled {
    opacity: 0.25;
    cursor: default;
  }
}


================================================
FILE: src/components/TextButton.tsx
================================================
import React from 'react';

import './TextButton.scss';

export const TextButton: React.FC<{
  disabled?: boolean;
  onClick?: () => void;
}> = ({ disabled, onClick, children }) => {
  return (
    <button
      className="CSVImporter_TextButton"
      type="button" // avoid triggering form submit
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
};


================================================
FILE: src/components/fields-step/ColumnDragCard.scss
================================================
@import '../../theme.scss';

.CSVImporter_ColumnDragCard {
  position: relative;
  z-index: 0; // reset stacking context
  padding: 0.5em 0.75em;
  border-radius: $borderRadius;
  background: $controlBgColor;
  box-shadow: 0 1px 1px rgba(#000, 0.25);
  cursor: default;

  &[data-draggable='true'] {
    cursor: grab;

    // avoid triggering scroll on iOS Safari (needed despite preventDefault also being used)
    touch-action: none;
  }

  &[data-dummy='true'] {
    border-radius: 0;
    background: $fillColor;
    box-shadow: none;
    opacity: 0.5;
    user-select: none;
  }

  &[data-error='true'] {
    background: rgba($errorTextColor, 0.25);
    color: $textColor;
  }

  &[data-shadow='true'] {
    background: $fillColor;
    box-shadow: none;
    color: rgba($textColor, 0.25); // reduce text
  }

  &[data-drop-indicator='true'] {
    box-shadow: 0 1px 2px rgba(#000, 0.5);
    color: $fgColor;
  }

  &__cardHeader {
    margin-top: -0.25em;
    margin-right: -0.5em;
    margin-bottom: 0.25em;
    margin-left: -0.5em;
    height: 1.5em; // sized to be covered by small button
    font-weight: bold;
    color: $textSecondaryColor;

    & > b {
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100%;
      background: $fillColor;
      line-height: 1; // centered by parent anyway
    }

    > var {
      display: block;
      margin-bottom: -1px;
      width: 1px; // non-zero size for reader
      height: 1px;
      overflow: hidden;
    }
  }

  &__cardPaper[data-draggable='true']:hover &__cardHeader,
  &__cardPaper[data-dragged='true'] &__cardHeader {
    color: $fgColor;
  }

  &__cardValue {
    margin-top: 0.25em;
    overflow: hidden;
    line-height: 1.25em; // might not be inherited from main content
    font-size: 0.75em;
    text-overflow: ellipsis;
    white-space: nowrap;

    &[data-header='true'] {
      text-align: center;
      font-style: italic;
      color: $textSecondaryColor;
    }

    & + div {
      margin-top: 0;
    }
  }

  &[data-shadow='true'] > &__cardValue[data-header='true'] {
    color: rgba($textSecondaryColor, 0.25); // reduce text
  }
}


================================================
FILE: src/components/fields-step/ColumnDragCard.tsx
================================================
import React, { useMemo } from 'react';

import { PREVIEW_ROW_COUNT } from '../../parser';
import { Column } from './ColumnPreview';

import './ColumnDragCard.scss';
import { useLocale } from '../../locale/LocaleContext';

// @todo sort out "grabbing" cursor state (does not work with pointer-events:none)
export const ColumnDragCard: React.FC<{
  hasHeaders?: boolean; // for correct display of dummy card
  column?: Column;
  rowCount?: number;
  hasError?: boolean;
  isAssigned?: boolean;
  isShadow?: boolean;
  isDraggable?: boolean;
  isDragged?: boolean;
  isDropIndicator?: boolean;
}> = ({
  hasHeaders,
  column: optionalColumn,
  rowCount = PREVIEW_ROW_COUNT,
  hasError,
  isAssigned,
  isShadow,
  isDraggable,
  isDragged,
  isDropIndicator
}) => {
  const isDummy = !optionalColumn;

  const column = useMemo<Column>(
    () =>
      optionalColumn || {
        index: -1,
        code: '',
        header: hasHeaders ? '' : undefined,
        values: [...new Array(PREVIEW_ROW_COUNT)].map(() => '')
      },
    [optionalColumn, hasHeaders]
  );

  const headerValue = column.header;
  const dataValues = column.values.slice(
    0,
    headerValue === undefined ? rowCount : rowCount - 1
  );

  const l10n = useLocale('fieldsStep');

  return (
    // not changing variant dynamically because it causes a height jump
    <div
      key={isDummy || isShadow ? 1 : isDropIndicator ? 2 : 0} // force re-creation to avoid transition anim
      className="CSVImporter_ColumnDragCard"
      data-dummy={!!isDummy}
      data-error={!!hasError}
      data-shadow={!!isShadow}
      data-draggable={!!isDraggable}
      data-dragged={!!isDragged}
      data-drop-indicator={!!isDropIndicator}
    >
      <div className="CSVImporter_ColumnDragCard__cardHeader">
        {isDummy ? (
          <var role="text">{l10n.columnCardDummyHeader}</var>
        ) : (
          <var role="text">{l10n.getColumnCardHeader(column.code)}</var>
        )}
        {isDummy || isAssigned ? '\u00a0' : <b aria-hidden>{column.code}</b>}
      </div>

      {headerValue !== undefined ? (
        <div className="CSVImporter_ColumnDragCard__cardValue" data-header>
          {headerValue || '\u00a0'}
        </div>
      ) : null}

      {/* all values grouped into one readable string */}
      <div role="text">
        {dataValues.map((value, valueIndex) => (
          <div
            key={valueIndex}
            className="CSVImporter_ColumnDragCard__cardValue"
          >
            {value || '\u00a0'}
          </div>
        ))}
      </div>
    </div>
  );
};


================================================
FILE: src/components/fields-step/ColumnDragObject.scss
================================================
.CSVImporter_ColumnDragObject {
  &__overlay {
    // scroll-independent container
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    overflow: none; // clipping to avoid triggering scrollbar when dragging near edges
    pointer-events: none;
  }

  &__positioner {
    // movement of mouse gesture inside overlay
    position: absolute; // @todo this is not working with scroll
    top: 0;
    left: 0;
    min-width: 8em; // in case could not compute
    width: 0; // dynamically set at drag start
    height: 0; // dynamically set at drag start
  }

  &__holder {
    // placement of visible card relative to mouse pointer
    position: absolute;
    top: -0.75em;
    left: -0.75em;
    width: 100%;
    opacity: 0.9;
  }
}


================================================
FILE: src/components/fields-step/ColumnDragObject.tsx
================================================
import React, { useRef, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';

import { ColumnDragCard } from './ColumnDragCard';
import { DragState } from './ColumnDragState';

import './ColumnDragObject.scss';

export const ColumnDragObject: React.FC<{
  dragState: DragState | null;
}> = ({ dragState }) => {
  const referenceBoxRef = useRef<HTMLDivElement | null>(null);

  // the dragged box is wrapped in a no-events overlay to clip against screen edges
  const dragBoxRef = useRef<HTMLDivElement | null>(null);
  const dragObjectPortal =
    dragState && dragState.pointerStartInfo
      ? createPortal(
          <div className="CSVImporter_ColumnDragObject__overlay">
            <div
              className="CSVImporter_ColumnDragObject__positioner"
              ref={dragBoxRef}
            >
              <div className="CSVImporter_ColumnDragObject__holder">
                <ColumnDragCard column={dragState.column} isDragged />
              </div>
            </div>
          </div>,
          document.body
        )
      : null;

  // set up initial position when pointer-based gesture is started
  const pointerStartInfo = dragState && dragState.pointerStartInfo;
  useLayoutEffect(() => {
    // ignore non-pointer drag states
    if (!pointerStartInfo || !dragBoxRef.current) {
      return;
    }

    // place based on initial position + size relative to viewport overlay
    const rect = pointerStartInfo.initialClientRect;
    dragBoxRef.current.style.left = `${rect.left}px`;
    dragBoxRef.current.style.top = `${rect.top}px`;
    dragBoxRef.current.style.width = `${rect.width}px`;
    dragBoxRef.current.style.height = `${rect.height}px`;

    // copy known cascaded font style from main content into portal content
    // @todo consider other text style properties?
    if (referenceBoxRef.current) {
      const computedStyle = window.getComputedStyle(referenceBoxRef.current);
      dragBoxRef.current.style.fontFamily = computedStyle.fontFamily;
      dragBoxRef.current.style.fontSize = computedStyle.fontSize;
      dragBoxRef.current.style.fontWeight = computedStyle.fontWeight;
      dragBoxRef.current.style.fontStyle = computedStyle.fontStyle;
      dragBoxRef.current.style.letterSpacing = computedStyle.letterSpacing;
    }
  }, [pointerStartInfo]);

  // subscribe to live position updates without state changes
  useLayoutEffect(() => {
    if (dragState) {
      const updateListener = (movement: number[]) => {
        if (!dragBoxRef.current) return;

        // update the visible offset relative to starting position
        const [x, y] = movement;
        dragBoxRef.current.style.transform = `translate(${x}px, ${y}px)`;
      };

      dragState.updateListeners.push(updateListener);

      // clean up listener
      return () => {
        const removeIndex = dragState.updateListeners.indexOf(updateListener);
        if (removeIndex !== -1) {
          dragState.updateListeners.splice(removeIndex, 1);
        }
      };
    }
  }, [dragState]);

  return <div ref={referenceBoxRef}>{dragObjectPortal}</div>;
};


================================================
FILE: src/components/fields-step/ColumnDragSourceArea.scss
================================================
@import '../../theme.scss';

.CSVImporter_ColumnDragSourceArea {
  display: flex;
  margin-top: 0.5em;
  margin-bottom: 1em;

  &__control {
    flex: none;
    display: flex;
    align-items: center;
  }

  &__page {
    position: relative; // for indicator
    flex: 1 1 0;
    display: flex;
    padding-top: 0.5em; // some room for the indicator
    padding-left: 0.5em; // match interior box spacing
  }

  &__pageIndicator {
    position: absolute;
    top: -0.5em;
    right: 0;
    left: 0;
    text-align: center;
    font-size: 0.75em;
  }

  &__pageFiller {
    flex: 1 1 0;
    margin-right: 0.5em; // match interior box spacing
  }

  &__box {
    position: relative; // for action
    flex: 1 1 0;
    margin-right: 0.5em;
    width: 0; // prevent internal sizing from affecting placement
  }

  &__boxAction {
    position: absolute;
    top: 0; // icon button padding matches card padding
    right: 0;
    z-index: 1; // right above content
  }
}


================================================
FILE: src/components/fields-step/ColumnDragSourceArea.tsx
================================================
import React, { useState, useMemo } from 'react';
import { useDrag } from '@use-gesture/react';

import { FieldAssignmentMap } from '../../parser';
import { Column } from './ColumnPreview';
import { DragState } from './ColumnDragState';
import { ColumnDragCard } from './ColumnDragCard';
import { IconButton } from '../IconButton';

import './ColumnDragSourceArea.scss';
import { useLocale } from '../../locale/LocaleContext';

const DEFAULT_PAGE_SIZE = 5; // fraction of 10 for easier counting

// @todo readable status text if not mouse-drag
const SourceBox: React.FC<{
  column: Column;
  fieldAssignments: FieldAssignmentMap;
  dragState: DragState | null;
  eventBinder: (column: Column) => ReturnType<typeof useDrag>;
  onSelect: (column: Column) => void;
  onUnassign: (column: Column) => void;
}> = ({
  column,
  fieldAssignments,
  dragState,
  eventBinder,
  onSelect,
  onUnassign
}) => {
  const isDragged = dragState ? column === dragState.column : false;

  const isAssigned = useMemo(
    () =>
      Object.keys(fieldAssignments).some(
        (fieldName) => fieldAssignments[fieldName] === column.index
      ),
    [fieldAssignments, column]
  );

  const eventHandlers = useMemo(() => eventBinder(column), [
    eventBinder,
    column
  ]);

  const l10n = useLocale('fieldsStep');

  return (
    <div className="CSVImporter_ColumnDragSourceArea__box">
      <div
        {...(isAssigned ? {} : eventHandlers)}
        style={{ touchAction: 'none' }}
      >
        <ColumnDragCard
          column={column}
          isAssigned={isAssigned}
          isShadow={isDragged || isAssigned}
          isDraggable={!dragState && !isDragged && !isAssigned}
        />
      </div>

      {/* tab order after column contents */}
      <div className="CSVImporter_ColumnDragSourceArea__boxAction">
        {isAssigned ? (
          <IconButton
            key="clear" // key-prop helps clear focus on click
            label={l10n.clearAssignmentTooltip}
            small
            type="replay"
            onClick={() => {
              onUnassign(column);
            }}
          />
        ) : (
          <IconButton
            key="dragSelect" // key-prop helps clear focus on click
            focusOnly
            label={
              dragState && dragState.column === column
                ? l10n.unselectColumnTooltip
                : l10n.selectColumnTooltip
            }
            small
            type="back"
            onClick={() => {
              onSelect(column);
            }}
          />
        )}
      </div>
    </div>
  );
};

// @todo current page indicator (dots)
export const ColumnDragSourceArea: React.FC<{
  columns: Column[];
  columnPageSize?: number;
  fieldAssignments: FieldAssignmentMap;
  dragState: DragState | null;
  eventBinder: (column: Column) => ReturnType<typeof useDrag>;
  onSelect: (column: Column) => void;
  onUnassign: (column: Column) => void;
}> = ({
  columns,
  columnPageSize,
  fieldAssignments,
  dragState,
  eventBinder,
  onSelect,
  onUnassign
}) => {
  // sanitize page size setting
  const pageSize = Math.round(Math.max(1, columnPageSize ?? DEFAULT_PAGE_SIZE));

  // track pagination state (resilient to page size changes)
  const [pageStart, setPageStart] = useState<number>(0);
  const [pageChanged, setPageChanged] = useState<boolean>(false);

  const page = Math.floor(pageStart / pageSize); // round down in case page size changes
  const pageCount = Math.ceil(columns.length / pageSize);

  // display page items and fill up with dummy divs up to pageSize
  const pageContents = columns
    .slice(page * pageSize, (page + 1) * pageSize)
    .map((column, columnIndex) => (
      <SourceBox
        key={columnIndex}
        column={column}
        fieldAssignments={fieldAssignments}
        dragState={dragState}
        eventBinder={eventBinder}
        onSelect={onSelect}
        onUnassign={onUnassign}
      />
    ));

  while (pageContents.length < pageSize) {
    pageContents.push(
      <div
        key={pageContents.length}
        className="CSVImporter_ColumnDragSourceArea__pageFiller"
      />
    );
  }

  const l10n = useLocale('fieldsStep');

  return (
    <section
      className="CSVImporter_ColumnDragSourceArea"
      aria-label={l10n.dragSourceAreaCaption}
    >
      <div className="CSVImporter_ColumnDragSourceArea__control">
        <IconButton
          label={l10n.previousColumnsTooltip}
          type="back"
          disabled={page === 0}
          onClick={() => {
            setPageStart(
              (prev) => Math.max(0, Math.floor(prev / pageSize) - 1) * pageSize
            );
            setPageChanged(true);
          }}
        />
      </div>
      <div className="CSVImporter_ColumnDragSourceArea__page">
        {dragState && !dragState.pointerStartInfo ? (
          <div
            className="CSVImporter_ColumnDragSourceArea__pageIndicator"
            role="status"
          >
            {l10n.getDragSourceActiveStatus(dragState.column.code)}
          </div>
        ) : (
          // show page number if needed (and treat as status role if it has changed)
          // @todo changing role to status does not seem to work
          pageCount > 1 && (
            <div
              className="CSVImporter_ColumnDragSourceArea__pageIndicator"
              role={pageChanged ? 'status' : 'text'}
            >
              {l10n.getDragSourcePageIndicator(page + 1, pageCount)}
            </div>
          )
        )}

        {pageContents}
      </div>
      <div className="CSVImporter_ColumnDragSourceArea__control">
        <IconButton
          label={l10n.nextColumnsTooltip}
          type="forward"
          disabled={page >= pageCount - 1}
          onClick={() => {
            setPageStart(
              (prev) =>
                Math.min(pageCount - 1, Math.floor(prev / pageSize) + 1) *
                pageSize
            );
          }}
        />
      </div>
    </section>
  );
};


================================================
FILE: src/components/fields-step/ColumnDragState.tsx
================================================
import { useState, useCallback, useRef } from 'react';

import { Column } from './ColumnPreview';

export interface DragState {
  // null if this is a non-pointer-initiated state
  pointerStartInfo: {
    // position + size of originating card relative to viewport overlay
    initialClientRect: DOMRectReadOnly;
  } | null;

  column: Column;
  dropFieldName: string | null;
  updateListeners: ((xy: number[]) => void)[];
}

export interface DragInfo {
  dragState: DragState | null;
  columnSelectHandler: (column: Column) => void;
  dragStartHandler: (
    column: Column,
    startFieldName: string | undefined,
    initialClientRect: DOMRectReadOnly
  ) => void;
  dragMoveHandler: (movement: [number, number]) => void;
  dragEndHandler: () => void;

  dragHoverHandler: (fieldName: string, isOn: boolean) => void;
  assignHandler: (fieldName: string) => void;
  unassignHandler: (column: Column) => void;
}

// state machine to represent the steps taken to assign a column to target field:
// - pick column (drag start or keyboard select)
// - hover over field (while dragging only)
// - assign picked column to field (drag end)
// @todo move the useDrag setup outside as well?
export function useColumnDragState(
  onColumnAssignment: (column: Column, fieldName: string | null) => void
): DragInfo {
  // wrap in ref to avoid re-triggering effects
  const onColumnAssignmentRef = useRef(onColumnAssignment);
  onColumnAssignmentRef.current = onColumnAssignment;

  const [dragState, setDragState] = useState<DragState | null>(null);

  const dragStartHandler = useCallback(
    (
      column: Column,
      startFieldName: string | undefined,
      initialClientRect: DOMRectReadOnly
    ) => {
      // create new pointer-based drag state
      setDragState({
        pointerStartInfo: {
          initialClientRect
        },
        column,
        dropFieldName: startFieldName !== undefined ? startFieldName : null,
        updateListeners: []
      });
    },
    []
  );

  const dragMoveHandler = useCallback(
    (movement: [number, number]) => {
      // @todo figure out a cleaner event stream solution
      if (dragState) {
        const listeners = dragState.updateListeners;
        for (const listener of listeners) {
          listener(movement);
        }
      }
    },
    [dragState]
  );

  const dragEndHandler = useCallback(() => {
    setDragState(null);

    if (dragState) {
      onColumnAssignmentRef.current(dragState.column, dragState.dropFieldName);
    }
  }, [dragState]);

  const columnSelectHandler = useCallback((column: Column) => {
    setDragState((prev) => {
      // toggle off if needed
      if (prev && prev.column === column) {
        return null;
      }

      return {
        pointerStartInfo: null, // no draggable position information
        column,
        dropFieldName: null,
        updateListeners: []
      };
    });
  }, []);

  const dragHoverHandler = useCallback((fieldName: string, isOn: boolean) => {
    setDragState((prev): DragState | null => {
      if (!prev) {
        return prev;
      }

      if (isOn) {
        // set the new drop target
        return {
          ...prev,
          dropFieldName: fieldName
        };
      } else if (prev.dropFieldName === fieldName) {
        // clear drop target if we are still the current one
        return {
          ...prev,
          dropFieldName: null
        };
      }

      // no changes by default
      return prev;
    });
  }, []);

  const assignHandler = useCallback(
    (fieldName: string) => {
      // clear active drag state
      setDragState(null);

      if (dragState) {
        onColumnAssignmentRef.current(dragState.column, fieldName);
      }
    },
    [dragState]
  );

  const unassignHandler = useCallback((column: Column) => {
    // clear active drag state
    setDragState(null);

    onColumnAssignmentRef.current(column, null);
  }, []);

  return {
    dragState,
    dragStartHandler,
    dragMoveHandler,
    dragEndHandler,
    dragHoverHandler,
    columnSelectHandler,
    assignHandler,
    unassignHandler
  };
}


================================================
FILE: src/components/fields-step/ColumnDragTargetArea.scss
================================================
@import '../../theme.scss';

.CSVImporter_ColumnDragTargetArea {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;

  &__box {
    flex-basis: 25%;
    flex-grow: 0;
    flex-shrink: 1;
    width: 0; // avoid interference from internal width
    padding-top: 1em; // not using margin for cleaner percentage calculation
    padding-right: 1em;
  }

  &__boxLabel {
    margin-bottom: 0.25em;
    font-weight: bold;
    color: $textColor;
    word-break: break-word;

    & > b {
      margin-left: 0.25em;
      color: $errorTextColor;
    }
  }

  &__boxValue {
    position: relative; // for action and placeholder
    z-index: 0; // contain the z-indexes of contents (to prevent e.g. placeholder being above drag object)
  }

  &__boxValueAction {
    position: absolute;
    top: 0; // icon button padding matches card padding
    right: 0;
    z-index: 1; // right above content
  }

  &__boxPlaceholderHelp {
    position: absolute;
    top: 0;
    left: 0;
    z-index: 1; // right above content
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 98%; // nudge up a bit
    padding: 0.5em;
    text-align: center; // in case text wraps
    color: $textSecondaryColor; // @todo font-size
  }
}


================================================
FILE: src/components/fields-step/ColumnDragTargetArea.tsx
================================================
import React, { useMemo, useRef } from 'react';
import { useDrag } from '@use-gesture/react';

import { FieldAssignmentMap } from '../../parser';
import { Column } from './ColumnPreview';
import { DragState } from './ColumnDragState';
import { ColumnDragCard } from './ColumnDragCard';
import { IconButton } from '../IconButton';
import { Field } from '../ImporterField';

export type FieldTouchedMap = { [name: string]: boolean | undefined };

import './ColumnDragTargetArea.scss';
import { useLocale } from '../../locale/LocaleContext';

const TargetBox: React.FC<{
  field: Field;
  hasHeaders: boolean; // for correct display of dummy card
  flexBasis?: string; // style override
  touched?: boolean;
  assignedColumn: Column | null;
  dragState: DragState | null;
  eventBinder: (
    column: Column,
    startFieldName?: string
  ) => ReturnType<typeof useDrag>;
  onHover: (fieldName: string, isOn: boolean) => void;
  onAssign: (fieldName: string) => void;
  onUnassign: (column: Column) => void;
}> = ({
  field,
  hasHeaders,
  flexBasis,
  touched,
  assignedColumn,
  dragState,
  eventBinder,
  onHover,
  onAssign,
  onUnassign
}) => {
  // respond to hover events when there is active mouse drag happening
  // (not keyboard-emulated one)
  const containerRef = useRef<HTMLDivElement>(null);

  // if this field is the current highlighted drop target,
  // get the originating column data for display
  const sourceColumn =
    dragState && dragState.dropFieldName === field.name
      ? dragState.column
      : null;

  // see if currently assigned column is being dragged again
  const isReDragged = dragState ? dragState.column === assignedColumn : false;

  // drag start handlers for columns that can be re-dragged (i.e. are assigned)
  const dragStartHandlers = useMemo(
    () =>
      assignedColumn && !isReDragged
        ? eventBinder(assignedColumn, field.name)
        : {},
    [eventBinder, assignedColumn, isReDragged, field.name]
  );

  const valueContents = useMemo(() => {
    if (sourceColumn) {
      return (
        <ColumnDragCard rowCount={3} column={sourceColumn} isDropIndicator />
      );
    }

    if (assignedColumn) {
      return (
        <ColumnDragCard
          rowCount={3}
          column={assignedColumn}
          isShadow={isReDragged}
          isDraggable={!isReDragged}
        />
      );
    }

    const hasError = touched && !field.isOptional;
    return (
      <ColumnDragCard
        rowCount={3}
        hasHeaders={hasHeaders}
        hasError={hasError}
      />
    );
  }, [hasHeaders, field, touched, assignedColumn, sourceColumn, isReDragged]);

  const l10n = useLocale('fieldsStep');

  // @todo mouse cursor changes to reflect draggable state
  return (
    <section
      className="CSVImporter_ColumnDragTargetArea__box"
      aria-label={
        field.isOptional
          ? l10n.getDragTargetOptionalCaption(field.label)
          : l10n.getDragTargetRequiredCaption(field.label)
      }
      ref={containerRef}
      style={{ flexBasis }}
      onPointerEnter={() => onHover(field.name, true)}
      onPointerLeave={() => onHover(field.name, false)}
    >
      <div className="CSVImporter_ColumnDragTargetArea__boxLabel" aria-hidden>
        {field.label}
        {field.isOptional ? null : <b>*</b>}
      </div>

      <div className="CSVImporter_ColumnDragTargetArea__boxValue">
        {!sourceColumn && !assignedColumn && (
          <div
            className="CSVImporter_ColumnDragTargetArea__boxPlaceholderHelp"
            aria-hidden
          >
            {l10n.dragTargetPlaceholder}
          </div>
        )}

        <div {...dragStartHandlers} style={{ touchAction: 'none' }}>
          {valueContents}
        </div>

        {/* tab order after column contents */}
        {dragState && !dragState.pointerStartInfo ? (
          <div className="CSVImporter_ColumnDragTargetArea__boxValueAction">
            <IconButton
              label={l10n.getDragTargetAssignTooltip(dragState.column.code)}
              small
              type="forward"
              onClick={() => onAssign(field.name)}
            />
          </div>
        ) : (
          !sourceColumn &&
          assignedColumn && (
            <div className="CSVImporter_ColumnDragTargetArea__boxValueAction">
              <IconButton
                label={l10n.dragTargetClearTooltip}
                small
                type="close"
                onClick={() => onUnassign(assignedColumn)}
              />
            </div>
          )
        )}
      </div>
    </section>
  );
};

export const ColumnDragTargetArea: React.FC<{
  hasHeaders: boolean; // for correct display of dummy card
  fields: Field[];
  columns: Column[];
  fieldRowSize?: number;
  fieldTouched: FieldTouchedMap;
  fieldAssignments: FieldAssignmentMap;
  dragState: DragState | null;
  eventBinder: (
    // @todo import type from drag state tracker
    column: Column,
    startFieldName?: string
  ) => ReturnType<typeof useDrag>;
  onHover: (fieldName: string, isOn: boolean) => void;
  onAssign: (fieldName: string) => void;
  onUnassign: (column: Column) => void;
}> = ({
  hasHeaders,
  fields,
  columns,
  fieldRowSize,
  fieldTouched,
  fieldAssignments,
  dragState,
  eventBinder,
  onHover,
  onAssign,
  onUnassign
}) => {
  const l10n = useLocale('fieldsStep');

  // override flex basis for unusual situations
  const flexBasis = fieldRowSize ? `${100 / fieldRowSize}%` : undefined;

  return (
    <section
      className="CSVImporter_ColumnDragTargetArea"
      aria-label={l10n.dragTargetAreaCaption}
    >
      {fields.map((field) => {
        const assignedColumnIndex = fieldAssignments[field.name];

        return (
          <TargetBox
            key={field.name}
            field={field}
            flexBasis={flexBasis}
            touched={fieldTouched[field.name]}
            hasHeaders={hasHeaders}
            assignedColumn={
              assignedColumnIndex !== undefined
                ? columns[assignedColumnIndex]
                : null
            }
            dragState={dragState}
            eventBinder={eventBinder}
            onHover={onHover}
            onAssign={onAssign}
            onUnassign={onUnassign}
          />
        );
      })}
    </section>
  );
};


================================================
FILE: src/components/fields-step/ColumnPreview.tsx
================================================
import { ImporterPreviewColumn } from '../ImporterProps';

export interface Column extends ImporterPreviewColumn {
  code: string;
}

// spreadsheet-style column code computation (A, B, ..., Z, AA, AB, ..., etc)
export function generateColumnCode(value: number): string {
  // ignore dummy index
  if (value < 0) {
    return '';
  }

  // first, determine how many base-26 letters there should be
  // (because the notation is not purely positional)
  let digitCount = 1;
  let base = 0;
  let next = 26;

  while (next <= value) {
    digitCount += 1;
    base = next;
    next = next * 26 + 26;
  }

  // then, apply normal positional digit computation on remainder above base
  let remainder = value - base;

  const digits = [];
  while (digits.length < digitCount) {
    const lastDigit = remainder % 26;
    remainder = Math.floor((remainder - lastDigit) / 26); // applying floor just in case

    // store ASCII code, with A as 0
    digits.unshift(65 + lastDigit);
  }

  return String.fromCharCode.apply(null, digits);
}

// prepare spreadsheet-like column display information for given raw data preview
export function generatePreviewColumns(
  firstRows: string[][],
  hasHeaders: boolean
): ImporterPreviewColumn[] {
  const columnStubs = [...new Array(firstRows[0].length)];

  return columnStubs.map((empty, index) => {
    const values = firstRows.map((row) => row[index] || '');

    const headerValue = hasHeaders ? values.shift() : undefined;

    return {
      index,
      header: headerValue,
      values
    };
  });
}


================================================
FILE: src/components/fields-step/FieldsStep.tsx
================================================
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useDrag } from '@use-gesture/react';

import { FieldAssignmentMap } from '../../parser';
import { FileStepState } from '../file-step/FileStep';
import { ImporterFrame } from '../ImporterFrame';
import {
  generatePreviewColumns,
  generateColumnCode,
  Column
} from './ColumnPreview';
import { useColumnDragState } from './ColumnDragState';
import { ColumnDragObject } from './ColumnDragObject';
import { ColumnDragSourceArea } from './ColumnDragSourceArea';
import { ColumnDragTargetArea, FieldTouchedMap } from './ColumnDragTargetArea';
import { Field } from '../ImporterField';
import { useLocale } from '../../locale/LocaleContext';

export interface FieldsStepState {
  fieldAssignments: FieldAssignmentMap;
}

export const FieldsStep: React.FC<{
  fields: Field[]; // current field definitions
  displayFieldRowSize?: number; // override defaults for unusual widths
  displayColumnPageSize?: number;

  fileState: FileStepState; // output from the file selector step
  prevState: FieldsStepState | null; // confirmed field selections so far

  onChange: (state: FieldsStepState) => void;
  onAccept: () => void;
  onCancel: () => void;
}> = ({
  fields,
  displayColumnPageSize,
  displayFieldRowSize,
  fileState,
  prevState,
  onChange,
  onAccept,
  onCancel
}) => {
  const l10n = useLocale('fieldsStep');

  const onChangeRef = useRef(onChange);
  onChangeRef.current = onChange;

  const columns = useMemo<Column[]>(
    () =>
      generatePreviewColumns(
        fileState.firstRows,
        fileState.hasHeaders
      ).map((item) => ({ ...item, code: generateColumnCode(item.index) })),
    [fileState]
  );

  // field assignments state
  const [fieldAssignments, setFieldAssignments] = useState<FieldAssignmentMap>(
    prevState ? prevState.fieldAssignments : {}
  );

  // make sure there are no extra fields
  useEffect(() => {
    const removedFieldNames = Object.keys(fieldAssignments).filter(
      (existingFieldName) =>
        !fields.some((field) => field.name === existingFieldName)
    );

    if (removedFieldNames.length > 0) {
      // @todo put everything inside this setter
      setFieldAssignments((prev) => {
        const copy = { ...prev };

        removedFieldNames.forEach((fieldName) => {
          delete copy[fieldName];
        });

        return copy;
      });
    }
  }, [fields, fieldAssignments]);

  // for any field, try to find an automatic match from known column names
  useEffect(() => {
    // prep insensitive/fuzzy match stems for known columns
    const columnStemMap: Record<string, number | undefined> = {};
    for (const column of columns) {
      const stem = column.header?.trim().toLowerCase() || undefined;

      if (stem) {
        columnStemMap[stem] = column.index;
      }
    }

    setFieldAssignments((prev) => {
      // prepare a lookup of already assigned columns
      const assignedColumns = columns.map(() => false);

      for (const fieldName of Object.keys(prev)) {
        const assignedColumnIndex = prev[fieldName];
        if (assignedColumnIndex !== undefined) {
          assignedColumns[assignedColumnIndex] = true;
        }
      }

      // augment with new auto-assignments
      const copy = { ...prev };
      for (const field of fields) {
        // ignore if field is already assigned
        if (copy[field.name] !== undefined) {
          continue;
        }

        // find by field stem
        const fieldLabelStem = field.label.trim().toLowerCase(); // @todo consider normalizing other whitespace/non-letters
        const matchingColumnIndex = columnStemMap[fieldLabelStem];

        // ignore if equivalent column not found
        if (matchingColumnIndex === undefined) {
          continue;
        }

        // ignore if column is already assigned
        if (assignedColumns[matchingColumnIndex]) {
          continue;
        }

        // auto-assign the column
        copy[field.name] = matchingColumnIndex;
      }

      return copy;
    });
  }, [fields, columns]);

  // track which fields need to show validation warning
  const [fieldTouched, setFieldTouched] = useState<FieldTouchedMap>({});
  const [validationError, setValidationError] = useState<string | null>(null);

  // clean up touched field map when dynamic field list changes
  useEffect(() => {
    setFieldTouched((prev) => {
      const result: FieldTouchedMap = {};
      for (const field of fields) {
        result[field.name] = prev[field.name];
      }

      return result;
    });
  }, [fields]);

  // abstract mouse drag/keyboard state tracker
  const {
    dragState,

    dragStartHandler,
    dragMoveHandler,
    dragEndHandler,
    dragHoverHandler,

    columnSelectHandler,
    assignHandler,
    unassignHandler
  } = useColumnDragState((column: Column, fieldName: string | null) => {
    // update field assignment map state
    setFieldAssignments((prev) => {
      const currentFieldName = Object.keys(prev).find(
        (fieldName) => prev[fieldName] === column.index
      );

      // see if there is nothing to do
      if (currentFieldName === undefined && fieldName === null) {
        return prev;
      }

      const copy = { ...prev };

      // ensure dropped column does not show up elsewhere
      if (currentFieldName) {
        delete copy[currentFieldName];
      }

      // set new field column
      if (fieldName !== null) {
        copy[fieldName] = column.index;
      }

      return copy;
    });

    // mark for validation display
    if (fieldName) {
      setFieldTouched((prev) => {
        if (prev[fieldName]) {
          return prev;
        }

        return { ...prev, [fieldName]: true };
      });
    }
  });

  // drag gesture wire-up
  const bindDrag = useDrag(
    ({ first, last, movement, xy, args, currentTarget }) => {
      if (first) {
        const [column, startFieldName] = args as [Column, string | undefined];
        const initialClientRect =
          currentTarget instanceof HTMLElement
            ? currentTarget.getBoundingClientRect()
            : new DOMRect(xy[0], xy[1], 0, 0); // fall back on just pointer position

        dragStartHandler(column, startFieldName, initialClientRect);
      } else if (last) {
        dragEndHandler();
      } else {
        dragMoveHandler(movement);
      }
    },
    {
      pointer: { capture: false } // turn off pointer capture to avoid interfering with hover tests
    }
  );

  // when dragging, set root-level user-select:none to prevent text selection, see Importer.scss
  // (done via class toggle to avoid interfering with any other dynamic style changes)
  useEffect(() => {
    if (dragState) {
      document.body.classList.add('CSVImporter_dragging');
    } else {
      // remove text selection prevention after a delay (otherwise on iOS it still selects something)
      const timeoutId = setTimeout(() => {
        document.body.classList.remove('CSVImporter_dragging');
      }, 200);

      return () => {
        // if another drag state comes along then cancel our delay and just clean up class right away
        clearTimeout(timeoutId);
        document.body.classList.remove('CSVImporter_dragging');
      };
    }
  }, [dragState]);

  // notify of current state
  useEffect(() => {
    onChangeRef.current({ fieldAssignments: { ...fieldAssignments } });
  }, [fieldAssignments]);

  return (
    <ImporterFrame
      fileName={fileState.file.name}
      subtitle={l10n.stepSubtitle}
      error={validationError}
      onCancel={onCancel}
      onNext={() => {
        // mark all fields as touched (to show all the errors now)
        const fullTouchedMap: typeof fieldTouched = {};
        fields.forEach((field) => {
          fullTouchedMap[field.name] = true;
        });
        setFieldTouched(fullTouchedMap);

        // submit if validation succeeds
        const hasUnassignedRequired = fields.some(
          (field) =>
            !field.isOptional && fieldAssignments[field.name] === undefined
        );

        if (!hasUnassignedRequired) {
          onAccept();
        } else {
          setValidationError(l10n.requiredFieldsError);
        }
      }}
      nextLabel={l10n.nextButton}
    >
      <ColumnDragSourceArea
        columns={columns}
        columnPageSize={displayColumnPageSize}
        fieldAssignments={fieldAssignments}
        dragState={dragState}
        eventBinder={bindDrag}
        onSelect={columnSelectHandler}
        onUnassign={unassignHandler}
      />

      <ColumnDragTargetArea
        hasHeaders={fileState.hasHeaders}
        fieldRowSize={displayFieldRowSize}
        fields={fields}
        columns={columns}
        fieldTouched={fieldTouched}
        fieldAssignments={fieldAssignments}
        dragState={dragState}
        eventBinder={bindDrag}
        onHover={dragHoverHandler}
        onAssign={assignHandler}
        onUnassign={unassignHandler}
      />

      <ColumnDragObject dragState={dragState} />
    </ImporterFrame>
  );
};


================================================
FILE: src/components/file-step/FileSelector.scss
================================================
@import '../../theme.scss';

.CSVImporter_FileSelector {
  border: 0.25em dashed $fgColor;
  padding: 4em;
  border-radius: $borderRadius;
  background: $fillColor;
  text-align: center;
  color: $textColor;
  cursor: pointer;

  &[data-active='true'] {
    background: darken($fillColor, 10%);
    transition: background 0.1s ease-out;
  }
}


================================================
FILE: src/components/file-step/FileSelector.tsx
================================================
import React, { useCallback, useRef } from 'react';
import { useDropzone } from 'react-dropzone';
import { useLocale } from '../../locale/LocaleContext';

import './FileSelector.scss';

export const FileSelector: React.FC<{ onSelected: (file: File) => void }> = ({
  onSelected
}) => {
  const onSelectedRef = useRef(onSelected);
  onSelectedRef.current = onSelected;

  const dropHandler = useCallback((acceptedFiles: File[]) => {
    // silently ignore if nothing to do
    if (acceptedFiles.length < 1) {
      return;
    }

    const file = acceptedFiles[0];
    onSelectedRef.current(file);
  }, []);

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop: dropHandler
  });

  const l10n = useLocale('fileStep');

  return (
    <div
      className="CSVImporter_FileSelector"
      data-active={!!isDragActive}
      {...getRootProps()}
    >
      <input {...getInputProps()} />

      {isDragActive ? (
        <span>{l10n.activeDragDropPrompt}</span>
      ) : (
        <span>{l10n.initialDragDropPrompt}</span>
      )}
    </div>
  );
};


================================================
FILE: src/components/file-step/FileStep.scss
================================================
@import '../../theme.scss';

.CSVImporter_FileStep {
  &__header {
    display: flex;
    align-items: center;
    margin-bottom: 0.5em;
    font-size: $titleFontSize;
    color: $textSecondaryColor;
  }

  &__headerToggle {
    display: flex;
    align-items: center;
    margin-top: -0.5em; // allow for larger toggle element
    margin-bottom: -0.5em;
    margin-left: 1.5em;
    color: $textColor;
    cursor: pointer;

    > input[type='checkbox'] {
      margin-right: 0.5em;
      width: 1.2em;
      height: 1.2em;
      cursor: pointer;
    }
  }

  &__mainPendingBlock {
    display: flex;
    align-content: center;
    justify-content: center;
    padding: 2em;
    color: $textSecondaryColor;
  }
}


================================================
FILE: src/components/file-step/FileStep.tsx
================================================
import React, { useMemo, useRef, useEffect, useState } from 'react';

import {
  parsePreview,
  PreviewResults,
  PreviewReport,
  CustomizablePapaParseConfig
} from '../../parser';
import { ImporterFrame } from '../ImporterFrame';
import { FileSelector } from './FileSelector';
import { FormatRawPreview } from './FormatRawPreview';
import { FormatDataRowPreview } from './FormatDataRowPreview';
import { FormatErrorMessage } from './FormatErrorMessage';

import './FileStep.scss';
import { useLocale } from '../../locale/LocaleContext';

export interface FileStepState extends PreviewReport {
  papaParseConfig: CustomizablePapaParseConfig; // config that was used for preview parsing
  hasHeaders: boolean;
}

export const FileStep: React.FC<{
  customConfig: CustomizablePapaParseConfig;
  defaultNoHeader?: boolean;
  prevState: FileStepState | null;
  onChange: (state: FileStepState | null) => void;
  onAccept: () => void;
}> = ({ customConfig, defaultNoHeader, prevState, onChange, onAccept }) => {
  const l10n = useLocale('fileStep');

  // seed from previous state as needed
  const [selectedFile, setSelectedFile] = useState<File | null>(
    prevState ? prevState.file : null
  );

  const [preview, setPreview] = useState<PreviewResults | null>(
    () =>
      prevState && {
        parseError: undefined,
        ...prevState
      }
  );

  const [papaParseConfig, setPapaParseConfig] = useState(
    prevState ? prevState.papaParseConfig : customConfig
  );

  const [hasHeaders, setHasHeaders] = useState(
    prevState ? prevState.hasHeaders : false
  );

  // wrap in ref to avoid triggering effect
  const customConfigRef = useRef(customConfig);
  customConfigRef.current = customConfig;
  const defaultNoHeaderRef = useRef(defaultNoHeader);
  defaultNoHeaderRef.current = defaultNoHeader;
  const onChangeRef = useRef(onChange);
  onChangeRef.current = onChange;

  // notify of current state
  useEffect(() => {
    onChangeRef.current(
      preview && !preview.parseError
        ? { ...preview, papaParseConfig, hasHeaders }
        : null
    );
  }, [preview, papaParseConfig, hasHeaders]);

  // perform async preview parse once for the given file
  const asyncLockRef = useRef<number>(0);
  useEffect(() => {
    // clear other state when file selector is reset
    if (!selectedFile) {
      setPreview(null);
      return;
    }

    // preserve existing state when parsing for this file is already complete
    if (preview && preview.file === selectedFile) {
      return;
    }

    const oplock = asyncLockRef.current;

    // lock in the current PapaParse config instance for use in multiple spots
    const config = customConfigRef.current;

    // kick off the preview parse
    parsePreview(selectedFile, config).then((results) => {
      // ignore if stale
      if (oplock !== asyncLockRef.current) {
        return;
      }

      // save the results and the original config
      setPreview(results);
      setPapaParseConfig(config);

      // pre-fill headers flag (only possible with >1 lines)
      setHasHeaders(
        results.parseError
          ? false
          : !defaultNoHeaderRef.current && !results.isSingleLine
      );
    });

    return () => {
      // invalidate current oplock on change or unmount
      asyncLockRef.current += 1;
    };
  }, [selectedFile, preview]);

  // clear selected file
  // preview result content to display
  const reportBlock = useMemo(() => {
    if (!preview) {
      return null;
    }

    if (preview.parseError) {
      return (
        <div className="CSVImporter_FileStep__mainResultBlock">
          <FormatErrorMessage onCancelClick={() => setSelectedFile(null)}>
            {l10n.getImportError(
              preview.parseError.message || String(preview.parseError)
            )}
          </FormatErrorMessage>
        </div>
      );
    }

    return (
      <div className="CSVImporter_FileStep__mainResultBlock">
        <div className="CSVImporter_FileStep__header">
          {l10n.rawFileContentsHeading}
        </div>

        <FormatRawPreview
          chunk={preview.firstChunk}
          warning={preview.parseWarning}
          onCancelClick={() => setSelectedFile(null)}
        />

        {preview.parseWarning ? null : (
          <>
            <div className="CSVImporter_FileStep__header">
              {l10n.previewImportHeading}
              {!preview.isSingleLine && ( // hide setting if only one line anyway
                <label className="CSVImporter_FileStep__headerToggle">
                  <input
                    type="checkbox"
                    checked={hasHeaders}
                    onChange={() => {
                      setHasHeaders((prev) => !prev);
                    }}
                  />
                  <span>{l10n.dataHasHeadersCheckbox}</span>
                </label>
              )}
            </div>
            <FormatDataRowPreview
              hasHeaders={hasHeaders}
              rows={preview.firstRows}
            />
          </>
        )}
      </div>
    );
  }, [preview, hasHeaders, l10n]);

  if (!selectedFile) {
    return <FileSelector onSelected={(file) => setSelectedFile(file)} />;
  }

  return (
    <ImporterFrame
      fileName={selectedFile.name}
      nextDisabled={!preview || !!preview.parseError || !!preview.parseWarning}
      onNext={() => {
        if (!preview || preview.parseError) {
          throw new Error('unexpected missing preview info');
        }

        onAccept();
      }}
      onCancel={() => setSelectedFile(null)}
      nextLabel={l10n.nextButton}
    >
      {reportBlock || (
        <div className="CSVImporter_FileStep__mainPendingBlock">
          {l10n.previewLoadingStatus}
        </div>
      )}
    </ImporterFrame>
  );
};


================================================
FILE: src/components/file-step/FormatDataRowPreview.scss
================================================
@import '../../theme.scss';

.CSVImporter_FormatDataRowPreview {
  max-height: 12em;
  min-height: 6em;
  border: 1px solid $controlBorderColor;
  overflow: scroll;

  &__table {
    width: 100%;
    border-spacing: 0;
    border-collapse: collapse;

    > thead > tr > th {
      font-style: italic;
      font-weight: normal;
      color: $textSecondaryColor;
    }

    > thead > tr > th,
    > tbody > tr > td {
      border-right: 1px solid rgba($controlBorderColor, 0.5);
      padding: 0.5em 0.5em;
      line-height: 1;
      font-size: 0.75em;
      white-space: nowrap;

      &:last-child {
        border-right: none;
      }
    }

    // shrink space between rows
    > thead + tbody > tr:first-child > td,
    > tbody > tr + tr > td {
      padding-top: 0;
    }
  }
}


================================================
FILE: src/components/file-step/FormatDataRowPreview.tsx
================================================
import React from 'react';

import './FormatDataRowPreview.scss';

export const FormatDataRowPreview: React.FC<{
  hasHeaders: boolean;
  rows: string[][];
  // eslint-disable-next-line react/display-name
}> = React.memo(({ hasHeaders, rows }) => {
  const headerRow = hasHeaders ? rows[0] : null;
  const bodyRows = hasHeaders ? rows.slice(1) : rows;

  return (
    <div className="CSVImporter_FormatDataRowPreview">
      <table className="CSVImporter_FormatDataRowPreview__table">
        {headerRow && (
          <thead>
            <tr>
              {headerRow.map((item, itemIndex) => (
                <th key={itemIndex}>{item}</th>
              ))}
            </tr>
          </thead>
        )}

        <tbody>
          {bodyRows.map((row, rowIndex) => (
            <tr key={rowIndex}>
              {row.map((item, itemIndex) => (
                <td key={itemIndex}>{item}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
});


================================================
FILE: src/components/file-step/FormatErrorMessage.scss
================================================
@import '../../theme.scss';

.CSVImporter_FormatErrorMessage {
  display: flex;
  align-items: center;
  padding: 0.5em 1em;
  border-radius: $borderRadius;
  background: $fillColor;
  color: $errorTextColor;

  & > span {
    flex: 1 1 0;
    margin-right: 1em;
    width: 0; // avoid sizing on inner content
    word-break: break-word;
  }
}


================================================
FILE: src/components/file-step/FormatErrorMessage.tsx
================================================
import React from 'react';

import { TextButton } from '../TextButton';

import './FormatErrorMessage.scss';
import { useLocale } from '../../locale/LocaleContext';

export const FormatErrorMessage: React.FC<{
  onCancelClick: () => void;
  // eslint-disable-next-line react/display-name
}> = React.memo(({ onCancelClick, children }) => {
  const l10n = useLocale('fileStep');
  return (
    <div className="CSVImporter_FormatErrorMessage">
      <span>{children}</span>
      <TextButton onClick={onCancelClick}>{l10n.goBackButton}</TextButton>
    </div>
  );
});


================================================
FILE: src/components/file-step/FormatRawPreview.scss
================================================
@import '../../theme.scss';

.CSVImporter_FormatRawPreview {
  &__scroll {
    margin-bottom: 1.2em;
    height: 6em;
    overflow: auto;
    border-radius: $borderRadius;
    background: $invertedBgColor;
    color: $invertedTextColor;
  }

  &__pre {
    margin: 0; // override default
    padding: 0.5em 1em;
    line-height: 1.25;
    font-size: 1.15em;

    & > aside {
      display: inline-block;
      margin-left: 0.2em;
      padding: 0 0.25em;
      border-radius: $borderRadius * 0.5;
      background: $controlBgColor;
      font-size: 0.75em;
      color: $controlBorderColor;
      opacity: 0.75;
    }
  }
}


================================================
FILE: src/components/file-step/FormatRawPreview.tsx
================================================
import React from 'react';
import { useLocale } from '../../locale/LocaleContext';

import { FormatErrorMessage } from './FormatErrorMessage';

import './FormatRawPreview.scss';

const RAW_PREVIEW_SIZE = 500;

export const FormatRawPreview: React.FC<{
  chunk: string;
  warning?: Papa.ParseError;
  onCancelClick: () => void;
  // eslint-disable-next-line react/display-name
}> = React.memo(({ chunk, warning, onCancelClick }) => {
  const chunkSlice = chunk.slice(0, RAW_PREVIEW_SIZE);
  const chunkHasMore = chunk.length > RAW_PREVIEW_SIZE;

  const l10n = useLocale('fileStep');

  return (
    <div className="CSVImporter_FormatRawPreview">
      <div className="CSVImporter_FormatRawPreview__scroll">
        <pre className="CSVImporter_FormatRawPreview__pre">
          {chunkSlice}
          {chunkHasMore && <aside>...</aside>}
        </pre>
      </div>

      {warning ? (
        <FormatErrorMessage onCancelClick={onCancelClick}>
          {l10n.getDataFormatError(warning.message || String(warning))}
        </FormatErrorMessage>
      ) : null}
    </div>
  );
});


================================================
FILE: src/index.ts
================================================
export * from './components/ImporterProps';
export * from './components/Importer';
export * from './locale';


================================================
FILE: src/locale/ImporterLocale.ts
================================================
export interface ImporterLocale {
  general: {
    goToPreviousStepTooltip: string;
  };

  fileStep: {
    initialDragDropPrompt: string;
    activeDragDropPrompt: string;

    getImportError: (message: string) => string;
    getDataFormatError: (message: string) => string;
    goBackButton: string;
    nextButton: string;

    rawFileContentsHeading: string;
    previewImportHeading: string;
    dataHasHeadersCheckbox: string;
    previewLoadingStatus: string;
  };

  fieldsStep: {
    stepSubtitle: string;
    requiredFieldsError: string;
    nextButton: string;

    dragSourceAreaCaption: string;
    getDragSourcePageIndicator: (
      currentPage: number,
      pageCount: number
    ) => string;
    getDragSourceActiveStatus: (columnCode: string) => string;
    nextColumnsTooltip: string;
    previousColumnsTooltip: string;
    clearAssignmentTooltip: string;
    selectColumnTooltip: string;
    unselectColumnTooltip: string;

    dragTargetAreaCaption: string;
    getDragTargetOptionalCaption: (field: string) => string;
    getDragTargetRequiredCaption: (field: string) => string;
    dragTargetPlaceholder: string;
    getDragTargetAssignTooltip: (columnCode: string) => string;
    dragTargetClearTooltip: string;

    columnCardDummyHeader: string;
    getColumnCardHeader: (code: string) => string;
  };

  progressStep: {
    stepSubtitle: string;
    uploadMoreButton: string;
    finishButton: string;
    statusError: string;
    statusComplete: string;
    statusPending: string;
    processedRowsLabel: string;
  };
}


================================================
FILE: src/locale/LocaleContext.tsx
================================================
import React from 'react';
import { ImporterLocale, enUS } from '.';
import { useContext } from 'react';

export const LocaleContext = React.createContext<ImporterLocale>(enUS);

type I18nNamespace = keyof ImporterLocale;

export function useLocale<N extends I18nNamespace>(
  namespace: N
): ImporterLocale[N] {
  const locale = useContext(LocaleContext);
  return locale[namespace]; // not using memo for basic property getter
}


================================================
FILE: src/locale/index.ts
================================================
export type { ImporterLocale } from './ImporterLocale';

export { enUS } from './locale_enUS';
export { deDE } from './locale_deDE';
export { itIT } from './locale_itIT';
export { ptBR } from './locale_ptBR';
export { daDK } from './locale_daDK';
export { trTR } from './locale_trTR';


================================================
FILE: src/locale/locale_daDK.ts
================================================
import { ImporterLocale } from './ImporterLocale';

/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
export const daDK: ImporterLocale = {
  general: {
    goToPreviousStepTooltip: 'Gå til forrige trin'
  },

  fileStep: {
    initialDragDropPrompt:
      'Træk og slip CSV-fil her eller klik for at vælge fra en mappe',
    activeDragDropPrompt: 'Slip CSV-fil her...',

    getImportError: (message) => `Import-fejl: ${message}`,
    getDataFormatError: (message) =>
      `Kontrollér venligst data-formatering: ${message}`,
    goBackButton: 'Gå tilbage',
    nextButton: 'Vælg kolonner',

    rawFileContentsHeading: 'Rå filindhold',
    previewImportHeading: 'Forhåndsvis Import',
    dataHasHeadersCheckbox: 'Data sidehoved',
    previewLoadingStatus: 'Indlæser forhåndsvisning...'
  },

  fieldsStep: {
    stepSubtitle: 'Vælg kolonner',
    requiredFieldsError: 'Tildel venligst alle påkrævede felter',
    nextButton: 'Importér',

    dragSourceAreaCaption: 'Kolonner til import',
    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
      `Side ${currentPage} af ${pageCount}`,
    getDragSourceActiveStatus: (columnCode: string) =>
      `Tildeler kolonne ${columnCode}`,
    nextColumnsTooltip: 'Vis næste kolonner',
    previousColumnsTooltip: 'Vis forrige kolonner',
    clearAssignmentTooltip: 'Ryd kolonne-tildeling',
    selectColumnTooltip: 'Vælg kolonne til tildeling',
    unselectColumnTooltip: 'Fravælg kolonne',

    dragTargetAreaCaption: 'Mål-felter',
    getDragTargetOptionalCaption: (field) => `${field} (valgfri)`,
    getDragTargetRequiredCaption: (field) => `${field} (påkrævet)`,
    dragTargetPlaceholder: 'Træk kolonne hertil',
    getDragTargetAssignTooltip: (columnCode: string) =>
      `Tildel kolonne ${columnCode}`,
    dragTargetClearTooltip: 'Ryd kolonne-tildeling',

    columnCardDummyHeader: 'Disponibelt felt',
    getColumnCardHeader: (code) => `Column ${code}`
  },

  progressStep: {
    stepSubtitle: 'Importér',
    uploadMoreButton: 'Upload Mere',
    finishButton: 'Færdiggør',
    statusError: 'Kunne ikke importere',
    statusComplete: 'Færdig',
    statusPending: 'Importerer...',
    processedRowsLabel: 'Processerede rækker:'
  }
};


================================================
FILE: src/locale/locale_deDE.ts
================================================
import { ImporterLocale } from './ImporterLocale';

/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
export const deDE: ImporterLocale = {
  general: {
    goToPreviousStepTooltip: 'Zum vorherigen Schritt'
  },

  fileStep: {
    initialDragDropPrompt:
      'CSV-Datei auf dieses Feld ziehen, oder klicken um eine Datei auszuwählen',
    activeDragDropPrompt: 'CSV-Datei auf dieses Feld ziehen...',
    nextButton: 'Spalten auswählen',

    getImportError: (message) => `Fehler beim Import: ${message}`,
    getDataFormatError: (message: string) =>
      `Bitte Datenformat überprüfen: ${message}`,
    goBackButton: 'Zurück',

    rawFileContentsHeading: 'Originaler Datei-Inhalt',
    previewImportHeading: 'Import-Vorschau',
    dataHasHeadersCheckbox: 'Mit Kopfzeile',
    previewLoadingStatus: 'Vorschau wird geladen...'
  },

  fieldsStep: {
    stepSubtitle: 'Spalten auswählen',
    requiredFieldsError:
      'Bitte weise allen nicht optionalen Spalten einen Wert zu',
    nextButton: 'Importieren',

    dragSourceAreaCaption: 'Zu importierende Spalte',
    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
      `Seite ${currentPage} von ${pageCount}`,
    getDragSourceActiveStatus: (columnCode: string) =>
      `Spalte ${columnCode} zuweisen`,
    nextColumnsTooltip: 'Nächste Spalten anzeigen',
    previousColumnsTooltip: 'Vorherige Spalten anzeigen',
    clearAssignmentTooltip: 'Zugewiesene Spalte entfernen',
    selectColumnTooltip: 'Spalte zum Zuweisen auswählen',
    unselectColumnTooltip: 'Spalte abwählen',

    dragTargetAreaCaption: 'Zielfelder',
    getDragTargetOptionalCaption: (field) => `${field} (optional)`,
    getDragTargetRequiredCaption: (field) => `${field} (erforderlich)`,
    dragTargetPlaceholder: 'Spalte hierher ziehen',
    getDragTargetAssignTooltip: (columnCode: string) =>
      `Spalte ${columnCode} zuweisen`,
    dragTargetClearTooltip: 'Zugewiesene Spalte entfernen',

    columnCardDummyHeader: 'Nicht zugewiesenes Feld',
    getColumnCardHeader: (code) => `Spalte ${code}`
  },

  progressStep: {
    stepSubtitle: 'Importieren',
    uploadMoreButton: 'Weitere hochladen',
    finishButton: 'Abschließen',
    statusError: 'Konnte nicht importiert werden',
    statusComplete: 'Fertig',
    statusPending: 'Wird importiert...',
    processedRowsLabel: 'Verarbeitete Zeilen:'
  }
};


================================================
FILE: src/locale/locale_enUS.ts
================================================
import { ImporterLocale } from './ImporterLocale';

/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
export const enUS: ImporterLocale = {
  general: {
    goToPreviousStepTooltip: 'Go to previous step'
  },

  fileStep: {
    initialDragDropPrompt:
      'Drag-and-drop CSV file here, or click to select in folder',
    activeDragDropPrompt: 'Drop CSV file here...',

    getImportError: (message) => `Import error: ${message}`,
    getDataFormatError: (message) => `Please check data formatting: ${message}`,
    goBackButton: 'Go Back',
    nextButton: 'Choose columns',

    rawFileContentsHeading: 'Raw File Contents',
    previewImportHeading: 'Preview Import',
    dataHasHeadersCheckbox: 'Data has headers',
    previewLoadingStatus: 'Loading preview...'
  },

  fieldsStep: {
    stepSubtitle: 'Select Columns',
    requiredFieldsError: 'Please assign all required fields',
    nextButton: 'Import',

    dragSourceAreaCaption: 'Columns to import',
    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
      `Page ${currentPage} of ${pageCount}`,
    getDragSourceActiveStatus: (columnCode: string) =>
      `Assigning column ${columnCode}`,
    nextColumnsTooltip: 'Show next columns',
    previousColumnsTooltip: 'Show previous columns',
    clearAssignmentTooltip: 'Clear column assignment',
    selectColumnTooltip: 'Select column for assignment',
    unselectColumnTooltip: 'Unselect column',

    dragTargetAreaCaption: 'Target fields',
    getDragTargetOptionalCaption: (field) => `${field} (optional)`,
    getDragTargetRequiredCaption: (field) => `${field} (required)`,
    dragTargetPlaceholder: 'Drag column here',
    getDragTargetAssignTooltip: (columnCode: string) =>
      `Assign column ${columnCode}`,
    dragTargetClearTooltip: 'Clear column assignment',

    columnCardDummyHeader: 'Unassigned field',
    getColumnCardHeader: (code) => `Column ${code}`
  },

  progressStep: {
    stepSubtitle: 'Import',
    uploadMoreButton: 'Upload More',
    finishButton: 'Finish',
    statusError: 'Could not import',
    statusComplete: 'Complete',
    statusPending: 'Importing...',
    processedRowsLabel: 'Processed rows:'
  }
};


================================================
FILE: src/locale/locale_itIT.ts
================================================
import { ImporterLocale } from './ImporterLocale';

/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
export const itIT: ImporterLocale = {
  general: {
    goToPreviousStepTooltip: 'Torna indietro'
  },

  fileStep: {
    initialDragDropPrompt:
      'Trascina qui il file CSV, o clicca per selezionarlo dal PC',
    activeDragDropPrompt: 'Rilascia qui il file CSV...',

    getImportError: (message) => `Errore durante l'importazione: ${message}`,
    getDataFormatError: (message) =>
      `Si prega di controllare il formato dei dati: ${message}`,
    goBackButton: 'Torna indietro',
    nextButton: 'Seleziona le colonne',

    rawFileContentsHeading: 'Contenuto delfile caricato',
    previewImportHeading: 'Anteprima dei dati',
    dataHasHeadersCheckbox: 'Intestazione presente nel file',
    previewLoadingStatus: 'Caricamento anteprima...'
  },

  fieldsStep: {
    stepSubtitle: 'Seleziona le colonne',
    requiredFieldsError: 'Si prega di assegnare tutte le colonne richieste',
    nextButton: 'Importa',

    dragSourceAreaCaption: 'Colonne da importare',
    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
      `Pagina ${currentPage} di ${pageCount}`,
    getDragSourceActiveStatus: (columnCode: string) =>
      `Assegnamento alla colonna ${columnCode}`,
    nextColumnsTooltip: 'Mostra colonna successiva',
    previousColumnsTooltip: 'Mostra colonna precedente',
    clearAssignmentTooltip: 'Cancella tutti gli assegnamenti delle colonne',
    selectColumnTooltip: 'Seleziona una colonna da assegnare',
    unselectColumnTooltip: 'Deseleziona colonna',

    dragTargetAreaCaption: 'Campi richiesti',
    getDragTargetOptionalCaption: (field) => `${field} (opzionale)`,
    getDragTargetRequiredCaption: (field) => `${field} (obbligatorio)`,
    dragTargetPlaceholder: 'Trascina qui la colonna',
    getDragTargetAssignTooltip: (columnCode: string) =>
      `Assegnamento alla colonna ${columnCode}`,
    dragTargetClearTooltip: 'Cancella gli assegnamenti alla colonna',

    columnCardDummyHeader: 'Campo non assegnato',
    getColumnCardHeader: (code) => `Column ${code}`
  },

  progressStep: {
    stepSubtitle: 'Importa',
    uploadMoreButton: 'Carica altri dati',
    finishButton: 'Fine',
    statusError: 'Errore di caricamento',
    statusComplete: 'Completato',
    statusPending: 'Caricamento...',
    processedRowsLabel: 'Righe processate:'
  }
};


================================================
FILE: src/locale/locale_ptBR.ts
================================================
import { ImporterLocale } from './ImporterLocale';

/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
export const ptBR: ImporterLocale = {
  general: {
    goToPreviousStepTooltip: 'Voltar a etapa anterior'
  },

  fileStep: {
    initialDragDropPrompt:
      'Arraste e solte o arquivo CSV aqui ou clique para selecionar na pasta',
    activeDragDropPrompt: 'Arraste e solte o arquivo CSV aqui...',

    getImportError: (message) => `Erro ao importar: ${message}`,
    getDataFormatError: (message) =>
      `Por favor confira a formatação dos dados: ${message}`,
    goBackButton: 'Voltar',
    nextButton: 'Escolher Colunas',

    rawFileContentsHeading: 'Conteúdo Bruto do Arquivo',
    previewImportHeading: 'Visualizar Importação',
    dataHasHeadersCheckbox: 'Os dados têm cabeçalhos',
    previewLoadingStatus: 'Carregando visualização...'
  },

  fieldsStep: {
    stepSubtitle: 'Selecionar Colunas',
    requiredFieldsError: 'Atribua todos os campos obrigatórios',
    nextButton: 'Importar',

    dragSourceAreaCaption: 'Colunas para importar',
    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
      `Página ${currentPage} de ${pageCount}`,
    getDragSourceActiveStatus: (columnCode: string) =>
      `Atribuindo coluna ${columnCode}`,
    nextColumnsTooltip: 'Mostrar as próximas colunas',
    previousColumnsTooltip: 'Mostrar colunas anteriores',
    clearAssignmentTooltip: 'Limpar atribuição de coluna',
    selectColumnTooltip: 'Selecione a coluna para atribuição',
    unselectColumnTooltip: 'Desmarcar coluna',

    dragTargetAreaCaption: 'Campos de destino',
    getDragTargetOptionalCaption: (field) => `${field} (opcional)`,
    getDragTargetRequiredCaption: (field) => `${field} (obrigatório)`,
    dragTargetPlaceholder: 'Arraste a coluna aqui',
    getDragTargetAssignTooltip: (columnCode: string) =>
      `Atribuir coluna ${columnCode}`,
    dragTargetClearTooltip: 'Limpar atribuição de coluna',

    columnCardDummyHeader: 'Campo não atribuído',
    getColumnCardHeader: (code) => `Coluna ${code}`
  },

  progressStep: {
    stepSubtitle: 'Importar',
    uploadMoreButton: 'Carregar mais',
    finishButton: 'Finalizar',
    statusError: 'Não foi possível importar',
    statusComplete: 'Completo',
    statusPending: 'Importando...',
    processedRowsLabel: 'Linhas processadas:'
  }
};


================================================
FILE: src/locale/locale_trTR.ts
================================================
import { ImporterLocale } from './ImporterLocale';

/* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
export const trTR: ImporterLocale = {
  general: {
    goToPreviousStepTooltip: 'Bir önceki adıma geri dön'
  },

  fileStep: {
    initialDragDropPrompt:
      'CSV dosyasını sürükleyin veya kutunun içine tıklayıp dosyayı seçin',
    activeDragDropPrompt: 'CSV dosyasını buraya bırakın...',

    getImportError: (message) => `Import hatası: ${message}`,
    getDataFormatError: (message) =>
      `Lütfen veri formatını kontrol edin: ${message}`,
    goBackButton: 'Geri',
    nextButton: 'Kolonları Seç',

    rawFileContentsHeading: 'CSV dosyası içeriği',
    previewImportHeading: 'Import önizleme',
    dataHasHeadersCheckbox: 'Veride başlıklar var',
    previewLoadingStatus: 'Önizleme yükleniyor...'
  },

  fieldsStep: {
    stepSubtitle: 'Kolonları seçin',
    requiredFieldsError: 'Lütfen zorunlu tüm alanları doldurun.',
    nextButton: 'Import',

    dragSourceAreaCaption: 'Import edilecek kolonlar',
    getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
      `${pageCount} sayfadan ${currentPage}. sayfadasınız`,
    getDragSourceActiveStatus: (columnCode: string) =>
      `${columnCode}. kolon atanıyor`,
    nextColumnsTooltip: 'Sıradaki kolonları göster',
    previousColumnsTooltip: 'Önceki kolonları göster',
    clearAssignmentTooltip: 'Kolon atamayı temizle',
    selectColumnTooltip: 'Atamak için kolon seçiniz',
    unselectColumnTooltip: 'Kolonu seçmeyi bırak',

    dragTargetAreaCaption: 'Hedef alanlar',
    getDragTargetOptionalCaption: (field) => `${field} (opsiyonel)`,
    getDragTargetRequiredCaption: (field) => `${field} (zorunlu)`,
    dragTargetPlaceholder: 'Kolonu buraya sürükle',
    getDragTargetAssignTooltip: (columnCode: string) =>
      `${columnCode}. kolonu ata`,
    dragTargetClearTooltip: 'Kolon atamayı temizle',

    columnCardDummyHeader: 'Atanmamış alan',
    getColumnCardHeader: (code) => `Kolon ${code}`
  },

  progressStep: {
    stepSubtitle: 'Import',
    uploadMoreButton: 'Sonrakileri yükle',
    finishButton: 'Bitir',
    statusError: 'Import edilemedi',
    statusComplete: 'Tamamlandı',
    statusPending: 'Import ediliyor...',
    processedRowsLabel: 'İşlenen satır sayısı:'
  }
};


================================================
FILE: src/parser.ts
================================================
import Papa from 'papaparse';
import { Readable } from 'stream';

export interface CustomizablePapaParseConfig {
  delimiter?: Papa.ParseConfig['delimiter'];
  newline?: Papa.ParseConfig['newline'];
  quoteChar?: Papa.ParseConfig['quoteChar'];
  escapeChar?: Papa.ParseConfig['escapeChar'];
  comments?: Papa.ParseConfig['comments'];
  skipEmptyLines?: Papa.ParseConfig['skipEmptyLines'];
  delimitersToGuess?: Papa.ParseConfig['delimitersToGuess'];
  chunkSize?: Papa.ParseConfig['chunkSize'];
  encoding?: Papa.ParseConfig['encoding'];
}

export interface PreviewReport {
  file: File;
  firstChunk: string;
  firstRows: string[][]; // always PREVIEW_ROWS count
  isSingleLine: boolean;
  parseWarning?: Papa.ParseError;
}

// success/failure report from the preview parse attempt
export type PreviewResults =
  | {
      parseError: Error | Papa.ParseError;
      file: File;
    }
  | ({
      parseError: undefined;
    } & PreviewReport);

export const PREVIEW_ROW_COUNT = 5;

// for each given target field name, source from original CSV column index
export type FieldAssignmentMap = { [name: string]: number | undefined };

export type BaseRow = { [name: string]: unknown };

export type ParseCallback<Row extends BaseRow> = (
  rows: Row[],
  info: {
    startIndex: number;
  }
) => void | Promise<void>;

// polyfill as implemented in https://github.com/eligrey/Blob.js/blob/master/Blob.js#L653
// (this is for Safari pre v14.1)
function streamForBlob(blob: Blob) {
  if (blob.stream) {
    return blob.stream();
  }

  const res = new Response(blob);
  if (res.body) {
    return res.body;
  }

  throw new Error('This browser does not support client-side file reads');
}

// incredibly cheap wrapper exposing a subset of stream.Readable interface just for PapaParse usage
// @todo chunk size
function nodeStreamWrapper(stream: ReadableStream, encoding: string): Readable {
  let dataHandler: ((chunk: string) => void) | null = null;
  let endHandler: ((unused: unknown) => void) | null = null;
  let errorHandler: ((error: unknown) => void) | null = null;
  let isStopped = false;

  let pausePromise: Promise<void> | null = null;
  let pauseResolver: (() => void) | null = null;

  async function runReaderPump() {
    // ensure this is truly in the next tick after uncorking
    await Promise.resolve();

    const streamReader = stream.getReader();
    const decoder = new TextDecoder(encoding); // this also strips BOM by default

    try {
      // main reader pump loop
      while (!isStopped) {
        // perform read from upstream
        const { done, value } = await streamReader.read();

        // wait if we became paused since last data event
        if (pausePromise) {
          await pausePromise;
        }

        // check again if stopped and unlistened
        if (isStopped || !dataHandler || !endHandler) {
          return;
        }

        // final data flush and end notification
        if (done) {
          const lastChunkString = decoder.decode(value); // value is empty but pass just in case
          if (lastChunkString) {
            dataHandler(lastChunkString);
          }

          endHandler(undefined);
          return;
        }

        // otherwise, normal data event after stream-safe decoding
        const chunkString = decoder.decode(value, { stream: true });
        dataHandler(chunkString);
      }
    } finally {
      // always release the lock
      streamReader.releaseLock();
    }
  }

  const self = {
    // marker properties to make PapaParse think this is a Readable object
    readable: true,
    read() {
      throw new Error('only flowing mode is emulated');
    },

    on(event: string, callback: (param: unknown) => void) {
      switch (event) {
        case 'data':
          if (dataHandler) {
            throw new Error('two data handlers not supported');
          }
          dataHandler = callback;

          // flowing state started, run the main pump loop
          runReaderPump().catch((error) => {
            if (errorHandler) {
              errorHandler(error);
            } else {
              // rethrow to show error in console
              throw error;
            }
          });

          return;
        case 'end':
          if (endHandler) {
            throw new Error('two end handlers not supported');
          }
          endHandler = callback;
          return;
        case 'error':
          if (errorHandler) {
            throw new Error('two error handlers not supported');
          }
          errorHandler = callback;
          return;
      }

      throw new Error('unknown stream shim event: ' + event);
    },

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    removeListener(event: string, callback: (param: unknown) => void) {
      // stop and clear everything for simplicity
      isStopped = true;
      dataHandler = null;
      endHandler = null;
      errorHandler = null;
    },

    pause() {
      if (!pausePromise) {
        pausePromise = new Promise((resolve) => {
          pauseResolver = resolve;
        });
      }
      return self;
    },

    resume() {
      if (pauseResolver) {
        pauseResolver(); // waiting code will proceed in next tick
        pausePromise = null;
        pauseResolver = null;
      }
      return self;
    }
  };

  // pass ourselves off as a real Node stream
  return (self as unknown) as Readable;
}

export function parsePreview(
  file: File,
  customConfig: CustomizablePapaParseConfig
): Promise<PreviewResults> {
  // wrap synchronous errors in promise
  return new Promise<PreviewResults>((resolve) => {
    let firstChunk: string | null = null;
    let firstWarning: Papa.ParseError | undefined = undefined;
    const rowAccumulator: string[][] = [];

    function reportSuccess() {
      // PapaParse normally complains first anyway, but might as well flag it
      if (rowAccumulator.length === 0) {
        return {
          parseError: new Error('File is empty'),
          file
        };
      }

      // remember whether this file has only one line
      const isSingleLine = rowAccumulator.length === 1;

      // fill preview with blanks if needed
      while (rowAccumulator.length < PREVIEW_ROW_COUNT) {
        rowAccumulator.push([]);
      }

      resolve({
        file,
        parseError: undefined,
        parseWarning: firstWarning || undefined,
        firstChunk: firstChunk || '',
        firstRows: rowAccumulator,
        isSingleLine
      });
    }

    // use our own multibyte-safe streamer, bail after first chunk
    // (this used to add skipEmptyLines but that was hiding possible parse errors)
    // @todo wait for upstream multibyte fix in PapaParse: https://github.com/mholt/PapaParse/issues/908
    const nodeStream = nodeStreamWrapper(
      streamForBlob(file),
      customConfig.encoding || 'utf-8'
    );

    Papa.parse(nodeStream, {
      ...customConfig,

      chunkSize: 10000, // not configurable, preview only @todo make configurable
      preview: PREVIEW_ROW_COUNT,

      error: (error) => {
        resolve({
          parseError: error,
          file
        });
      },
      beforeFirstChunk: (chunk) => {
        firstChunk = chunk;
      },
      chunk: ({ data, errors }, parser) => {
        data.forEach((row) => {
          const stringRow = (row as unknown[]).map((item) =>
            typeof item === 'string' ? item : ''
          );

          rowAccumulator.push(stringRow);
        });

        if (errors.length > 0 && !firstWarning) {
          firstWarning = errors[0];
        }

        // finish parsing once we got enough data, otherwise try for more
        // (in some cases PapaParse flushes out last line as separate chunk)
        if (rowAccumulator.length >= PREVIEW_ROW_COUNT) {
          nodeStream.pause(); // parser does not pause source stream, do it here explicitly
          parser.abort();

          reportSuccess();
        }
      },
      complete: reportSuccess
    });
  }).catch((error) => {
    return {
      parseError: error, // delegate message display to UI logic
      file
    };
  });
}

export interface ParserInput {
  file: File;
  papaParseConfig: CustomizablePapaParseConfig;
  hasHeaders: boolean;
  fieldAssignments: FieldAssignmentMap;
}

export function processFile<Row extends BaseRow>(
  input: ParserInput,
  reportProgress: (deltaCount: number) => void,
  callback: ParseCallback<Row>
): Promise<void> {
  const { file, hasHeaders, papaParseConfig, fieldAssignments } = input;
  const fieldNames = Object.keys(fieldAssignments);

  // wrap synchronous errors in promise
  return new Promise<void>((resolve, reject) => {
    // skip first line if needed
    let skipLine = hasHeaders;
    let processedCount = 0;

    // use our own multibyte-safe decoding streamer
    // @todo wait for upstream multibyte fix in PapaParse: https://github.com/mholt/PapaParse/issues/908
    const nodeStream = nodeStreamWrapper(
      streamForBlob(file),
      papaParseConfig.encoding || 'utf-8'
    );

    Papa.parse(nodeStream, {
      ...papaParseConfig,
      chunkSize: papaParseConfig.chunkSize || 10000, // our own preferred default

      error: (error) => {
        reject(error);
      },
      chunk: ({ data }, parser) => {
        // pause to wait until the rows are consumed
        nodeStream.pause(); // parser does not pause source stream, do it here explicitly
        parser.pause();

        const skipped = skipLine && data.length > 0;

        const rows = (skipped ? data.slice(1) : data).map((row) => {
          const stringRow = (row as unknown[]).map((item) =>
            typeof item === 'string' ? item : ''
          );

          const record = {} as { [name: string]: string | undefined };

          fieldNames.forEach((fieldName) => {
            const columnIndex = fieldAssignments[fieldName];
            if (columnIndex !== undefined) {
              record[fieldName] = stringRow[columnIndex];
            }
          });

          return record as Row; // @todo look into a more precise setup
        });

        // clear line skip flag if there was anything to skip
        if (skipped) {
          skipLine = false;
        }

        // info snapshot for processing callback
        const info = {
          startIndex: processedCount
        };

        processedCount += rows.length;

        // @todo collect errors
        reportProgress(rows.length);

        // wrap sync errors in promise
        // (avoid invoking callback if there are no rows to consume)
        const whenConsumed = new Promise<void>((resolve) => {
          const result = rows.length ? callback(rows, info) : undefined;

          // introduce delay to allow a frame render
          setTimeout(() => resolve(result), 0);
        });

        // unpause parsing when done
        whenConsumed.then(
          () => {
            nodeStream.resume();
            parser.resume();
          },
          () => {
            // @todo collect errors
            nodeStream.resume();
            parser.resume();
          }
        );
      },
      complete: () => {
        resolve();
      }
    });
  });
}


================================================
FILE: src/theme.scss
================================================
$fgColor: #000;
$fillColor: #f0f0f0;
$controlBorderColor: #808080;
$controlBgColor: #fff;

$invertedTextColor: #f0f0f0;
$invertedBgColor: #404040;

$textColor: #202020;
$textSecondaryColor: #808080;
$textDisabledColor: rgba($textColor, 0.5);
$errorTextColor: #c00000;
$titleFontSize: 1.15em; // relative to body font

$borderRadius: 0.4em;


================================================
FILE: test/.eslintrc.json
================================================
{
  "env": {
    "node": true,
    "mocha": true
  },
  "parser": "@typescript-eslint/parser",
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint"
  ],
  "plugins": ["@typescript-eslint"]
}


================================================
FILE: test/basics.test.ts
================================================
import { By, until } from 'selenium-webdriver';
import { expect } from 'chai';
import path from 'path';

import { runTestServer } from './testServer';
import { runDriver } from './webdriver';
import { runUI } from './uiSetup';

// extra timeout allowance on CI
const testTimeoutMs = process.env.CI ? 20000 : 10000;

describe('importer basics', () => {
  const appUrl = runTestServer();
  const getDriver = runDriver();
  const initUI = runUI(getDriver);

  beforeEach(async () => {
    await getDriver().get(appUrl);

    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
      ReactDOM.render(
        React.createElement(
          ReactCSVImporter,
          {
            dataHandler: (rows, info) => {
              ((window as unknown) as Record<
                string,
                unknown
              >).TEST_DATA_HANDLER_ROWS = rows;
              ((window as unknown) as Record<
                string,
                unknown
              >).TEST_DATA_HANDLER_INFO = info;

              return new Promise((resolve) => {
                ((window as unknown) as Record<
                  string,
                  unknown
                >).TEST_DATA_HANDLER_RESOLVE = resolve;
              });
            }
          },
          [
            React.createElement(ReactCSVImporterField, {
              name: 'fieldA',
              label: 'Field A'
            }),
            React.createElement(ReactCSVImporterField, {
              name: 'fieldB',
              label: 'Field B',
              optional: true
            })
          ]
        ),
        document.getElementById('root')
      );
    });
  });

  it('shows file selector', async () => {
    const fileInput = await getDriver().findElement(By.xpath('//input'));
    expect(await fileInput.getAttribute('type')).to.equal('file');
  });

  describe('with file selected', () => {
    beforeEach(async () => {
      const filePath = path.resolve(__dirname, './fixtures/simple.csv');

      const fileInput = await getDriver().findElement(By.xpath('//input'));
      await fileInput.sendKeys(filePath);

      await getDriver().wait(
        until.elementLocated(By.xpath('//*[contains(., "Raw File Contents")]')),
        300 // extra time
      );
    });

    it('shows file name under active focus for screen reader', async () => {
      const focusedHeading = await getDriver().switchTo().activeElement();
      expect(await focusedHeading.getText()).to.equal('simple.csv');
    });

    it('shows raw file contents', async () => {
      const rawPreview = await getDriver().findElement(By.xpath('//pre'));
      expect(await rawPreview.getText()).to.have.string('AAAA,BBBB,CCCC,DDDD');
    });

    it('shows a preview table', async () => {
      const tablePreview = await getDriver().findElement(By.xpath('//table'));

      // header row
      const tableCols = await tablePreview.findElements(
        By.xpath('thead/tr/th')
      );
      const tableColStrings = await tableCols.reduce(
        async (acc, col) => [...(await acc), await col.getText()],
        Promise.resolve([] as string[])
      );
      expect(tableColStrings).to.deep.equal(['ColA', 'ColB', 'ColC', 'ColD']);

      // first data row
      const firstDataCells = await tablePreview.findElements(
        By.xpath('tbody/tr[1]/td')
      );
      const firstDataCellStrings = await firstDataCells.reduce(
        async (acc, col) => [...(await acc), await col.getText()],
        Promise.resolve([] as string[])
      );
      expect(firstDataCellStrings).to.deep.equal([
        'AAAA',
        'BBBB',
        'CCCC',
        'DDDD'
      ]);
    });

    it('allows toggling header row', async () => {
      const headersCheckbox = await getDriver().findElement(
        By.xpath(
          '//label[contains(., "Data has headers")]/input[@type="checkbox"]'
        )
      );

      await headersCheckbox.click();

      // ensure there are no headers now
      const tablePreview = await getDriver().findElement(By.xpath('//table'));
      const tableCols = await tablePreview.findElements(
        By.xpath('thead/tr/th')
      );
      expect(tableCols.length).to.equal(0);

      // first data row should now show the header strings
      const firstDataCells = await tablePreview.findElements(
        By.xpath('tbody/tr[1]/td')
      );
      const firstDataCellStrings = await firstDataCells.reduce(
        async (acc, col) => [...(await acc), await col.getText()],
        Promise.resolve([] as string[])
      );
      expect(firstDataCellStrings).to.deep.equal([
        'ColA',
        'ColB',
        'ColC',
        'ColD'
      ]);
    });

    describe('with preview accepted', () => {
      beforeEach(async () => {
        const nextButton = await getDriver().findElement(
          By.xpath('//button[text() = "Choose columns"]')
        );

        await nextButton.click();

        await getDriver().wait(
          until.elementLocated(By.xpath('//*[contains(., "Select Columns")]')),
          300 // extra time
        );
      });

      it('shows selection prompt under active focus for screen reader', async () => {
        const focusedHeading = await getDriver().switchTo().activeElement();
        expect(await focusedHeading.getText()).to.equal('Select Columns');
      });

      it('shows target fields', async () => {
        const targetFields = await getDriver().findElements(
          By.xpath('//section[@aria-label = "Target fields"]/section')
        );

        expect(targetFields.length).to.equal(2);
        expect(await targetFields[0].getAttribute('aria-label')).to.equal(
          'Field A (required)'
        );
        expect(await targetFields[1].getAttribute('aria-label')).to.equal(
          'Field B (optional)'
        );
      });

      it('does not allow to proceed without assignment', async () => {
        const nextButton = await getDriver().findElement(
          By.xpath('//button[text() = "Import"]')
        );

        await nextButton.click();

        await getDriver().wait(
          until.elementLocated(
            By.xpath('//*[contains(., "Please assign all required fields")]')
          ),
          300 // extra time
        );
      });

      it('offers keyboard-only select start buttons', async () => {
        const selectButtons = await getDriver().findElements(
          By.xpath('//button[@aria-label = "Select column for assignment"]')
        );

        expect(selectButtons.length).to.equal(4);
      });

      describe('with assigned field', () => {
        beforeEach(async () => {
          // start the keyboard-based selection mode
          const focusedHeading = await getDriver().switchTo().activeElement();
          await focusedHeading.sendKeys('\t'); // tab to next element

          const selectButton = await getDriver().findElement(
            By.xpath(
              '//button[@aria-label = "Select column for assignment"][1]'
            )
          );
          await selectButton.sendKeys('\n'); // cannot use click

          await getDriver().wait(
            until.elementLocated(
              By.xpath('//*[contains(., "Assigning column A")]')
            ),
            200
          );

          const assignButton = await getDriver().findElement(
            By.xpath('//button[@aria-label = "Assign column A"]')
          );
          await assignButton.click();
        });

        describe('with confirmation to start processing', () => {
          beforeEach(async () => {
            const nextButton = await getDriver().findElement(
              By.xpath('//button[text() = "Import"]')
            );

            await nextButton.click();

            await getDriver().wait(
              until.elementLocated(
                By.xpath(
                  '//button[@aria-label = "Go to previous step"]/../*[contains(., "Import")]'
                )
              ),
              200
            );
          });

          it('sets focus on next heading', async () => {
            const focusedHeading = await getDriver().switchTo().activeElement();
            expect(await focusedHeading.getText()).to.equal('Import');
          });

          it('does not finish until dataHandler returns', async () => {
            await getDriver().sleep(300);

            const focusedHeading = await getDriver().switchTo().activeElement();
            expect(await focusedHeading.getText()).to.equal('Import');
          });

          describe('after dataHandler is complete', () => {
            beforeEach(async () => {
              await getDriver().executeScript(
                'window.TEST_DATA_HANDLER_RESOLVE()'
              );
              await getDriver().wait(
                until.elementLocated(By.xpath('//*[contains(., "Complete")]')),
                200
              );
            });

            it('has active focus on completion message', async () => {
              const focusedHeading = await getDriver()
                .switchTo()
                .activeElement();
              expect(await focusedHeading.getText()).to.equal('Complete');
            });

            it('produces parsed data with correct fields', async () => {
              const parsedData = await getDriver().executeScript(
                'return window.TEST_DATA_HANDLER_ROWS'
              );
              const chunkInfo = await getDriver().executeScript(
                'return window.TEST_DATA_HANDLER_INFO'
              );

              expect(parsedData).to.deep.equal([
                { fieldA: 'AAAA' },
                { fieldA: 'EEEE' }
              ]);
              expect(chunkInfo).to.deep.equal({ startIndex: 0 });
            });

            it('does not show any interactable buttons', async () => {
              const anyButtons = await getDriver().findElements(
                By.xpath('//button')
              );

              expect(anyButtons.length).to.equal(1);
              expect(await anyButtons[0].getAttribute('aria-label')).to.equal(
                'Go to previous step'
              );
              expect(await anyButtons[0].getAttribute('disabled')).to.equal(
                'true'
              );
            });
          });
        });
      });
    });
  });
}).timeout(testTimeoutMs);


================================================
FILE: test/bom.test.ts
================================================
import { expect } from 'chai';
import path from 'path';

import { runTestServer } from './testServer';
import { runDriver } from './webdriver';
import { runUI, uiHelperSetup } from './uiSetup';
import { ImportInfo } from '../src/components/ImporterProps';

type RawWindow = Record<string, unknown>;

// extra timeout allowance on CI
const testTimeoutMs = process.env.CI ? 20000 : 10000;

describe('importer with input containing BOM character', () => {
  const appUrl = runTestServer();
  const getDriver = runDriver();
  const initUI = runUI(getDriver);
  const {
    uploadFile,
    getDisplayedPreviewData,
    advanceToFieldStepAndFinish
  } = uiHelperSetup(getDriver);

  beforeEach(async () => {
    await getDriver().get(appUrl);

    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
      ReactDOM.render(
        React.createElement(
          ReactCSVImporter,
          {
            onStart: (info) => {
              ((window as unknown) as RawWindow).TEST_ON_START_INFO = info;
            },
            dataHandler: (rows, info) => {
              ((window as unknown) as RawWindow).TEST_DATA_HANDLER_ROWS = rows;
              ((window as unknown) as RawWindow).TEST_DATA_HANDLER_INFO = info;
            }
          },
          [
            React.createElement(ReactCSVImporterField, {
              name: 'fieldA',
              label: 'Field A'
            }),
            React.createElement(ReactCSVImporterField, {
              name: 'fieldB',
              label: 'Field B',
              optional: true
            })
          ]
        ),
        document.getElementById('root')
      );
    });
  });

  describe('at preview stage', () => {
    beforeEach(async () => {
      await uploadFile(path.resolve(__dirname, './fixtures/bom.csv'));
    });

    it('shows correctly parsed preview table', async () => {
      expect(await getDisplayedPreviewData()).to.deep.equal([
        ['Date', 'Open', 'High', 'Low', 'Close', 'Adj Close', 'Volume'],
        [
          '2019-09-16',
          '299.839996',
          '301.140015',
          '299.450012',
          '300.160004',
          '294.285339',
          '58191200'
        ]
      ]);
    });

    describe('after accepting and assigning fields', () => {
      beforeEach(async () => {
        await advanceToFieldStepAndFinish();
      });

      it('reports correct import info', async () => {
        const importInfo = await getDriver().executeScript(
          'return window.TEST_ON_START_INFO'
        );

        expect(importInfo).to.have.property('preview');

        const { preview } = importInfo as ImportInfo;
        expect(preview).to.have.property('columns');
        expect(preview.columns).to.be.an('array');

        expect(preview.columns.map((item) => item.header)).to.deep.equal([
          'Date', // should not have BOM prefix
          'Open',
          'High',
          'Low',
          'Close',
          'Adj Close',
          'Volume'
        ]);
      });

      it('produces parsed data with correct fields', async () => {
        const parsedData = await getDriver().executeScript(
          'return window.TEST_DATA_HANDLER_ROWS'
        );
        const chunkInfo = await getDriver().executeScript(
          'return window.TEST_DATA_HANDLER_INFO'
        );

        expect(parsedData).to.deep.equal([{ fieldA: '2019-09-16' }]);
        expect(chunkInfo).to.deep.equal({ startIndex: 0 });
      });
    });
  });
}).timeout(testTimeoutMs);


================================================
FILE: test/customConfig.test.ts
================================================
import { By, until } from 'selenium-webdriver';
import { expect } from 'chai';
import path from 'path';

import { runTestServer } from './testServer';
import { runDriver } from './webdriver';
import { runUI } from './uiSetup';

// extra timeout allowance on CI
const testTimeoutMs = process.env.CI ? 20000 : 10000;

describe('importer with custom Papa Parse config', () => {
  const appUrl = runTestServer();
  const getDriver = runDriver();
  const initUI = runUI(getDriver);

  beforeEach(async () => {
    await getDriver().get(appUrl);

    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
      ReactDOM.render(
        React.createElement(
          ReactCSVImporter,
          {
            delimiter: '!', // not a normal guessable delimiter for Papa Parse
            dataHandler: (rows, info) => {
              ((window as unknown) as Record<
                string,
                unknown
              >).TEST_DATA_HANDLER_ROWS = rows;
              ((window as unknown) as Record<
                string,
                unknown
              >).TEST_DATA_HANDLER_INFO = info;
            }
          },
          [
            React.createElement(ReactCSVImporterField, {
              name: 'fieldA',
              label: 'Field A'
            }),
            React.createElement(ReactCSVImporterField, {
              name: 'fieldB',
              label: 'Field B',
              optional: true
            })
          ]
        ),
        document.getElementById('root')
      );
    });
  });

  describe('at preview stage', () => {
    beforeEach(async () => {
      const filePath = path.resolve(
        __dirname,
        './fixtures/customDelimited.txt'
      );

      const fileInput = await getDriver().findElement(By.xpath('//input'));
      await fileInput.sendKeys(filePath);

      await getDriver().wait(
        until.elementLocated(By.xpath('//*[contains(., "Raw File Contents")]')),
        300 // extra time
      );
    });

    it('shows correctly parsed preview table', async () => {
      const tablePreview = await getDriver().findElement(By.xpath('//table'));

      // header row
      const tableCols = await tablePreview.findElements(
        By.xpath('thead/tr/th')
      );
      const tableColStrings = await tableCols.reduce(
        async (acc, col) => [...(await acc), await col.getText()],
        Promise.resolve([] as string[])
      );
      expect(tableColStrings).to.deep.equal(['val1', 'val2']);

      // first data row
      const firstDataCells = await tablePreview.findElements(
        By.xpath('tbody/tr[1]/td')
      );
      const firstDataCellStrings = await firstDataCells.reduce(
        async (acc, col) => [...(await acc), await col.getText()],
        Promise.resolve([] as string[])
      );
      expect(firstDataCellStrings).to.deep.equal(['val3', 'val4']);
    });

    describe('after accepting and assigning fields', () => {
      beforeEach(async () => {
        const previewNextButton = await getDriver().findElement(
          By.xpath('//button[text() = "Choose columns"]')
        );

        await previewNextButton.click();

        await getDriver().wait(
          until.elementLocated(By.xpath('//*[contains(., "Select Columns")]')),
          300 // extra time
        );

        // start the keyboard-based selection mode
        const focusedHeading = await getDriver().switchTo().activeElement();
        await focusedHeading.sendKeys('\t'); // tab to next element

        const selectButton = await getDriver().findElement(
          By.xpath('//button[@aria-label = "Select column for assignment"][1]')
        );
        await selectButton.sendKeys('\n'); // cannot use click

        await getDriver().wait(
          until.elementLocated(
            By.xpath('//*[contains(., "Assigning column A")]')
          ),
          200
        );

        const assignButton = await getDriver().findElement(
          By.xpath('//button[@aria-label = "Assign column A"]')
        );
        await assignButton.click();

        const fieldsNextButton = await getDriver().findElement(
          By.xpath('//button[text() = "Import"]')
        );

        await fieldsNextButton.click();

        await getDriver().wait(
          until.elementLocated(
            By.xpath(
              '//button[@aria-label = "Go to previous step"]/../*[contains(., "Import")]'
            )
          ),
          200
        );

        await getDriver().wait(
          until.elementLocated(By.xpath('//*[contains(., "Complete")]')),
          200
        );
      });

      it('produces parsed data with correct fields', async () => {
        const parsedData = await getDriver().executeScript(
          'return window.TEST_DATA_HANDLER_ROWS'
        );
        const chunkInfo = await getDriver().executeScript(
          'return window.TEST_DATA_HANDLER_INFO'
        );

        expect(parsedData).to.deep.equal([{ fieldA: 'val3' }]);
        expect(chunkInfo).to.deep.equal({ startIndex: 0 });
      });
    });
  });
}).timeout(testTimeoutMs);


================================================
FILE: test/encoding.test.ts
================================================
import { expect } from 'chai';
import path from 'path';

import { runTestServer } from './testServer';
import { runDriver } from './webdriver';
import { runUI, uiHelperSetup } from './uiSetup';

type RawWindow = Record<string, unknown>;

// extra timeout allowance on CI
const testTimeoutMs = process.env.CI ? 20000 : 10000;

describe('importer with custom encoding setting', () => {
  const appUrl = runTestServer();
  const getDriver = runDriver();
  const initUI = runUI(getDriver);
  const {
    uploadFile,
    getDisplayedPreviewData,
    advanceToFieldStepAndFinish
  } = uiHelperSetup(getDriver);

  beforeEach(async () => {
    await getDriver().get(appUrl);

    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
      ReactDOM.render(
        React.createElement(
          ReactCSVImporter,
          {
            encoding: 'windows-1250', // encoding incompatible with UTF-8
            delimiter: ',',
            dataHandler: (rows, info) => {
              ((window as unknown) as RawWindow).TEST_DATA_HANDLER_ROWS = rows;
              ((window as unknown) as RawWindow).TEST_DATA_HANDLER_INFO = info;
            }
          },
          [
            React.createElement(ReactCSVImporterField, {
              name: 'fieldA',
              label: 'Field A'
            }),
            React.createElement(ReactCSVImporterField, {
              name: 'fieldB',
              label: 'Field B',
              optional: true
            })
          ]
        ),
        document.getElementById('root')
      );
    });
  });

  describe('at preview stage', () => {
    beforeEach(async () => {
      await uploadFile(
        path.resolve(__dirname, './fixtures/encodingWindows1250.csv')
      );
    });

    it('shows correctly parsed preview table', async () => {
      expect(await getDisplayedPreviewData()).to.deep.equal([
        ['value1', 'value2'],
        ['Montréal', 'Köppen']
      ]);
    });

    describe('after accepting and assigning fields', () => {
      beforeEach(async () => {
        await advanceToFieldStepAndFinish();
      });

      it('produces parsed data with correct fields', async () => {
        const parsedData = await getDriver().executeScript(
          'return window.TEST_DATA_HANDLER_ROWS'
        );
        const chunkInfo = await getDriver().executeScript(
          'return window.TEST_DATA_HANDLER_INFO'
        );

        expect(parsedData).to.deep.equal([{ fieldA: 'Montréal' }]);
        expect(chunkInfo).to.deep.equal({ startIndex: 0 });
      });
    });
  });
}).timeout(testTimeoutMs);


================================================
FILE: test/fixtures/bom.csv
================================================
Date,Open,High,Low,Close,Adj Close,Volume
2019-09-16,299.839996,301.140015,299.450012,300.160004,294.285339,58191200


================================================
FILE: test/fixtures/customDelimited.txt
================================================
val1!val2
val3!val4


================================================
FILE: test/fixtures/encodingWindows1250.csv
================================================
value1,value2
Montral,Kppen


================================================
FILE: test/fixtures/noeof.csv
================================================
ColA,ColB,ColC,ColD
AAAA,BBBB,CCCC,DDDD
EEEE,FFFF,GGGG,HHHH

================================================
FILE: test/fixtures/simple.csv
================================================
ColA,ColB,ColC,ColD
AAAA,BBBB,CCCC,DDDD
EEEE,FFFF,GGGG,HHHH


================================================
FILE: test/noeof.test.ts
================================================
import { expect } from 'chai';
import path from 'path';

import { runTestServer } from './testServer';
import { runDriver } from './webdriver';
import { runUI, uiHelperSetup } from './uiSetup';

// extra timeout allowance on CI
const testTimeoutMs = process.env.CI ? 20000 : 10000;

describe('importer with input not terminated by EOL character at end of file', () => {
  const appUrl = runTestServer();
  const getDriver = runDriver();
  const initUI = runUI(getDriver);
  const {
    uploadFile,
    getDisplayedPreviewData,
    advanceToFieldStepAndFinish
  } = uiHelperSetup(getDriver);

  beforeEach(async () => {
    await getDriver().get(appUrl);

    await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
      ReactDOM.render(
        React.createElement(
          ReactCSVImporter,
          {
            dataHandler: (rows, info) => {
              const rawWin = window as any; // eslint-disable-line @typescript-eslint/no-explicit-any
              rawWin.TEST_DATA_HANDLER_ROWS = (
                rawWin.TEST_DATA_HANDLER_ROWS || []
              ).concat(rows);
              rawWin.TEST_DATA_HANDLER_INFO = info;
            }
          },
          [
            React.createElement(ReactCSVImporterField, {
              name: 'fieldA',
              label: 'Field A'
            }),
            React.createElement(ReactCSVImporterField, {
              name: 'fieldB',
              label: 'Field B',
              optional: true
            })
          ]
        ),
        document.getElementById('root')
      );
    });
  });

  describe('at preview stage', () => {
    beforeEach(async () => {
      await uploadFile(path.resolve(__dirname, './fixtures/noeof.csv'));
    });

    it('shows correctly parsed preview table', async () => {
      expect(await getDisplayedPreviewData()).to.deep.equal([
        ['ColA', 'ColB', 'ColC', 'ColD'],
        ['AAAA', 'BBBB', 'CCCC', 'DDDD']
      ]);
    });

    describe('after accepting and assigning fields', () => {
      beforeEach(async () => {
        await advanceToFieldStepAndFinish();
      });

      it('produces parsed data with correct fields', async () => {
        // await getDriver().sleep(10000);

        const parsedData = await getDriver().executeScript(
          'return window.TEST_DATA_HANDLER_ROWS'
        );
        const chunkInfo = await getDriver().executeScript(
          'return window.TEST_DATA_HANDLER_INFO'
        );

        expect(parsedData).to.deep.equal([
          { fieldA: 'AAAA' },
          { fieldA: 'EEEE' }
        ]);

        // chunk start may be 1 because the parser "flushes" the last line separately?
        expect(chunkInfo).to.deep.equal({ startIndex: 1 });
      });
    });
  });
}).timeout(testTimeoutMs);


================================================
FILE: test/public/index.html
================================================
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link rel="icon" href="data:image/x-icon;," />

    <link rel="stylesheet" href="/index.css" />
    <script src="/index.js"></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>


================================================
FILE: test/testServer.ts
================================================
import path from 'path';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';

const TEST_SERVER_PORT = 8090;

// @todo use pre-built dist folder instead (to properly test production artifacts)
// eslint-disable-next-line @typescript-eslint/no-var-requires
const appWebpackConfig = require('../webpack.config');

export function runTestServer(): string {
  let testDevServer: WebpackDevServer | null = null; // internal handle

  const serverUrl = `http://localhost:${TEST_SERVER_PORT}`;

  before(async function () {
    // override config to allow direct in-browser usage with test code
    const webpackConfig = {
      ...appWebpackConfig,

      module: {
        ...appWebpackConfig.module,

        rules: [
          ...appWebpackConfig.module.rules,

          {
            test: require.resolve('react'),
            loader: 'expose-loader',
            options: {
              exposes: ['React']
            }
          },
          {
            test: require.resolve('react-dom'),
            loader: 'expose-loader',
            options: {
              exposes: ['ReactDOM']
            }
          }
        ]
      },

      output: {
        ...appWebpackConfig.output,
        publicPath: '/',

        // browser-friendly settings
        libraryTarget: 'global',
        library: 'ReactCSVImporter'
      },

      // ensure everything is included instead of generating require() statements
      externals: {},

      mode: 'production',
      watch: false
    };

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const compiler = webpack(webpackConfig as any);

    const devServer = new WebpackDevServer(compiler, {
      contentBase: path.resolve(__dirname, './public'), // static test helper content
      hot: false,
      liveReload: false,
      noInfo: true,
      stats: 'errors-only'
    });

    // store reference for later cleanup
    testDevServer = devServer;

    const serverListenPromise = new Promise<void>((resolve, reject) => {
      devServer.listen(TEST_SERVER_PORT, 'localhost', function (err) {
        if (err) {
          reject(err);
        } else {
          resolve();
        }
      });
    });

    const serverCompilationPromise = new Promise<void>((resolve) => {
      compiler.hooks.done.tap('_', () => {
        resolve();
      });
    });

    await Promise.all([serverListenPromise, serverCompilationPromise]);
  });

  after(async function () {
    const devServer = testDevServer;
    testDevServer = null;

    if (!devServer) {
      throw new Error('dev server not initialized');
    }

    // wait for server to fully close
    await new Promise<void>((resolve) => {
      devServer.close(() => {
        resolve();
      });
    });
  });

  return serverUrl;
}


================================================
FILE: test/uiSetup.ts
================================================
import { By, until, ThenableWebDriver } from 'selenium-webdriver';
import ReactModule from 'react';
import ReactDOMModule from 'react-dom';

import {
  ImporterProps,
  ImporterFieldProps
} from '../src/components/ImporterProps';

export type ScriptBody = (
  r: typeof ReactModule,
  rd: typeof ReactDOMModule,
  im: (
    props: ImporterProps<Record<string, unknown>>
  ) => ReactModule.ReactElement,
  imf: (props: ImporterFieldProps) => ReactModule.ReactElement
) => void;

export function runUI(
  getDriver: () => ThenableWebDriver
): (script: ScriptBody) => Promise<void> {
  async function runScript(script: ScriptBody) {
    await getDriver().executeScript(
      `(${script.toString()})(React, ReactDOM, ReactCSVImporter.Importer, ReactCSVImporter.ImporterField)`
    );
  }

  // always clean up
  afterEach(async () => {
    await runScript((React, ReactDOM) => {
      ReactDOM.unmountComponentAtNode(
        document.getElementById('root') || document.body
      );
    });
  });

  return async function initUI(script: ScriptBody) {
    await runScript(script);

    await getDriver().wait(
      until.elementLocated(By.xpath('//span[contains(., "Drag-and-drop")]')),
      300 // a little extra time
    );
  };
}

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function uiHelperSetup(getDriver: () => ThenableWebDriver) {
  return {
    async uploadFile(filePath: string) {
      const fileInput = await getDriver().findElement(By.xpath('//input'));
      await fileInput.sendKeys(filePath);

      await getDriver().wait(
        until.elementLocated(By.xpath('//*[contains(., "Raw File Contents")]')),
        300 // extra time
      );
    },

    async getDisplayedPreviewData() {
      const tablePreview = await getDriver().findElement(By.xpath('//table'));

      // header row
      const tableCols = await tablePreview.findElements(
        By.xpath('thead/tr/th')
      );
      const tableColStrings = await tableCols.reduce(
        async (acc, col) => [...(await acc), await col.getText()],
        Promise.resolve([] as string[])
      );

      // first data row
      const firstDataCells = await tablePreview.findElements(
        By.xpath('tbody/tr[1]/td')
      );
      const firstDataCellStrings = await firstDataCells.reduce(
        async (acc, col) => [...(await acc), await col.getText()],
        Promise.resolve([] as string[])
      );

      return [tableColStrings, firstDataCellStrings];
    },

    async advanceToFieldStepAndFinish() {
      const previewNextButton = await getDriver().findElement(
        By.xpath('//button[text() = "Choose columns"]')
      );

      await previewNextButton.click();

      await getDriver().wait(
        until.elementLocated(By.xpath('//*[contains(., "Select Columns")]')),
        300 // extra time
      );

      // start the keyboard-based selection mode
      const focusedHeading = await getDriver().switchTo().activeElement();
      await focusedHeading.sendKeys('\t'); // tab to next element

      const selectButton = await getDriver().findElement(
        By.xpath('//button[@aria-label = "Select column for assignment"][1]')
      );
      await selectButton.sendKeys('\n'); // cannot use click

      await getDriver().wait(
        until.elementLocated(
          By.xpath('//*[contains(., "Assigning column A")]')
        ),
        200
      );

      const assignButton = await getDriver().findElement(
        By.xpath('//button[@aria-label = "Assign column A"]')
      );
      await assignButton.click();

      const fieldsNextButton = await getDriver().findElement(
        By.xpath('//button[text() = "Import"]')
      );

      await fieldsNextButton.click();

      await getDriver().wait(
        until.elementLocated(
          By.xpath(
            '//button[@aria-label = "Go to previous step"]/../*[contains(., "Import")]'
          )
        ),
        200
      );

      await getDriver().wait(
        until.elementLocated(By.xpath('//*[contains(., "Complete")]')),
        200
      );
    }
  };
}


================================================
FILE: test/webdriver.ts
================================================
import * as path from 'path';
import * as child_process from 'child_process';
import { Builder, ThenableWebDriver } from 'selenium-webdriver';
import * as chrome from 'selenium-webdriver/chrome';

async function getGlobalChromedriverPath() {
  const yarnGlobalPath = await new Promise<string>((resolve, reject) => {
    child_process.exec('yarn global dir', { timeout: 8000 }, (err, result) => {
      if (err) {
        reject(err);
      } else {
        resolve(result.trim());
      }
    });
  });

  return path.resolve(
    yarnGlobalPath,
    './node_modules/chromedriver/lib/chromedriver',
    process.platform === 'win32' ? './chromedriver.exe' : './chromedriver'
  );
}

export function runDriver(): () => ThenableWebDriver {
  let webdriver: ThenableWebDriver | null = null;

  // same webdriver instance serves all the tests in the suite
  before(async function () {
    const chromedriverPath = await getGlobalChromedriverPath();

    const service = new chrome.ServiceBuilder(chromedriverPath).build();
    chrome.setDefaultService(service);

    webdriver = new Builder()
      .forBrowser('chrome')
      .setChromeOptions(
        process.env.CI ? new chrome.Options().headless() : new chrome.Options()
      )
      .build();
  });

  after(async function () {
    if (!webdriver) {
      throw new Error(
        'cannot clean up webdriver because it was not initialized'
      );
    }

    await webdriver.quit();

    // complete cleanup
    webdriver = null;
  });

  // expose singleton getter
  return () => {
    if (!webdriver) {
      throw new Error('webdriver not initialized');
    }

    return webdriver;
  };
}


================================================
FILE: tsconfig.base.json
================================================
{
  "compilerOptions": {
    "target": "es6",
    "lib": ["dom", "dom.iterable", "es6"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "es6",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react"
  },
  "include": ["src"]
}


================================================
FILE: tsconfig.json
================================================
{
  "extends": "./tsconfig.base.json",
  "include": ["src", "test"]
}


================================================
FILE: webpack.config.js
================================================
const path = require('path');
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: { index: './src/index.ts' },
  output: {
    path: path.resolve(__dirname, 'dist'),
    libraryTarget: 'commonjs2'
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: [
          {
            loader: 'ts-loader',
            options: {
              configFile: 'tsconfig.base.json',
              compilerOptions: {
                noEmit: false
              }
            }
          }
        ],
        exclude: /node_modules/
      },
      {
        test: /\.scss$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
      }
    ]
  },
  externals: {
    papaparse: 'papaparse',
    react: 'react',
    'react-dom': 'react-dom',
    'react-dropzone': 'react-dropzone',
    'react-use-gesture': 'react-use-gesture'
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js']
  },
  devtool: 'cheap-source-map',
  optimization: {
    minimize: false
  },
  plugins: [new MiniCssExtractPlugin(), new CleanWebpackPlugin()]
};
Download .txt
gitextract_2gw30632/

├── .editorconfig
├── .github/
│   └── workflows/
│       └── test.yml
├── .gitignore
├── .prettierrc
├── .storybook/
│   ├── main.js
│   └── preview.js
├── LICENSE.md
├── README.md
├── demo-sandbox/
│   ├── .gitignore
│   ├── .prettierrc
│   ├── index.css
│   ├── index.jsx
│   └── package.json
├── package.json
├── src/
│   ├── .eslintrc.json
│   ├── .stylelintrc
│   ├── components/
│   │   ├── IconButton.scss
│   │   ├── IconButton.tsx
│   │   ├── Importer.scss
│   │   ├── Importer.stories.tsx
│   │   ├── Importer.tsx
│   │   ├── ImporterField.tsx
│   │   ├── ImporterFrame.scss
│   │   ├── ImporterFrame.tsx
│   │   ├── ImporterProps.ts
│   │   ├── ProgressDisplay.scss
│   │   ├── ProgressDisplay.tsx
│   │   ├── TextButton.scss
│   │   ├── TextButton.tsx
│   │   ├── fields-step/
│   │   │   ├── ColumnDragCard.scss
│   │   │   ├── ColumnDragCard.tsx
│   │   │   ├── ColumnDragObject.scss
│   │   │   ├── ColumnDragObject.tsx
│   │   │   ├── ColumnDragSourceArea.scss
│   │   │   ├── ColumnDragSourceArea.tsx
│   │   │   ├── ColumnDragState.tsx
│   │   │   ├── ColumnDragTargetArea.scss
│   │   │   ├── ColumnDragTargetArea.tsx
│   │   │   ├── ColumnPreview.tsx
│   │   │   └── FieldsStep.tsx
│   │   └── file-step/
│   │       ├── FileSelector.scss
│   │       ├── FileSelector.tsx
│   │       ├── FileStep.scss
│   │       ├── FileStep.tsx
│   │       ├── FormatDataRowPreview.scss
│   │       ├── FormatDataRowPreview.tsx
│   │       ├── FormatErrorMessage.scss
│   │       ├── FormatErrorMessage.tsx
│   │       ├── FormatRawPreview.scss
│   │       └── FormatRawPreview.tsx
│   ├── index.ts
│   ├── locale/
│   │   ├── ImporterLocale.ts
│   │   ├── LocaleContext.tsx
│   │   ├── index.ts
│   │   ├── locale_daDK.ts
│   │   ├── locale_deDE.ts
│   │   ├── locale_enUS.ts
│   │   ├── locale_itIT.ts
│   │   ├── locale_ptBR.ts
│   │   └── locale_trTR.ts
│   ├── parser.ts
│   └── theme.scss
├── test/
│   ├── .eslintrc.json
│   ├── basics.test.ts
│   ├── bom.test.ts
│   ├── customConfig.test.ts
│   ├── encoding.test.ts
│   ├── fixtures/
│   │   ├── bom.csv
│   │   ├── customDelimited.txt
│   │   ├── encodingWindows1250.csv
│   │   ├── noeof.csv
│   │   └── simple.csv
│   ├── noeof.test.ts
│   ├── public/
│   │   └── index.html
│   ├── testServer.ts
│   ├── uiSetup.ts
│   └── webdriver.ts
├── tsconfig.base.json
├── tsconfig.json
└── webpack.config.js
Download .txt
SYMBOL INDEX (50 symbols across 20 files)

FILE: src/components/Importer.stories.tsx
  type SampleImporterProps (line 16) | type SampleImporterProps = ImporterProps<{ fieldA: string }>;

FILE: src/components/Importer.tsx
  function Importer (line 18) | function Importer<Row extends BaseRow>(

FILE: src/components/ImporterField.tsx
  type Field (line 5) | interface Field {
  type FieldDef (line 12) | type FieldDef = Field & { instanceId: symbol };
  type FieldListSetter (line 13) | type FieldListSetter = (prev: FieldDef[]) => FieldDef[];
  function useFieldDefinitions (line 20) | function useFieldDefinitions(): [

FILE: src/components/ImporterProps.ts
  type ImporterPreviewColumn (line 6) | interface ImporterPreviewColumn {
  type ImporterFilePreview (line 12) | interface ImporterFilePreview {
  type ImportInfo (line 20) | interface ImportInfo {
  type ImporterContentRenderProp (line 27) | type ImporterContentRenderProp = (info: {
  type ImporterFieldProps (line 32) | interface ImporterFieldProps {
  type ImporterDataHandlerProps (line 38) | type ImporterDataHandlerProps<Row extends BaseRow> =
  type ImporterProps (line 51) | type ImporterProps<Row extends BaseRow> = ImporterDataHandlerProps<

FILE: src/components/ProgressDisplay.tsx
  function countUTF8Bytes (line 14) | function countUTF8Bytes(item: string) {
  function ProgressDisplay (line 24) | function ProgressDisplay<Row extends BaseRow>({

FILE: src/components/fields-step/ColumnDragSourceArea.tsx
  constant DEFAULT_PAGE_SIZE (line 13) | const DEFAULT_PAGE_SIZE = 5;

FILE: src/components/fields-step/ColumnDragState.tsx
  type DragState (line 5) | interface DragState {
  type DragInfo (line 17) | interface DragInfo {
  function useColumnDragState (line 38) | function useColumnDragState(

FILE: src/components/fields-step/ColumnDragTargetArea.tsx
  type FieldTouchedMap (line 11) | type FieldTouchedMap = { [name: string]: boolean | undefined };

FILE: src/components/fields-step/ColumnPreview.tsx
  type Column (line 3) | interface Column extends ImporterPreviewColumn {
  function generateColumnCode (line 8) | function generateColumnCode(value: number): string {
  function generatePreviewColumns (line 42) | function generatePreviewColumns(

FILE: src/components/fields-step/FieldsStep.tsx
  type FieldsStepState (line 19) | interface FieldsStepState {

FILE: src/components/file-step/FileStep.tsx
  type FileStepState (line 18) | interface FileStepState extends PreviewReport {

FILE: src/components/file-step/FormatRawPreview.tsx
  constant RAW_PREVIEW_SIZE (line 8) | const RAW_PREVIEW_SIZE = 500;

FILE: src/locale/ImporterLocale.ts
  type ImporterLocale (line 1) | interface ImporterLocale {

FILE: src/locale/LocaleContext.tsx
  type I18nNamespace (line 7) | type I18nNamespace = keyof ImporterLocale;
  function useLocale (line 9) | function useLocale<N extends I18nNamespace>(

FILE: src/parser.ts
  type CustomizablePapaParseConfig (line 4) | interface CustomizablePapaParseConfig {
  type PreviewReport (line 16) | interface PreviewReport {
  type PreviewResults (line 25) | type PreviewResults =
  constant PREVIEW_ROW_COUNT (line 34) | const PREVIEW_ROW_COUNT = 5;
  type FieldAssignmentMap (line 37) | type FieldAssignmentMap = { [name: string]: number | undefined };
  type BaseRow (line 39) | type BaseRow = { [name: string]: unknown };
  type ParseCallback (line 41) | type ParseCallback<Row extends BaseRow> = (
  function streamForBlob (line 50) | function streamForBlob(blob: Blob) {
  function nodeStreamWrapper (line 65) | function nodeStreamWrapper(stream: ReadableStream, encoding: string): Re...
  function parsePreview (line 193) | function parsePreview(
  type ParserInput (line 285) | interface ParserInput {
  function processFile (line 292) | function processFile<Row extends BaseRow>(

FILE: test/bom.test.ts
  type RawWindow (line 9) | type RawWindow = Record<string, unknown>;

FILE: test/encoding.test.ts
  type RawWindow (line 8) | type RawWindow = Record<string, unknown>;

FILE: test/testServer.ts
  constant TEST_SERVER_PORT (line 5) | const TEST_SERVER_PORT = 8090;
  function runTestServer (line 11) | function runTestServer(): string {

FILE: test/uiSetup.ts
  type ScriptBody (line 10) | type ScriptBody = (
  function runUI (line 19) | function runUI(
  function uiHelperSetup (line 48) | function uiHelperSetup(getDriver: () => ThenableWebDriver) {

FILE: test/webdriver.ts
  function getGlobalChromedriverPath (line 6) | async function getGlobalChromedriverPath() {
  function runDriver (line 24) | function runDriver(): () => ThenableWebDriver {
Condensed preview — 80 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (180K chars).
[
  {
    "path": ".editorconfig",
    "chars": 118,
    "preview": "[*]\nend_of_line = lf\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 455,
    "preview": "name: e2e tests\n\non:\n  push:\n    branches: [master]\n  pull_request:\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    strat"
  },
  {
    "path": ".gitignore",
    "chars": 20,
    "preview": "/node_modules\n/dist\n"
  },
  {
    "path": ".prettierrc",
    "chars": 73,
    "preview": "{\n  \"printWidth\": 80,\n  \"singleQuote\": true,\n  \"trailingComma\": \"none\"\n}\n"
  },
  {
    "path": ".storybook/main.js",
    "chars": 212,
    "preview": "module.exports = {\n  stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],\n  addons: [\n    '@sto"
  },
  {
    "path": ".storybook/preview.js",
    "chars": 74,
    "preview": "\nexport const parameters = {\n  actions: { argTypesRegex: \"^on[A-Z].*\" },\n}"
  },
  {
    "path": "LICENSE.md",
    "chars": 1091,
    "preview": "MIT License\n\nCopyright (c) 2020 Beamworks Enterprise Software Inc.\n\nPermission is hereby granted, free of charge, to any"
  },
  {
    "path": "README.md",
    "chars": 9978,
    "preview": "# React CSV Importer\n\n[![https://www.npmjs.com/package/react-csv-importer](https://img.shields.io/npm/v/react-csv-import"
  },
  {
    "path": "demo-sandbox/.gitignore",
    "chars": 75,
    "preview": "/node_modules\n\n# ignore this lockfile to keep versioning simple\n/yarn.lock\n"
  },
  {
    "path": "demo-sandbox/.prettierrc",
    "chars": 47,
    "preview": "{\n  \"printWidth\": 80,\n  \"singleQuote\": false\n}\n"
  },
  {
    "path": "demo-sandbox/index.css",
    "chars": 214,
    "preview": "body {\n  font-family: Arial, Helvetica, sans-serif;\n  padding: 1em;\n  background: #e0f4f8;\n  background-image: radial-gr"
  },
  {
    "path": "demo-sandbox/index.jsx",
    "chars": 2198,
    "preview": "import React from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { Importer, ImporterField } from \"react-csv-importer"
  },
  {
    "path": "demo-sandbox/package.json",
    "chars": 326,
    "preview": "{\n  \"name\": \"demo-sandbox\",\n  \"version\": \"0.0.1\",\n  \"description\": \"Sample react-csv-importer usage snippet\",\n  \"main\": "
  },
  {
    "path": "package.json",
    "chars": 3469,
    "preview": "{\n  \"name\": \"react-csv-importer\",\n  \"version\": \"0.8.1\",\n  \"description\": \"React CSV import widget with user-customizable"
  },
  {
    "path": "src/.eslintrc.json",
    "chars": 526,
    "preview": "{\n  \"env\": {\n    \"browser\": true,\n    \"es6\": true\n  },\n  \"settings\": {\n    \"react\": {\n      \"version\": \"detect\"\n    }\n  "
  },
  {
    "path": "src/.stylelintrc",
    "chars": 3865,
    "preview": "{\n  \"extends\": \"stylelint-config-standard\",\n  \"plugins\": [\n    \"stylelint-scss\",\n    \"stylelint-order\"\n  ],\n  \"rules\": {"
  },
  {
    "path": "src/components/IconButton.scss",
    "chars": 2637,
    "preview": "@import '../theme.scss';\n\n.CSVImporter_IconButton {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n "
  },
  {
    "path": "src/components/IconButton.tsx",
    "chars": 640,
    "preview": "import React from 'react';\n\nimport './IconButton.scss';\n\nexport const IconButton: React.FC<{\n  label: string;\n  type: 'b"
  },
  {
    "path": "src/components/Importer.scss",
    "chars": 423,
    "preview": ".CSVImporter_Importer {\n  // base styling for all content\n  box-sizing: border-box;\n  line-height: 1.4;\n\n  * {\n    box-s"
  },
  {
    "path": "src/components/Importer.stories.tsx",
    "chars": 4631,
    "preview": "import React, { useState } from 'react';\nimport { Story, Meta } from '@storybook/react';\n\nimport { ImporterProps } from "
  },
  {
    "path": "src/components/Importer.tsx",
    "chars": 4909,
    "preview": "import React, { useMemo, useState, useEffect } from 'react';\n\nimport { BaseRow } from '../parser';\nimport { FileStep, Fi"
  },
  {
    "path": "src/components/ImporterField.tsx",
    "chars": 2523,
    "preview": "import React, { useMemo, useState, useEffect, useContext } from 'react';\n\nimport { ImporterFieldProps } from './Importer"
  },
  {
    "path": "src/components/ImporterFrame.scss",
    "chars": 1884,
    "preview": "@import '../theme.scss';\n\n// @todo use em instead of rem\n.CSVImporter_ImporterFrame {\n  border: 1px solid $controlBorder"
  },
  {
    "path": "src/components/ImporterFrame.tsx",
    "chars": 2734,
    "preview": "import React, { useRef, useEffect } from 'react';\n\nimport { TextButton } from './TextButton';\nimport { IconButton } from"
  },
  {
    "path": "src/components/ImporterProps.ts",
    "chars": 2154,
    "preview": "import React from 'react';\nimport { ImporterLocale } from '../locale';\nimport { CustomizablePapaParseConfig, ParseCallba"
  },
  {
    "path": "src/components/ProgressDisplay.scss",
    "chars": 786,
    "preview": "@import '../theme.scss';\n\n.CSVImporter_ProgressDisplay {\n  padding: 2em;\n\n  &__status {\n    text-align: center;\n    font"
  },
  {
    "path": "src/components/ProgressDisplay.tsx",
    "chars": 7449,
    "preview": "import React, { useState, useEffect, useMemo, useRef } from 'react';\n\nimport { processFile, ParseCallback, BaseRow } fro"
  },
  {
    "path": "src/components/TextButton.scss",
    "chars": 425,
    "preview": "@import '../theme.scss';\n\n.CSVImporter_TextButton {\n  display: block;\n  margin: 0; // override default\n  border: 1px sol"
  },
  {
    "path": "src/components/TextButton.tsx",
    "chars": 394,
    "preview": "import React from 'react';\n\nimport './TextButton.scss';\n\nexport const TextButton: React.FC<{\n  disabled?: boolean;\n  onC"
  },
  {
    "path": "src/components/fields-step/ColumnDragCard.scss",
    "chars": 2151,
    "preview": "@import '../../theme.scss';\n\n.CSVImporter_ColumnDragCard {\n  position: relative;\n  z-index: 0; // reset stacking context"
  },
  {
    "path": "src/components/fields-step/ColumnDragCard.tsx",
    "chars": 2569,
    "preview": "import React, { useMemo } from 'react';\n\nimport { PREVIEW_ROW_COUNT } from '../../parser';\nimport { Column } from './Col"
  },
  {
    "path": "src/components/fields-step/ColumnDragObject.scss",
    "chars": 764,
    "preview": ".CSVImporter_ColumnDragObject {\n  &__overlay {\n    // scroll-independent container\n    position: fixed;\n    top: 0;\n    "
  },
  {
    "path": "src/components/fields-step/ColumnDragObject.tsx",
    "chars": 3093,
    "preview": "import React, { useRef, useLayoutEffect } from 'react';\nimport { createPortal } from 'react-dom';\n\nimport { ColumnDragCa"
  },
  {
    "path": "src/components/fields-step/ColumnDragSourceArea.scss",
    "chars": 964,
    "preview": "@import '../../theme.scss';\n\n.CSVImporter_ColumnDragSourceArea {\n  display: flex;\n  margin-top: 0.5em;\n  margin-bottom: "
  },
  {
    "path": "src/components/fields-step/ColumnDragSourceArea.tsx",
    "chars": 5975,
    "preview": "import React, { useState, useMemo } from 'react';\nimport { useDrag } from '@use-gesture/react';\n\nimport { FieldAssignmen"
  },
  {
    "path": "src/components/fields-step/ColumnDragState.tsx",
    "chars": 4089,
    "preview": "import { useState, useCallback, useRef } from 'react';\n\nimport { Column } from './ColumnPreview';\n\nexport interface Drag"
  },
  {
    "path": "src/components/fields-step/ColumnDragTargetArea.scss",
    "chars": 1263,
    "preview": "@import '../../theme.scss';\n\n.CSVImporter_ColumnDragTargetArea {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: flex"
  },
  {
    "path": "src/components/fields-step/ColumnDragTargetArea.tsx",
    "chars": 6286,
    "preview": "import React, { useMemo, useRef } from 'react';\nimport { useDrag } from '@use-gesture/react';\n\nimport { FieldAssignmentM"
  },
  {
    "path": "src/components/fields-step/ColumnPreview.tsx",
    "chars": 1544,
    "preview": "import { ImporterPreviewColumn } from '../ImporterProps';\n\nexport interface Column extends ImporterPreviewColumn {\n  cod"
  },
  {
    "path": "src/components/fields-step/FieldsStep.tsx",
    "chars": 9020,
    "preview": "import React, { useState, useMemo, useEffect, useRef } from 'react';\nimport { useDrag } from '@use-gesture/react';\n\nimpo"
  },
  {
    "path": "src/components/file-step/FileSelector.scss",
    "chars": 343,
    "preview": "@import '../../theme.scss';\n\n.CSVImporter_FileSelector {\n  border: 0.25em dashed $fgColor;\n  padding: 4em;\n  border-radi"
  },
  {
    "path": "src/components/file-step/FileSelector.tsx",
    "chars": 1079,
    "preview": "import React, { useCallback, useRef } from 'react';\nimport { useDropzone } from 'react-dropzone';\nimport { useLocale } f"
  },
  {
    "path": "src/components/file-step/FileStep.scss",
    "chars": 712,
    "preview": "@import '../../theme.scss';\n\n.CSVImporter_FileStep {\n  &__header {\n    display: flex;\n    align-items: center;\n    margi"
  },
  {
    "path": "src/components/file-step/FileStep.tsx",
    "chars": 5768,
    "preview": "import React, { useMemo, useRef, useEffect, useState } from 'react';\n\nimport {\n  parsePreview,\n  PreviewResults,\n  Previ"
  },
  {
    "path": "src/components/file-step/FormatDataRowPreview.scss",
    "chars": 784,
    "preview": "@import '../../theme.scss';\n\n.CSVImporter_FormatDataRowPreview {\n  max-height: 12em;\n  min-height: 6em;\n  border: 1px so"
  },
  {
    "path": "src/components/file-step/FormatDataRowPreview.tsx",
    "chars": 1000,
    "preview": "import React from 'react';\n\nimport './FormatDataRowPreview.scss';\n\nexport const FormatDataRowPreview: React.FC<{\n  hasHe"
  },
  {
    "path": "src/components/file-step/FormatErrorMessage.scss",
    "chars": 344,
    "preview": "@import '../../theme.scss';\n\n.CSVImporter_FormatErrorMessage {\n  display: flex;\n  align-items: center;\n  padding: 0.5em "
  },
  {
    "path": "src/components/file-step/FormatErrorMessage.tsx",
    "chars": 566,
    "preview": "import React from 'react';\n\nimport { TextButton } from '../TextButton';\n\nimport './FormatErrorMessage.scss';\nimport { us"
  },
  {
    "path": "src/components/file-step/FormatRawPreview.scss",
    "chars": 624,
    "preview": "@import '../../theme.scss';\n\n.CSVImporter_FormatRawPreview {\n  &__scroll {\n    margin-bottom: 1.2em;\n    height: 6em;\n  "
  },
  {
    "path": "src/components/file-step/FormatRawPreview.tsx",
    "chars": 1082,
    "preview": "import React from 'react';\nimport { useLocale } from '../../locale/LocaleContext';\n\nimport { FormatErrorMessage } from '"
  },
  {
    "path": "src/index.ts",
    "chars": 109,
    "preview": "export * from './components/ImporterProps';\nexport * from './components/Importer';\nexport * from './locale';\n"
  },
  {
    "path": "src/locale/ImporterLocale.ts",
    "chars": 1550,
    "preview": "export interface ImporterLocale {\n  general: {\n    goToPreviousStepTooltip: string;\n  };\n\n  fileStep: {\n    initialDragD"
  },
  {
    "path": "src/locale/LocaleContext.tsx",
    "chars": 431,
    "preview": "import React from 'react';\nimport { ImporterLocale, enUS } from '.';\nimport { useContext } from 'react';\n\nexport const L"
  },
  {
    "path": "src/locale/index.ts",
    "chars": 285,
    "preview": "export type { ImporterLocale } from './ImporterLocale';\n\nexport { enUS } from './locale_enUS';\nexport { deDE } from './l"
  },
  {
    "path": "src/locale/locale_daDK.ts",
    "chars": 2299,
    "preview": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types "
  },
  {
    "path": "src/locale/locale_deDE.ts",
    "chars": 2446,
    "preview": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types "
  },
  {
    "path": "src/locale/locale_enUS.ts",
    "chars": 2261,
    "preview": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types "
  },
  {
    "path": "src/locale/locale_itIT.ts",
    "chars": 2487,
    "preview": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types "
  },
  {
    "path": "src/locale/locale_ptBR.ts",
    "chars": 2430,
    "preview": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types "
  },
  {
    "path": "src/locale/locale_trTR.ts",
    "chars": 2359,
    "preview": "import { ImporterLocale } from './ImporterLocale';\n\n/* eslint-disable @typescript-eslint/explicit-module-boundary-types "
  },
  {
    "path": "src/parser.ts",
    "chars": 11161,
    "preview": "import Papa from 'papaparse';\nimport { Readable } from 'stream';\n\nexport interface CustomizablePapaParseConfig {\n  delim"
  },
  {
    "path": "src/theme.scss",
    "chars": 340,
    "preview": "$fgColor: #000;\n$fillColor: #f0f0f0;\n$controlBorderColor: #808080;\n$controlBgColor: #fff;\n\n$invertedTextColor: #f0f0f0;\n"
  },
  {
    "path": "test/.eslintrc.json",
    "chars": 293,
    "preview": "{\n  \"env\": {\n    \"node\": true,\n    \"mocha\": true\n  },\n  \"parser\": \"@typescript-eslint/parser\",\n  \"extends\": [\n    \"eslin"
  },
  {
    "path": "test/basics.test.ts",
    "chars": 10283,
    "preview": "import { By, until } from 'selenium-webdriver';\nimport { expect } from 'chai';\nimport path from 'path';\n\nimport { runTes"
  },
  {
    "path": "test/bom.test.ts",
    "chars": 3492,
    "preview": "import { expect } from 'chai';\nimport path from 'path';\n\nimport { runTestServer } from './testServer';\nimport { runDrive"
  },
  {
    "path": "test/customConfig.test.ts",
    "chars": 5063,
    "preview": "import { By, until } from 'selenium-webdriver';\nimport { expect } from 'chai';\nimport path from 'path';\n\nimport { runTes"
  },
  {
    "path": "test/encoding.test.ts",
    "chars": 2587,
    "preview": "import { expect } from 'chai';\nimport path from 'path';\n\nimport { runTestServer } from './testServer';\nimport { runDrive"
  },
  {
    "path": "test/fixtures/bom.csv",
    "chars": 120,
    "preview": "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",
    "chars": 20,
    "preview": "val1!val2\nval3!val4\n"
  },
  {
    "path": "test/fixtures/encodingWindows1250.csv",
    "chars": 30,
    "preview": "value1,value2\r\nMontral,Kppen\r\n"
  },
  {
    "path": "test/fixtures/noeof.csv",
    "chars": 59,
    "preview": "ColA,ColB,ColC,ColD\nAAAA,BBBB,CCCC,DDDD\nEEEE,FFFF,GGGG,HHHH"
  },
  {
    "path": "test/fixtures/simple.csv",
    "chars": 64,
    "preview": "ColA,ColB,ColC,ColD\r\nAAAA,BBBB,CCCC,DDDD\r\nEEEE,FFFF,GGGG,HHHH\r\n"
  },
  {
    "path": "test/noeof.test.ts",
    "chars": 2765,
    "preview": "import { expect } from 'chai';\nimport path from 'path';\n\nimport { runTestServer } from './testServer';\nimport { runDrive"
  },
  {
    "path": "test/public/index.html",
    "chars": 336,
    "preview": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width,initi"
  },
  {
    "path": "test/testServer.ts",
    "chars": 2785,
    "preview": "import path from 'path';\nimport webpack from 'webpack';\nimport WebpackDevServer from 'webpack-dev-server';\n\nconst TEST_S"
  },
  {
    "path": "test/uiSetup.ts",
    "chars": 4063,
    "preview": "import { By, until, ThenableWebDriver } from 'selenium-webdriver';\nimport ReactModule from 'react';\nimport ReactDOMModul"
  },
  {
    "path": "test/webdriver.ts",
    "chars": 1646,
    "preview": "import * as path from 'path';\nimport * as child_process from 'child_process';\nimport { Builder, ThenableWebDriver } from"
  },
  {
    "path": "tsconfig.base.json",
    "chars": 453,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"es6\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"es6\"],\n    \"allowJs\": true,\n    \"skipL"
  },
  {
    "path": "tsconfig.json",
    "chars": 70,
    "preview": "{\n  \"extends\": \"./tsconfig.base.json\",\n  \"include\": [\"src\", \"test\"]\n}\n"
  },
  {
    "path": "webpack.config.js",
    "chars": 1197,
    "preview": "const path = require('path');\nconst webpack = require('webpack');\nconst { CleanWebpackPlugin } = require('clean-webpack-"
  }
]

About this extraction

This page contains the full source code of the beamworks/react-csv-importer GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 80 files (163.9 KB), approximately 43.4k tokens, and a symbol index with 50 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!