├── .enzyme.js ├── .eslintignore ├── .eslintrc.json ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug--documentation.md │ ├── bug--functionality.md │ ├── bug--styling.md │ ├── question.md │ ├── request--component.md │ └── request--functionality.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── deploy.yaml │ └── lint_and_test.yaml ├── .gitignore ├── .postcssrc.js ├── .prettierignore ├── .prettierrc.yaml ├── .storybook ├── main.ts ├── manager-head.html ├── manager.ts ├── preview.tsx ├── style.scss ├── theme.ts └── withTheme.tsx ├── .versionrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __mocks__ └── fileMock.js ├── commitlint.config.js ├── configs ├── tsbuild.json ├── tsbuild_cjs.json ├── tsbuild_esm.json ├── tsbuild_types.json └── tsconfig.json ├── jest.config.js ├── package.json ├── public ├── favicon.ico └── favicon.png ├── scripts ├── build_package.sh ├── make │ ├── README.md │ ├── constants.ts │ ├── file-content │ │ ├── component.ts │ │ ├── componentIndex.ts │ │ ├── componentReadme.ts │ │ ├── componentRef.ts │ │ ├── componentStory.ts │ │ ├── componentStyle.ts │ │ └── componentTest.ts │ ├── generateFiles.ts │ ├── index.ts │ ├── optionPicker.ts │ ├── summaryLog.ts │ ├── types.ts │ └── utils │ │ ├── fileUtils.ts │ │ ├── logUtils.ts │ │ ├── pathUtils.ts │ │ └── stringUtils.ts ├── parse_scss.js └── verify-package-json.js ├── src ├── __stories__ │ ├── 1_basics │ │ ├── 1_introduction.stories.mdx │ │ ├── 2_getting-started.stories.mdx │ │ └── Version.tsx │ ├── 2_style │ │ ├── 1_colors.stories.mdx │ │ ├── 2_variables.stories.mdx │ │ ├── 3_mixins.stories.mdx │ │ ├── 4_breakpoints.stories.mdx │ │ ├── generated │ │ │ ├── breakpointRange.ts │ │ │ ├── breakpoints.ts │ │ │ ├── mixins.ts │ │ │ └── styleVariables.ts │ │ ├── styleBreakpoints.tsx │ │ ├── styleComponents.tsx │ │ ├── styles.ts │ │ └── utils.tsx │ ├── 3_hooks │ │ ├── useFocusTrap.stories.mdx │ │ ├── useFocusTrapHelpers.ts │ │ ├── useManagedFiles.stories.mdx │ │ ├── useManagedFilesHelpers.ts │ │ ├── useToast.stories.mdx │ │ └── useToastHelpers.ts │ └── 4_data │ │ ├── file.stories.mdx │ │ ├── fileHelpers.ts │ │ ├── memoized-promise.stories.mdx │ │ └── memoizedPromiseHelpers.ts ├── components │ ├── Button │ │ ├── Button.stories.tsx │ │ ├── Button.test.tsx │ │ ├── Button.tsx │ │ ├── README.md │ │ ├── _Button.scss │ │ ├── __snapshots__ │ │ │ └── Button.test.tsx.snap │ │ └── index.ts │ ├── ButtonGroup │ │ ├── ButtonGroup.stories.tsx │ │ ├── ButtonGroup.tsx │ │ ├── README.md │ │ ├── _ButtonGroup.scss │ │ └── index.ts │ ├── Choice │ │ ├── Choice.stories.tsx │ │ ├── Choice.test.tsx │ │ ├── Choice.tsx │ │ ├── README.md │ │ ├── _Choice.scss │ │ ├── __snapshots__ │ │ │ └── Choice.test.tsx.snap │ │ └── index.ts │ ├── ClickableDiv │ │ ├── ClickableDiv.stories.tsx │ │ ├── ClickableDiv.test.tsx │ │ ├── ClickableDiv.tsx │ │ ├── README.md │ │ ├── _ClickableDiv.scss │ │ ├── __snapshots__ │ │ │ └── ClickableDiv.test.tsx.snap │ │ └── index.ts │ ├── DndMultiProvider │ │ ├── DndMultiProvider.tsx │ │ └── index.ts │ ├── DragLayer │ │ ├── DragLayer.stories.tsx │ │ ├── DragLayer.tsx │ │ ├── README.md │ │ ├── _DragLayer.scss │ │ └── index.ts │ ├── Draggable │ │ ├── Draggable.stories.tsx │ │ ├── Draggable.tsx │ │ ├── README.md │ │ ├── _Draggable.scss │ │ └── index.ts │ ├── EditableText │ │ ├── EditableText.stories.tsx │ │ ├── EditableText.test.tsx │ │ ├── EditableText.tsx │ │ ├── README.md │ │ ├── _EditableText.scss │ │ ├── __snapshots__ │ │ │ └── EditableText.test.tsx.snap │ │ └── index.ts │ ├── FileOrganizer │ │ ├── FileOrganizer.stories.tsx │ │ ├── FileOrganizer.tsx │ │ ├── MemoAutoSizer.tsx │ │ ├── README.md │ │ ├── _FileOrganizer.scss │ │ └── index.ts │ ├── FilePicker │ │ ├── FilePicker.stories.tsx │ │ ├── FilePicker.test.tsx │ │ ├── FilePicker.tsx │ │ ├── README.md │ │ ├── _FilePicker.scss │ │ ├── __snapshots__ │ │ │ └── FilePicker.test.tsx.snap │ │ └── index.ts │ ├── FilePlaceholder │ │ ├── FilePlaceholder.stories.tsx │ │ ├── FilePlaceholder.test.tsx │ │ ├── FilePlaceholder.tsx │ │ ├── README.md │ │ ├── _FilePlaceholder.scss │ │ ├── __snapshots__ │ │ │ └── FilePlaceholder.test.tsx.snap │ │ └── index.ts │ ├── FileSkeleton │ │ ├── FileSkeleton.stories.tsx │ │ ├── FileSkeleton.test.tsx │ │ ├── FileSkeleton.tsx │ │ ├── README.md │ │ ├── _FileSkeleton.scss │ │ ├── __snapshots__ │ │ │ └── FileSkeleton.test.tsx.snap │ │ └── index.ts │ ├── FocusTrap │ │ ├── FocusTrap.stories.scss │ │ ├── FocusTrap.stories.tsx │ │ ├── FocusTrap.tsx │ │ ├── README.md │ │ ├── _FocusTrap.scss │ │ └── index.ts │ ├── Icon │ │ ├── Icon.stories.tsx │ │ ├── Icon.test.tsx │ │ ├── Icon.tsx │ │ ├── README.md │ │ ├── _Icon.scss │ │ ├── __snapshots__ │ │ │ └── Icon.test.tsx.snap │ │ └── index.ts │ ├── IconButton │ │ ├── IconButton.stories.tsx │ │ ├── IconButton.test.tsx │ │ ├── IconButton.tsx │ │ ├── README.md │ │ ├── _IconButton.scss │ │ ├── __snapshots__ │ │ │ └── IconButton.test.tsx.snap │ │ └── index.ts │ ├── Image │ │ ├── Image.stories.tsx │ │ ├── Image.test.tsx │ │ ├── Image.tsx │ │ ├── README.md │ │ ├── _Image.scss │ │ ├── __snapshots__ │ │ │ └── Image.test.tsx.snap │ │ └── index.ts │ ├── Input │ │ ├── Input.stories.tsx │ │ ├── Input.test.tsx │ │ ├── Input.tsx │ │ ├── README.md │ │ ├── _Input.scss │ │ ├── __snapshots__ │ │ │ └── Input.test.tsx.snap │ │ └── index.ts │ ├── Label │ │ ├── Label.stories.tsx │ │ ├── Label.test.tsx │ │ ├── Label.tsx │ │ ├── README.md │ │ ├── _Label.scss │ │ ├── __snapshots__ │ │ │ └── Label.test.tsx.snap │ │ └── index.ts │ ├── Modal │ │ ├── Modal.stories.tsx │ │ ├── Modal.test.tsx │ │ ├── Modal.tsx │ │ ├── README.md │ │ ├── _Modal.scss │ │ ├── __snapshots__ │ │ │ └── Modal.test.tsx.snap │ │ └── index.ts │ ├── Overlay │ │ ├── Overlay.scss │ │ ├── Overlay.stories.tsx │ │ ├── Overlay.tsx │ │ ├── README.md │ │ └── index.ts │ ├── Spinner │ │ ├── README.md │ │ ├── Spinner.stories.tsx │ │ ├── Spinner.test.tsx │ │ ├── Spinner.tsx │ │ ├── _Spinner.scss │ │ ├── __snapshots__ │ │ │ └── Spinner.test.tsx.snap │ │ └── index.ts │ ├── Thumbnail │ │ ├── README.md │ │ ├── Thumbnail.stories.tsx │ │ ├── Thumbnail.test.tsx │ │ ├── Thumbnail.tsx │ │ ├── _Thumbnail.scss │ │ ├── __snapshots__ │ │ │ └── Thumbnail.test.tsx.snap │ │ ├── index.ts │ │ └── thumbnailFocusObservable.ts │ ├── ThumbnailDragLayer │ │ ├── README.md │ │ ├── ThumbnailDragLayer.stories.tsx │ │ ├── ThumbnailDragLayer.test.tsx │ │ ├── ThumbnailDragLayer.tsx │ │ ├── _ThumbnailDragLayer.scss │ │ ├── __snapshots__ │ │ │ └── ThumbnailDragLayer.test.tsx.snap │ │ └── index.ts │ ├── ThumbnailSkeleton │ │ ├── README.md │ │ ├── ThumbnailSkeleton.stories.tsx │ │ ├── ThumbnailSkeleton.tsx │ │ ├── _ThumbnailSkeleton.scss │ │ └── index.ts │ ├── Toast │ │ ├── README.md │ │ ├── Toast.stories.tsx │ │ ├── Toast.test.tsx │ │ ├── Toast.tsx │ │ ├── _Toast.scss │ │ ├── __snapshots__ │ │ │ └── Toast.test.tsx.snap │ │ └── index.ts │ ├── ToastProvider │ │ ├── README.md │ │ ├── ToastProvider.stories.tsx │ │ ├── ToastProvider.tsx │ │ ├── _ToastProvider.scss │ │ └── index.ts │ ├── ToolButton │ │ ├── README.md │ │ ├── ToolButton.stories.tsx │ │ ├── ToolButton.test.tsx │ │ ├── ToolButton.tsx │ │ ├── _ToolButton.scss │ │ ├── __snapshots__ │ │ │ └── ToolButton.test.tsx.snap │ │ └── index.ts │ └── index.ts ├── data │ ├── file.ts │ ├── futurable.ts │ ├── index.ts │ └── memoizedPromise.ts ├── hooks │ ├── index.ts │ ├── useAccessibleFocus.ts │ ├── useCurrentRef.ts │ ├── useFile.ts │ ├── useFileSubscribe.ts │ ├── useFocus.ts │ ├── useFocusTrap.ts │ ├── useID.ts │ ├── useKeyForClick.ts │ ├── useManagedFiles.ts │ ├── useOnClick.ts │ ├── useToast.ts │ └── useUnmountDelay.ts ├── icons │ ├── Check.tsx │ ├── ChevronDown.tsx │ ├── Circle.tsx │ ├── Close.tsx │ ├── Download.tsx │ ├── Error.tsx │ ├── Hide.tsx │ ├── Info.tsx │ ├── Menu.tsx │ ├── MultiPage.tsx │ ├── README.md │ ├── RotateRight.tsx │ ├── Search.tsx │ ├── Show.tsx │ ├── SinglePage.tsx │ ├── Success.tsx │ ├── Warning.tsx │ └── index.ts ├── index.scss ├── index.ts ├── internal.d.ts ├── storybook-helpers │ ├── action │ │ └── action.ts │ ├── data │ │ └── files.ts │ ├── images │ │ ├── pdf-preview-2.png │ │ └── pdf-preview.png │ ├── knobs │ │ ├── forwardAction.ts │ │ ├── integer.ts │ │ └── selectIcon.ts │ ├── mdx │ │ ├── LinkTo.tsx │ │ └── index.ts │ └── theme │ │ └── font.ts ├── styles │ ├── _base.scss │ ├── _breakpoints.scss │ ├── _globals.scss │ ├── _mixins.scss │ ├── _theme.scss │ └── _variables.scss ├── types.d.ts ├── utils │ ├── arrayUtils.ts │ ├── constantUtils.ts │ ├── debugUtils.ts │ ├── domUtils.ts │ ├── fileUtils.ts │ ├── gridUtils.ts │ ├── idUtils.ts │ ├── index.ts │ ├── typeUtils.ts │ └── webviewerUtils.ts └── work │ └── GlobalQueue.ts ├── tsconfig.json ├── tsconfig.lint.json ├── webpack.config.ts ├── webpack.style.config.ts └── yarn.lock /.enzyme.js: -------------------------------------------------------------------------------- 1 | const { configure } = require('enzyme'); 2 | const EnzymeAdapter = require('enzyme-adapter-react-16'); 3 | configure({ adapter: new EnzymeAdapter() }); 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Ignore build folders 2 | dist 3 | lib 4 | storybook-build 5 | storybook-static 6 | 7 | # Ignore node modules folder 8 | node_modules 9 | 10 | src/types.d.ts 11 | # Those files have linting issues resulted from CRA-eject. CRA-eject can not be reversed and if there are issues specific to react-scripts versions then it's extremely difficult to fix. 12 | src/utils/arrayUtils.ts 13 | src/utils/webviewerUtils.ts 14 | src/work/GlobalQueue.ts -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.lint.json" 5 | }, 6 | "plugins": ["react-hooks", "prettier"], 7 | "extends": [ 8 | "plugin:jsx-a11y/recommended", 9 | "plugin:jest/recommended", 10 | "plugin:jest/style", 11 | "eslint:recommended", 12 | "plugin:react/recommended", 13 | "plugin:@typescript-eslint/eslint-recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | // Prettier overrides 16 | "prettier", 17 | "prettier/react", 18 | "prettier/@typescript-eslint" 19 | ], 20 | "settings": { 21 | "react": { 22 | "version": "detect" 23 | } 24 | }, 25 | "env": { 26 | "browser": true, 27 | "es6": true, 28 | "commonjs": true, 29 | "node": true, 30 | "jest": true 31 | }, 32 | "rules": { 33 | // Off 34 | "react/prop-types": "off", 35 | "react/display-name": "off", 36 | "@typescript-eslint/no-explicit-any": "off", 37 | "@typescript-eslint/no-empty-function": "off", 38 | "@typescript-eslint/explicit-function-return-type": "off", 39 | "@typescript-eslint/no-var-requires": "off", 40 | "@typescript-eslint/no-empty-interface": "off", 41 | "@typescript-eslint/camelcase": "off", 42 | "@typescript-eslint/no-triple-slash-reference": "off", 43 | "@typescript-eslint/no-non-null-assertion": "off", 44 | "@typescript-eslint/ban-ts-comment": "off", 45 | "@typescript-eslint/no-use-before-define": "off", 46 | "@typescript-eslint/ban-ts-ignore": "off", 47 | // Consider turning on eventually... lots of warnings though. 48 | "@typescript-eslint/explicit-module-boundary-types": "off", 49 | // On. 50 | "prettier/prettier": "error", 51 | "no-empty": [ 52 | "error", 53 | { 54 | "allowEmptyCatch": true 55 | } 56 | ], 57 | "prefer-const": "error", 58 | "react-hooks/rules-of-hooks": "error", 59 | "react-hooks/exhaustive-deps": "error", 60 | "@typescript-eslint/no-unused-vars": [ 61 | "error", 62 | { 63 | "argsIgnorePattern": "^_" 64 | } 65 | ], 66 | "@typescript-eslint/unbound-method": "error" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global, lowest priority (will be overridden by any below). 2 | * @liamross 3 | 4 | # GITHUB: Actions, templates, etc. 5 | /.github @liamross 6 | 7 | # BUILD: Build and bundling scripts and configs (override non-build scripts below). 8 | /scripts/ @liamross 9 | /configs/ @liamross 10 | /.postcssrc.js @liamross 11 | /webpack.config.ts @liamross 12 | 13 | # RELEASES: Commit, Changelog, and GitHub release preferences. 14 | /commitlint.config.js @liamross 15 | /.versionrc.js @liamross 16 | /CHANGELOG.md @liamross 17 | 18 | # DOCUMENTATION: Storybook configuration directories. 19 | /.storybook/ @liamross 20 | /public/ @liamross 21 | 22 | # TESTING: Testing configuration. 23 | /jest.config.js @liamross 24 | /.enzyme.js @liamross 25 | 26 | # LINTING: Linters and formatters. 27 | /.prettier* @liamross 28 | /.eslint* @liamross 29 | 30 | # SCRIPT(Make): Make script and utilities. 31 | /scripts/make/ @liamross 32 | 33 | # SCRIPT(Sass): Sass AST parser and docs generator. 34 | /scripts/parse_scss.js @liamross -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug--documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Bug: Documentation' 3 | about: Report an issue with the documentation of a component 4 | title: '' 5 | labels: "\U0001F4D6 documentation bug" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | 12 | 13 | 14 | ### To Reproduce 15 | 16 | 17 | 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | ### Expected behavior 24 | 25 | 26 | 27 | ### Screenshots 28 | 29 | 30 | 31 | ### Device 32 | 33 | **Desktop:** 34 | 35 | - OS: [e.g. iOS] 36 | - Browser [e.g. chrome, safari] 37 | - Version [e.g. 22] 38 | 39 | **Smartphone:** 40 | 41 | - Device: [e.g. iPhone6] 42 | - OS: [e.g. iOS8.1] 43 | - Browser [e.g. stock browser, safari] 44 | - Version [e.g. 22] 45 | 46 | ### Additional context 47 | 48 | 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug--functionality.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Bug: Functionality' 3 | about: Report an issue with props or usability of a component 4 | title: '' 5 | labels: "⚙️ functionality bug" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | 12 | 13 | 14 | ### To Reproduce 15 | 16 | 17 | 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | ### Expected behavior 24 | 25 | 26 | 27 | ### Screenshots 28 | 29 | 30 | 31 | ### Device 32 | 33 | **Desktop:** 34 | 35 | - OS: [e.g. iOS] 36 | - Browser [e.g. chrome, safari] 37 | - Version [e.g. 22] 38 | 39 | **Smartphone:** 40 | 41 | - Device: [e.g. iPhone6] 42 | - OS: [e.g. iOS8.1] 43 | - Browser [e.g. stock browser, safari] 44 | - Version [e.g. 22] 45 | 46 | ### Additional context 47 | 48 | 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug--styling.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Bug: Styling' 3 | about: Report an issue with the style of a component 4 | title: '' 5 | labels: "\U0001F3A8 style bug" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | 12 | 13 | 14 | ### To Reproduce 15 | 16 | 17 | 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | ### Expected behavior 24 | 25 | 26 | 27 | ### Screenshots 28 | 29 | 30 | 31 | ### Device 32 | 33 | **Desktop:** 34 | 35 | - OS: [e.g. iOS] 36 | - Browser [e.g. chrome, safari] 37 | - Version [e.g. 22] 38 | 39 | **Smartphone:** 40 | 41 | - Device: [e.g. iPhone6] 42 | - OS: [e.g. iOS8.1] 43 | - Browser [e.g. stock browser, safari] 44 | - Version [e.g. 22] 45 | 46 | ### Additional context 47 | 48 | 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about a component, functionality, or anything 4 | title: '' 5 | labels: "\U0001F64B question" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### My question is 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request--component.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Request: Component' 3 | about: Request a new component 4 | title: '' 5 | labels: "\U0001F9E9 component request" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### What is the component you would like 11 | 12 | 13 | 14 | ### Additional context 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request--functionality.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Request: Functionality' 3 | about: Request functionality for an existing component or in general 4 | title: '' 5 | labels: "\U0001F69C functionality request" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### What is the feature you would like 11 | 12 | 13 | 14 | ### Additional context 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | ### Description 18 | 19 | 1. Some changes 20 | 2. Some other changes 21 | 22 | ### Related issues 23 | 24 | Fixes #SomeNumberHere 25 | 26 | ### Checklist 27 | 28 | 29 | 30 | - [ ] Added the title of this PR to the CHANGELOG 31 | 32 | - [ ] I added at least one functional test for the component 33 | 34 | - [ ] I edited the README to give more information on the component 35 | 36 | - [ ] I created knobs for every reasonable prop 37 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Lint and test components, then deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'change_this_to_master_when_this_is_fixed' 7 | # - 'master' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [12.x] # Add more if you want to test other node versions. 16 | 17 | steps: 18 | - name: Check out repository 19 | uses: actions/checkout@v1 20 | 21 | - name: Use Node version ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | # https://github.com/actions/cache/blob/master/examples.md#node---yarn 27 | - name: Get yarn cache directory 28 | id: yarn-cache 29 | run: echo "::set-output name=dir::$(yarn cache dir)" 30 | 31 | - name: Use yarn cache 32 | uses: actions/cache@v1 33 | with: 34 | path: ${{ steps.yarn-cache.outputs.dir }} 35 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 36 | restore-keys: | 37 | ${{ runner.os }}-yarn- 38 | 39 | - name: Install dependencies 40 | run: yarn install 41 | 42 | - name: Lint components 43 | run: yarn lint 44 | 45 | - name: Test components 46 | run: yarn test 47 | env: 48 | CI: true 49 | 50 | - name: Deploy to GitHub pages 51 | run: npm run deploy-storybook -- --ci 52 | env: 53 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | -------------------------------------------------------------------------------- /.github/workflows/lint_and_test.yaml: -------------------------------------------------------------------------------- 1 | name: Lint and test components 2 | 3 | on: push 4 | # Redo this once deploy script works. 5 | # on: 6 | # push: 7 | # branches: 8 | # - '*' 9 | # - '!master' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [12.x] # Add more if you want to test other node versions. 18 | 19 | steps: 20 | - name: Check out repository 21 | uses: actions/checkout@v1 22 | 23 | - name: Use Node version ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | # https://github.com/actions/cache/blob/master/examples.md#node---yarn 29 | - name: Get yarn cache directory path 30 | id: yarn-cache-dir-path 31 | run: echo "::set-output name=dir::$(yarn cache dir)" 32 | 33 | - name: Use yarn cache 34 | uses: actions/cache@v1 35 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 36 | with: 37 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 38 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-yarn- 41 | 42 | - name: Install dependencies 43 | run: yarn install 44 | 45 | - name: Lint components 46 | run: yarn lint 47 | 48 | - name: Test components 49 | run: yarn test 50 | env: 51 | CI: true 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Package lock file 11 | package-lock.json 12 | 13 | # VSCode files 14 | .vscode 15 | 16 | # Builds 17 | dist 18 | lib 19 | docs 20 | .out 21 | storybook-static 22 | 23 | # Size json 24 | size.json 25 | 26 | # Runtime data 27 | pids 28 | *.pid 29 | *.seed 30 | *.pid.lock 31 | 32 | # Directory for instrumented libs generated by jscoverage/JSCover 33 | lib-cov 34 | 35 | # Coverage directory used by tools like istanbul 36 | coverage 37 | 38 | # nyc test coverage 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | .grunt 43 | 44 | # Bower dependency directory (https://bower.io/) 45 | bower_components 46 | 47 | # node-waf configuration 48 | .lock-wscript 49 | 50 | # Compiled binary addons (https://nodejs.org/api/addons.html) 51 | build/Release 52 | 53 | # Dependency directories 54 | node_modules/ 55 | jspm_packages/ 56 | 57 | # TypeScript v1 declaration files 58 | typings/ 59 | 60 | # Optional npm cache directory 61 | .npm 62 | 63 | # Optional eslint cache 64 | .eslintcache 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variables file 76 | .env 77 | .env.test 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | 82 | # Other 83 | .DS_Store 84 | 85 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { plugins: [require('autoprefixer')] }; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore GitHub folder 2 | .github 3 | 4 | # Ignore build folders 5 | dist 6 | lib 7 | docs 8 | storybook-build 9 | storybook-static 10 | 11 | # Ignore node modules folder 12 | node_modules 13 | *.mdx -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | # Wrap markdown files at 80 chars for raw file readability. 2 | proseWrap: always 3 | 4 | # Use single quotes where possible, subjective preference. 5 | singleQuote: true 6 | 7 | # Trailing commas for improved diffs when using source control. 8 | trailingComma: all 9 | 10 | overrides: 11 | # TypeScript files benefit from more horizontal space, subjective preference. 12 | - files: 13 | - '*.ts' 14 | - '*.tsx' 15 | options: 16 | printWidth: 120 17 | 18 | # Some files don't need to be wrapped at 80 chars. List those here. 19 | - files: 20 | - CHANGELOG.md 21 | - release-logs/**/*.md 22 | options: 23 | proseWrap: never 24 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.*'], 5 | addons: [ 6 | '@storybook/addon-docs', 7 | '@storybook/addon-knobs', 8 | '@storybook/addon-actions', 9 | '@storybook/addon-viewport', 10 | '@storybook/addon-links', 11 | '@storybook/addon-google-analytics', 12 | ], 13 | webpackFinal: async (config: any) => { 14 | // Sass support with post-css for vendor prefixes. 15 | config.module.rules.push({ 16 | test: /\.scss$/, 17 | use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'], 18 | include: path.resolve(__dirname, '../'), 19 | }); 20 | return config; 21 | }, 22 | presets: [ 23 | { 24 | name: '@storybook/preset-typescript', 25 | options: { 26 | tsLoaderOptions: { ignoreDiagnostics: [7005] }, 27 | include: [path.resolve(__dirname, '../')], 28 | }, 29 | }, 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | 3 | // Google Analytics ID 4 | // @ts-ignore 5 | window.STORYBOOK_GA_ID = 'UA-6566170-4'; 6 | 7 | addons.setConfig({ 8 | /** 9 | * show story component as full screen 10 | * @type {Boolean} 11 | */ 12 | isFullscreen: false, 13 | 14 | /** 15 | * display panel that shows a list of stories 16 | * @type {Boolean} 17 | */ 18 | showNav: true, 19 | 20 | /** 21 | * display panel that shows addon configurations 22 | * @type {Boolean} 23 | */ 24 | showPanel: true, 25 | 26 | /** 27 | * where to show the addon panel 28 | * @type {('bottom'|'right')} 29 | */ 30 | panelPosition: 'right', 31 | 32 | /** 33 | * sidebar tree animations 34 | * @type {Boolean} 35 | */ 36 | sidebarAnimations: true, 37 | 38 | /** 39 | * enable/disable shortcuts 40 | * @type {Boolean} 41 | */ 42 | enableShortcuts: true, 43 | 44 | /** 45 | * show/hide tool bar 46 | * @type {Boolean} 47 | */ 48 | isToolshown: true, 49 | 50 | /** 51 | * id to select an addon panel 52 | * @type {String} 53 | */ 54 | selectedPanel: undefined, 55 | 56 | previewTabs: { 57 | // the order of the tabs is configured by the order here 58 | canvas: { title: 'Playground' }, 59 | 'storybook/docs/panel': { title: 'Info' }, 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import { Description, Props, Subtitle, Title } from '@storybook/addon-docs/dist/blocks'; 2 | import { withInfo } from '@storybook/addon-info'; 3 | import { withKnobs } from '@storybook/addon-knobs'; 4 | import { addDecorator, addParameters } from '@storybook/react'; 5 | import React from 'react'; 6 | import './style.scss'; 7 | import theme from './theme'; 8 | import { withTheme } from './withTheme'; 9 | 10 | /* --- Add global decorators. --- */ 11 | 12 | addDecorator( 13 | // TODO: Remove once features are supported in docs. 14 | // @ts-ignore 15 | withInfo({ 16 | inline: true, 17 | header: false, 18 | maxPropArrayLength: 1, 19 | TableComponent: () => null, 20 | styles: (base: any) => ({ 21 | ...base, 22 | // The wrapper around info. 23 | infoBody: { padding: 15, backgroundColor: 'rgb(51, 51, 51)' }, 24 | // The wrapper around the story. 25 | infoStory: { padding: 40 }, 26 | // Hide prop table header. 27 | propTableHead: { display: 'none' }, 28 | }), 29 | }), 30 | ); 31 | addDecorator(withKnobs); 32 | addDecorator(withTheme); 33 | 34 | /* --- Add global parameters. --- */ 35 | 36 | addParameters({ 37 | // TODO: Move `options` to `manager.js` once it's supported more. 38 | options: { 39 | theme, 40 | /** 41 | * display the top-level grouping as a "root" in the sidebar 42 | * @type {Boolean} 43 | */ 44 | showRoots: true, 45 | }, 46 | // Default to show "story" vs "docs" whenever story switches. 47 | viewMode: 'story', 48 | docs: { 49 | page: () => ( 50 | <> 51 | 52 | <Subtitle /> 53 | <Description /> 54 | {/* <Primary /> */} 55 | <Props /> 56 | </> 57 | ), 58 | 59 | // Since we do not add component description in code (instead inserting it 60 | // into a .md file) we extract it using the following. 61 | extractComponentDescription: (_c: unknown, { readme }: { readme: string }) => { 62 | if (readme) return readme; 63 | return null; 64 | }, 65 | 66 | // prepareForInline: (s: StoryFn) => <div>{s}</div>, 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /.storybook/theme.ts: -------------------------------------------------------------------------------- 1 | import { create } from '@storybook/theming'; 2 | import font from '../src/storybook-helpers/theme/font'; 3 | 4 | export default create({ 5 | // Theme base 6 | base: 'dark', 7 | 8 | // Main colors 9 | colorPrimary: '#00a3e3', 10 | colorSecondary: '#00a3e3', 11 | 12 | // UI 13 | // appBg: 'white', 14 | // appContentBg: 'white', 15 | // appBorderColor: 'white', 16 | appBorderRadius: 4, 17 | 18 | // Typography 19 | fontBase: font.fontBase, 20 | fontCode: font.fontCode, 21 | 22 | // // Text colors 23 | textColor: 'rgba(240, 240, 255, 0.9)', 24 | textInverseColor: 'rgba(0, 0, 20, 0.9)', 25 | 26 | // Toolbar default and active colors 27 | barTextColor: 'rgba(240, 240, 255, 0.6)', 28 | barSelectedColor: '#00a3e3', 29 | // barBg: 'white', 30 | 31 | // // Form colors 32 | // inputBg: 'white', 33 | // inputBorder: 'white', 34 | inputTextColor: 'rgba(240, 240, 255, 0.9)', 35 | inputBorderRadius: 4, 36 | 37 | // Branding 38 | brandTitle: `${require('../package.json').version}`, 39 | brandUrl: 'https://github.com/PDFTron/webviewer-react-toolkit', 40 | brandImage: 'https://www.pdftron.com/brand-assets/pdftron-logo-blue.png', 41 | }); 42 | -------------------------------------------------------------------------------- /.storybook/withTheme.tsx: -------------------------------------------------------------------------------- 1 | import { StoryContext, StoryFn } from '@storybook/addons'; 2 | import React, { useLayoutEffect, useRef, useState } from 'react'; 3 | 4 | export function withTheme<StoryFnReturnType>(storyFn: StoryFn<StoryFnReturnType>, context: StoryContext) { 5 | return ( 6 | <> 7 | <ThemeChangeButton id="playground-theme-button" /> 8 | {storyFn(context)} 9 | </> 10 | ); 11 | } 12 | 13 | export interface ThemeChangeButtonProps { 14 | className?: string; 15 | onClick?: (darkTheme: boolean) => void; 16 | id?: string; 17 | } 18 | 19 | export function isDarkThemeStored() { 20 | const storedTheme = localStorage.getItem('data-theme') || ''; 21 | return storedTheme === 'dark'; 22 | } 23 | 24 | export function ThemeChangeButton({ className, onClick, id }: ThemeChangeButtonProps) { 25 | const html = useRef(document.documentElement); 26 | 27 | const [darkTheme, setDarkTheme] = useState(isDarkThemeStored); 28 | 29 | useLayoutEffect(() => { 30 | const newTheme = darkTheme ? 'dark' : ''; 31 | html.current.setAttribute('data-theme', newTheme); 32 | setTimeout(() => localStorage.setItem('data-theme', newTheme)); 33 | }, [darkTheme]); 34 | 35 | const handleOnClick = () => { 36 | setDarkTheme((t) => { 37 | onClick?.(!t); 38 | return !t; 39 | }); 40 | }; 41 | 42 | return ( 43 | <div 44 | id={id} 45 | className={className} 46 | onClick={handleOnClick} 47 | title={`Change to ${darkTheme ? 'light' : 'dark'} theme`} 48 | > 49 | {darkTheme ? '☀️' : '🌙'} 50 | </div> 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /.versionrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This formats the auto-generated Changelog, and GitHub releases. 3 | */ 4 | module.exports = { 5 | types: [ 6 | /* --- Shown in Changelog. --- */ 7 | 8 | // Affects the build system or external dependencies. 9 | { type: 'build', section: 'Build and Dependencies' }, 10 | // Adds a new feature. 11 | { type: 'feat', section: 'Features' }, 12 | // Solves a bug. 13 | { type: 'fix', section: 'Bug Fixes' }, 14 | // Rewrites code without feature, performance or bug changes. 15 | { type: 'refactor', section: 'Refactors' }, 16 | // Improves formatting, white-space. 17 | { type: 'style', section: 'Styles' }, 18 | 19 | /* --- Hidden from Changelog. --- */ 20 | 21 | // Other changes that don't modify src or test files. 22 | { type: 'chore', hidden: true }, 23 | // Adds or alters documentation. 24 | { type: 'docs', hidden: true }, 25 | // Reverts a previous commit. 26 | { type: 'revert', hidden: true }, 27 | // Adds or modifies tests. 28 | { type: 'test', hidden: true }, 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 PDFTron Systems Inc. All rights reserved. 2 | 3 | WebViewer React Toolkit project/codebase or any derived works is only permitted 4 | in solutions with an active commercial PDFTron WebViewer license. For exact 5 | licensing terms please refer to your commercial WebViewer license. For use in 6 | other scenario, please contact sales@pdftron.com. 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebViewer React Toolkit 2 | 3 | The WebViewer React Toolkit is a React component library contains various 4 | components that integrate with the 5 | [PDFTron WebViewer API](https://www.pdftron.com/documentation/web/). 6 | 7 | Check out the [documentation](https://pdftron.github.io/webviewer-react-toolkit) 8 | to get started with the toolkit! 9 | 10 | For a demo showcasing some of the functionality, along with step-by-step 11 | instructions on how to build it, check out the 12 | [demo repository](https://github.com/PDFTron/webviewer-react-toolkit-demo). 13 | 14 | > Note: file functionality within toolkit v7 and above requires WebViewer v7 or 15 | > higher. If you are on a previous version of WebViewer, you can use v0.6.0 of 16 | > the toolkit: 17 | > 18 | > ```sh 19 | > # Yarn 20 | > yarn add @pdftron/webviewer-react-toolkit@0.6.0 21 | > 22 | > # npm 23 | > npm install @pdftron/webviewer-react-toolkit@0.6.0 24 | > ``` 25 | 26 | ## Installation 27 | 28 | You can install the toolkit from npm using your preferred package manager: 29 | 30 | ```bash 31 | # Yarn 32 | yarn add @pdftron/webviewer-react-toolkit 33 | 34 | # npm 35 | npm install @pdftron/webviewer-react-toolkit 36 | ``` 37 | 38 | ## Using the toolkit 39 | 40 | Check the [introduction](https://pdftron.github.io/webviewer-react-toolkit) for 41 | information on using the toolkit. 42 | 43 | ## Contributing 44 | 45 | > Warning: There are issues building with versions of Node >=11. For now, use 46 | > [nvm](https://github.com/nvm-sh/nvm) to get latest node 10 version (works fine 47 | > with `v10.23.3`). 48 | 49 | ### To start up Storybook 50 | 51 | ```bash 52 | yarn # 1. To install dependencies (or `npm i`) 53 | yarn start # 2. To start the Storybook environment (or `npm start`) 54 | ``` 55 | 56 | ### To test 57 | 58 | ```bash 59 | yarn test # Single test run (or `npm test`) 60 | yarn test --watch # Watch for changes (or `npm test -- --watch`) 61 | ``` 62 | 63 | ### To lint 64 | 65 | ```bash 66 | yarn lint # Lint for errors (or `npm lint`) 67 | ``` 68 | 69 | ## Goal 70 | 71 | To create a set of highly customizable components that take WebViewer documents 72 | (and other objects), and wrap them in specific functionality. 73 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This lints commits, and configures the `yarn commit` command. 3 | */ 4 | module.exports = { 5 | extends: ['@commitlint/config-conventional'], 6 | rules: { 7 | // Allow any case for scope (ex: "FileOrganizer"). 8 | 'scope-case': [0, 'always', 'lower-case'], 9 | 10 | // Limit possible types. 11 | 'type-enum': [ 12 | 2, 13 | 'always', 14 | [ 15 | '--- Shown in CHANGELOG: ---', 16 | 'build', 17 | 'feat', 18 | 'fix', 19 | 'refactor', 20 | 'style', 21 | '--- Hidden in CHANGELOG: ---', 22 | 'chore', 23 | 'docs', 24 | 'revert', 25 | 'test', 26 | ], 27 | ], 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /configs/tsbuild.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["../src"], 3 | "exclude": [ 4 | "../node_modules", 5 | "../src/**/*.test.*", 6 | "../src/**/*.stories.*", 7 | "../src/storybook-helpers", 8 | "../src/**/__stories__" 9 | ], 10 | "extends": "./tsconfig.json" 11 | } 12 | -------------------------------------------------------------------------------- /configs/tsbuild_cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsbuild", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../dist/cjs", 6 | "target": "ES5", 7 | "module": "CommonJS" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /configs/tsbuild_esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsbuild", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../dist/esm", 6 | "target": "ES5", 7 | "module": "esnext" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /configs/tsbuild_types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsbuild", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../lib", 6 | "target": "es2020", 7 | "module": "esnext" 8 | }, 9 | "include": ["../src"], 10 | "exclude": [ 11 | "../node_modules", 12 | "../src/**/*.test.*", 13 | "../src/**/*.stories.*", 14 | "../src/storybook-helpers", 15 | "../src/**/__stories__" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /configs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "jsx": "react", 5 | "skipLibCheck": true, 6 | "allowSyntheticDefaultImports": true, 7 | "baseUrl": "src", 8 | "emitDecoratorMetadata": true, 9 | "esModuleInterop": true, 10 | "experimentalDecorators": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "importHelpers": true, 13 | "incremental": true, 14 | "lib": ["esnext", "dom"], 15 | "moduleResolution": "node", 16 | "noImplicitAny": true, 17 | "noImplicitReturns": true, 18 | "noUnusedLocals": true, 19 | "resolveJsonModule": true, 20 | "strict": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 3 | // collectCoverage: true, 4 | // coverageDirectory: './coverage/', 5 | 6 | testMatch: ['**/?(*.)test.(ts|tsx)'], 7 | 8 | transform: { 9 | '^.+\\.(ts|tsx)$': 'ts-jest', 10 | '^.+\\.svg$': 'jest-svg-transformer', 11 | }, 12 | 13 | moduleNameMapper: { 14 | '^.+\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 15 | '<rootDir>/__mocks__/fileMock.js', 16 | }, 17 | 18 | // Setup Enzyme 19 | snapshotSerializers: ['enzyme-to-json/serializer'], 20 | setupFilesAfterEnv: ['<rootDir>/.enzyme.js'], 21 | }; 22 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApryseSDK/webviewer-react-toolkit/a96e3be370d02d7c9c7144eaf279c6d39f1574d2/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApryseSDK/webviewer-react-toolkit/a96e3be370d02d7c9c7144eaf279c6d39f1574d2/public/favicon.png -------------------------------------------------------------------------------- /scripts/build_package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | yarn tsc -p ./configs/tsbuild_types.json 4 | yarn tsc -p ./configs/tsbuild_esm.json 5 | yarn tsc -p ./configs/tsbuild_cjs.json 6 | wait 7 | -------------------------------------------------------------------------------- /scripts/make/README.md: -------------------------------------------------------------------------------- 1 | # Make script 2 | 3 | The make script generates components along with all of their files and imports. 4 | The goal is to reduce the time spent bootstrapping and increase productivity. 5 | 6 | It's written in TypeScript in order to improve maintainability. This means it 7 | runs slower, and requires ts-node to run, but the cost is worth it. 8 | 9 | The main flow of this script is: 10 | 11 | 1. In `index.ts`, **begin the script** 12 | 13 | - This is the entry point, and it also outlines the entire flow of the 14 | script. It begins by calling the `optionPicker`. 15 | 16 | 1. In `optionPicker.ts`, **choose your options** 17 | 18 | - This is the file that collects user input and provides options for them. 19 | 20 | 1. In `summaryLog.ts`, **summarize the options** 21 | 22 | - This takes the options selected in `optionPicker.ts` and displays them. 23 | 24 | 1. In `index.ts`, **ask for approval** 25 | 26 | - Check if the user is ok with the options they selected, as well as the 27 | warnings they received. If approved, call `generateFiles`. 28 | 29 | 1. In `generateFiles.ts`, **generate all files and directories** 30 | 31 | - This builds out all of the files and directories based on the options 32 | selected. This is the most substantial file, as it needs to interact a lot 33 | with the file system. 34 | 35 | 1. It imports all of the file content strings from the `/file-content` 36 | directory in order to populate the file content. 37 | 1. It uses the `getPaths` utility to generate all of the paths to files and 38 | directories 39 | 1. It checks for any errors (ex: existing files or directories) 40 | 1. It generates files and directories, as well as modifies index files to 41 | include the imports 42 | -------------------------------------------------------------------------------- /scripts/make/constants.ts: -------------------------------------------------------------------------------- 1 | export const STYLE_PREFIX = 'ui'; 2 | -------------------------------------------------------------------------------- /scripts/make/file-content/component.ts: -------------------------------------------------------------------------------- 1 | import { STYLE_PREFIX } from '../constants'; 2 | import { pascalToCamel } from '../utils/stringUtils'; 3 | 4 | export const component = (componentName: string) => 5 | `import classnames from 'classnames'; 6 | import React, { FC, ButtonHTMLAttributes } from 'react'; 7 | 8 | export interface ${componentName}Props extends ButtonHTMLAttributes<HTMLButtonElement> { 9 | // write your first prop 10 | } 11 | 12 | export const ${componentName}: FC<${componentName}Props> = ({ className, children, ...props }) => { 13 | const ${pascalToCamel(componentName)}Class = classnames( 14 | '${STYLE_PREFIX}__base ${STYLE_PREFIX}__${pascalToCamel(componentName)}', 15 | { '${STYLE_PREFIX}__${pascalToCamel(componentName)}--modifier': true }, 16 | className, 17 | ); 18 | 19 | return ( 20 | <button {...props} className={${pascalToCamel(componentName)}Class}> 21 | {children} 22 | </button> 23 | ); 24 | }; 25 | `; 26 | -------------------------------------------------------------------------------- /scripts/make/file-content/componentIndex.ts: -------------------------------------------------------------------------------- 1 | export const componentIndex = (componentName: string) => 2 | `export * from './${componentName}'; 3 | `; 4 | -------------------------------------------------------------------------------- /scripts/make/file-content/componentReadme.ts: -------------------------------------------------------------------------------- 1 | export const componentReadme = (componentName: string) => 2 | `TODO: write a README for ${componentName}. 3 | `; 4 | -------------------------------------------------------------------------------- /scripts/make/file-content/componentRef.ts: -------------------------------------------------------------------------------- 1 | import { STYLE_PREFIX } from '../constants'; 2 | import { pascalToCamel } from '../utils/stringUtils'; 3 | 4 | export const componentRef = (componentName: string) => 5 | `import classnames from 'classnames'; 6 | import React, { forwardRef, ButtonHTMLAttributes } from 'react'; 7 | 8 | export interface ${componentName}Props extends ButtonHTMLAttributes<HTMLButtonElement> { 9 | // write your first prop 10 | } 11 | 12 | export const ${componentName} = forwardRef<HTMLButtonElement, ${componentName}Props>(({ className, children, ...props }, ref) => { 13 | const ${pascalToCamel(componentName)}Class = classnames( 14 | '${STYLE_PREFIX}__base ${STYLE_PREFIX}__${pascalToCamel(componentName)}', 15 | { '${STYLE_PREFIX}__${pascalToCamel(componentName)}--modifier': true }, 16 | className, 17 | ); 18 | 19 | return ( 20 | <button {...props} className={${pascalToCamel(componentName)}Class} ref={ref}> 21 | {children} 22 | </button> 23 | ); 24 | }, 25 | ); 26 | `; 27 | -------------------------------------------------------------------------------- /scripts/make/file-content/componentStory.ts: -------------------------------------------------------------------------------- 1 | export const componentStory = (componentName: string) => 2 | `import { action } from '../../storybook-helpers/action/action'; 3 | import { boolean, text } from '@storybook/addon-knobs'; 4 | import React from 'react'; 5 | import { ${componentName} } from '../${componentName}'; 6 | import readme from './README.md'; 7 | 8 | export default { title: 'Components/${componentName}', component: ${componentName}, parameters: { readme } }; 9 | 10 | export const Basic = () => <${componentName} onClick={action('onClick')}>{text('children', 'Hello, World!')}</${componentName}>; 11 | `; 12 | -------------------------------------------------------------------------------- /scripts/make/file-content/componentStyle.ts: -------------------------------------------------------------------------------- 1 | import { pascalToCamel } from '../utils/stringUtils'; 2 | import { STYLE_PREFIX } from '../constants'; 3 | 4 | export const componentStyle = (componentName: string) => 5 | `.${STYLE_PREFIX}__${pascalToCamel(componentName)} { 6 | // TODO: write styles for ${componentName}. 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /scripts/make/file-content/componentTest.ts: -------------------------------------------------------------------------------- 1 | import { STYLE_PREFIX } from '../constants'; 2 | import { pascalToCamel } from '../utils/stringUtils'; 3 | 4 | export const componentTest = (componentName: string) => 5 | `import { mount, shallow } from 'enzyme'; 6 | import React from 'react'; 7 | import { spy } from 'sinon'; 8 | import { ${componentName} } from '../${componentName}'; 9 | 10 | describe('${componentName} component', () => { 11 | it('renders its contents', () => { 12 | const ${pascalToCamel(componentName)} = shallow(<${componentName} />); 13 | expect(${pascalToCamel(componentName)}.find('.${STYLE_PREFIX}__${pascalToCamel(componentName)}')).toHaveLength(1); 14 | }); 15 | 16 | it('snapshot renders default ${pascalToCamel(componentName)}', () => { 17 | const ${pascalToCamel(componentName)} = shallow(<${componentName} />); 18 | expect(${pascalToCamel(componentName)}).toMatchSnapshot(); 19 | }); 20 | 21 | it('clicking ${pascalToCamel(componentName)} triggers onClick prop', () => { 22 | const onClick = spy(); 23 | shallow(<${componentName} onClick={onClick} />).simulate('click'); 24 | expect(onClick.callCount).toBe(1); 25 | }); 26 | 27 | it('clicking disabled ${pascalToCamel(componentName)} does not trigger onClick prop', () => { 28 | const onClick = spy(); 29 | // full DOM mount so \`${pascalToCamel(componentName)}\` element will use disabled prop 30 | mount(<${componentName} onClick={onClick} disabled />).simulate('click'); 31 | expect(onClick.callCount).toBe(0); 32 | }); 33 | }); 34 | `; 35 | -------------------------------------------------------------------------------- /scripts/make/index.ts: -------------------------------------------------------------------------------- 1 | import { prompt } from 'enquirer'; 2 | import { generateFiles } from './generateFiles'; 3 | import { optionPicker } from './optionPicker'; 4 | import { summaryLog } from './summaryLog'; 5 | import { clearTerminal, log, redErr } from './utils/logUtils'; 6 | 7 | (async () => { 8 | clearTerminal(); 9 | 10 | // eslint-disable-next-line no-constant-condition 11 | while (true) { 12 | try { 13 | /* --- Pick and display options --- */ 14 | 15 | const options = await optionPicker(); 16 | summaryLog(options); 17 | 18 | const { componentName, isRef } = options; 19 | 20 | /* --- Check for final approval before generating files --- */ 21 | 22 | const approvedOptions = ( 23 | await prompt<{ approvedOptions: 'quit' | 'no' | 'yes' }>({ 24 | type: 'select', 25 | name: 'approvedOptions', 26 | message: `Do these options look ok?`, 27 | choices: [ 28 | { name: 'yes', hint: ' All of your choices are correct' }, 29 | { name: 'no', hint: ' Reselect your component options' }, 30 | { name: 'quit', hint: 'You give up' }, 31 | ], 32 | }) 33 | )['approvedOptions']; 34 | 35 | // Quit 36 | if (approvedOptions === 'quit') return; 37 | 38 | // Restart (clear the console before repeating make script) 39 | if (approvedOptions === 'no') { 40 | clearTerminal(); 41 | log('Restarting script...\n'); 42 | } 43 | 44 | // Generate files and directories 45 | if (approvedOptions === 'yes') { 46 | log('\nAttempting to generate files...\n'); 47 | generateFiles({ isRef, componentName }); 48 | return; 49 | } 50 | } catch (error) { 51 | redErr(`\nScript Error:\n${error || 'No error returned.'}`); 52 | return; 53 | } 54 | } 55 | })(); 56 | -------------------------------------------------------------------------------- /scripts/make/optionPicker.ts: -------------------------------------------------------------------------------- 1 | import { prompt } from 'enquirer'; 2 | import { MakeOptions } from './types'; 3 | 4 | /** 5 | * Option picker function for make script. 6 | */ 7 | export const optionPicker = async (): Promise<MakeOptions> => { 8 | /* --- Naming the component --- */ 9 | 10 | const componentName = ( 11 | await prompt<{ componentName: string }>({ 12 | type: 'input', 13 | name: 'componentName', 14 | message: 'What will your component be called?', 15 | validate: value => { 16 | if (!/^[A-Z]/.test(value)) { 17 | return 'Must begin with a capital'; 18 | } 19 | if (!/^[A-Za-z]+$/.test(value)) { 20 | return 'Must only contain letters'; 21 | } 22 | if (value.length < 2) { 23 | return 'Must be at least 2 letters long'; 24 | } 25 | return true; 26 | }, 27 | }) 28 | )['componentName']; 29 | 30 | /* --- Forwards ref --- */ 31 | 32 | const isRef = await (async () => { 33 | const result = ( 34 | await prompt<{ isRef: 'no' | 'yes' }>({ 35 | type: 'select', 36 | name: 'isRef', 37 | message: 'Will your component forward a ref?', 38 | hint: 'https://reactjs.org/docs/forwarding-refs.html', 39 | choices: [ 40 | { name: 'no', hint: ' Component does not require a ref (ex: Tooltip)' }, 41 | { name: 'yes', hint: 'Component should forward a ref (ex: Button, Input)' }, 42 | ], 43 | }) 44 | )['isRef']; 45 | 46 | return result === 'yes'; 47 | })(); 48 | 49 | return { componentName, isRef }; 50 | }; 51 | -------------------------------------------------------------------------------- /scripts/make/summaryLog.ts: -------------------------------------------------------------------------------- 1 | import c from 'ansi-colors'; 2 | import { MakeOptions } from './types'; 3 | import { ERROR_SYMBOL, log, SUCCESS_SYMBOL } from './utils/logUtils'; 4 | 5 | const checkOrCross = (bool: boolean, ifTrue: string, ifFalse: string) => { 6 | const symbol = bool ? c.green(SUCCESS_SYMBOL) : c.red(ERROR_SYMBOL); 7 | const text = bool ? ifTrue : ifFalse; 8 | return `${symbol} ${c.dim(`- ${text}`)}`; 9 | }; 10 | 11 | /** 12 | * Log a summary of your options. 13 | */ 14 | export const summaryLog = ({ isRef, componentName }: MakeOptions) => { 15 | const componentInfo = ` 16 | Component information: 17 | ${c.dim('Component name:')} ${c.yellow(`${componentName}`)}`; 18 | 19 | const forwardsRef = ` 20 | ${c.dim('Forwards ref:')} ${checkOrCross(isRef, 'Will forward a ref to inner component', "Won't forward a ref")}`; 21 | 22 | log(componentInfo + forwardsRef + '\n'); 23 | }; 24 | -------------------------------------------------------------------------------- /scripts/make/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The available make options chosen and logged during the script. 3 | */ 4 | export interface MakeOptions { 5 | isRef: boolean; 6 | componentName: string; 7 | } 8 | -------------------------------------------------------------------------------- /scripts/make/utils/pathUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Joins segments with '/' to create a path. 3 | * @param segments The multiple segments to join. 4 | */ 5 | export function pathify(...segments: string[]) { 6 | return segments.join('/'); 7 | } 8 | 9 | export const getPaths = (componentName: string) => { 10 | // Directories. 11 | const rootDir = 'src'; 12 | const componentCommonDir = pathify(rootDir, 'components'); // Components folder. 13 | const componentDir = pathify(componentCommonDir, componentName); 14 | 15 | // Files. 16 | const indexFile = 'index.ts'; 17 | const componentFile = componentName + '.tsx'; 18 | const styleFile = '_' + componentName + '.scss'; 19 | const testFile = componentName + '.test.tsx'; 20 | const storyFile = componentName + '.stories.tsx'; 21 | const readmeFile = 'README.md'; 22 | const styleIndexFile = 'index.scss'; 23 | const componentIndexFile = 'index.ts'; 24 | 25 | return { 26 | rootDir, 27 | componentCommonDir, 28 | componentDir, 29 | indexFile, 30 | componentFile, 31 | styleFile, 32 | testFile, 33 | storyFile, 34 | readmeFile, 35 | styleIndexFile, 36 | componentIndexFile, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /scripts/make/utils/stringUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Transforms pascal case (eg. SomeString) to camel case (eg. someString). 3 | * @param s The pascal case string to transform to camel case. 4 | */ 5 | export function pascalToCamel(s: string) { 6 | return s.charAt(0).toLowerCase() + s.slice(1); 7 | } 8 | 9 | /** 10 | * Transforms pascal case (eg. SomeString) to delimiter (eg. some-string). 11 | * @param string The pascal case string. 12 | * @param delimiter Defaults to '-'. 13 | */ 14 | export function pascalToDelimiter(string: string, delimiter = '-') { 15 | return string 16 | .split(/(?=[A-Z])/) 17 | .join(delimiter) 18 | .toLowerCase(); 19 | } 20 | -------------------------------------------------------------------------------- /scripts/verify-package-json.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | /** 5 | * This script makes sure that the symlink between this repo and 6 | * any local repos are destroyed before commiting 7 | */ 8 | (async () => { 9 | const jsonPath = path.resolve(__dirname, '../package.json'); 10 | const json = JSON.parse(fs.readFileSync(jsonPath) + ''); 11 | json.main = './dist/cjs/index.js'; 12 | fs.writeFileSync(jsonPath, JSON.stringify(json, null, 2) + '\n'); 13 | })(); 14 | -------------------------------------------------------------------------------- /src/__stories__/1_basics/Version.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react'; 2 | import font from '../../storybook-helpers/theme/font'; 3 | const version = require('../../../package.json').version; 4 | 5 | export function Version() { 6 | return <span>{version}</span>; 7 | } 8 | 9 | export function Current(props: { text: string }) { 10 | const style: CSSProperties = { 11 | fontFamily: font.fontBase, 12 | margin: 0, 13 | WebkitTapHighlightColor: 'rgba(0,0,0,0)', 14 | WebkitOverflowScrolling: 'touch', 15 | color: '#00a3e3', 16 | textDecoration: 'none', 17 | }; 18 | 19 | return ( 20 | <a 21 | style={style} 22 | href={`https://github.com/PDFTron/webviewer-react-toolkit/releases/tag/v${version}`} 23 | target="_blank" 24 | rel="noopener noreferrer" 25 | > 26 | {props.text || version} 27 | </a> 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/__stories__/2_style/2_variables.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | import { Theme, Groups } from './styleComponents'; 3 | 4 | <Meta title="Style/Variables" parameters={{previewTabs: {canvas: {hidden: true}}}} /> 5 | 6 | # Variables 7 | 8 | To allow consistency over our variables, we centralize the style by using 9 | a number of variables to control everything from font size to breakpoints. These 10 | are all configurable, and doing so will change the appearance of the toolkit. 11 | 12 | > The WebViewer React Toolkit css stylesheet contains 13 | > [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties). 14 | > It also includes a file of Sass variables that might be helpful for development 15 | > if you use Sass. 16 | 17 | ## Using 18 | 19 | To use these in your own project, you can include the variables in your code to 20 | match the visual structure outlined in the toolkit: 21 | 22 | ```scss 23 | div { 24 | /* If you are using CSS: */ 25 | border-radius: var(--border-radius); 26 | /* If you are using Sass: */ 27 | border-radius: $border-radius; 28 | } 29 | ``` 30 | 31 | If you are using Sass, make sure you import the Sass variables file: 32 | 33 | ```scss 34 | @import "~@pdftron/webviewer-react-toolkit/dist/scss/_variables.scss"; 35 | ``` 36 | 37 | ## Overriding 38 | 39 | Since each value is a variable, and all Sass variables just point to the CSS 40 | variables, you can easily the appearance of the toolkit. Here's an example of 41 | overriding the border radius: 42 | 43 | ```css 44 | /* your_custom_theme.css */ 45 | :root { 46 | --border-radius-small: 1px; 47 | --border-radius: 6px; 48 | --border-radius-large: 12px; 49 | } 50 | ``` 51 | 52 | ## CSS and Sass Variables 53 | 54 | Below are the CSS and Sass variables. You can click any of these to copy the 55 | value to your clipboard. 56 | 57 | <Groups /> 58 | -------------------------------------------------------------------------------- /src/__stories__/2_style/4_breakpoints.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | import { Mixins, Breakpoints } from './styleBreakpoints'; 3 | import { ToastProvider } from '../../components'; 4 | 5 | <Meta title="Style/Breakpoints" parameters={{previewTabs: {canvas: {hidden: true}}}} /> 6 | 7 | # Breakpoints 8 | 9 | Unfortunately, 10 | [CSS variables do not work in media queries](https://stackoverflow.com/a/40723269), 11 | and so breakpoints are not customizable. However, we provide our breakpoints for 12 | you to use, either as Sass mixins, or just hard-coded pixel values. 13 | 14 | ## Using 15 | 16 | To use these in your own project, you can include the mixins in your code: 17 | 18 | ```scss 19 | div { 20 | @include for-tablet-up { 21 | background-color: blue; 22 | }; 23 | } 24 | ``` 25 | 26 | Or copy the code into your CSS files: 27 | 28 | ```css 29 | div { 30 | @media (min-width: 768px) { 31 | background-color: blue; 32 | } 33 | } 34 | ``` 35 | 36 | If you are using Sass, make sure you import the Sass breakpoints file: 37 | 38 | ```scss 39 | @import "~@pdftron/webviewer-react-toolkit/dist/scss/_breakpoints.scss"; 40 | ``` 41 | 42 | ## Breakpoint Visualizer 43 | 44 | You can change the size of your browser window to see the breakpoints in effect. 45 | 46 | <Breakpoints /> 47 | 48 | ## Mixins 49 | 50 | The following mixins show the value you can include in your code, as well as the 51 | raw values in case you wish to copy them to CSS code. You can click any of these 52 | to copy the value to your clipboard. 53 | 54 | <!-- Below here is auto-generated. DO NOT EDIT. --> 55 | <!-- GENERATE_ENTRY --> 56 | 57 | <ToastProvider> 58 | 59 | 60 | ### `for-phone-only` 61 | 62 | Try not to use this, as it breaks "mobile first" principles. Applies only to mobile devices. 63 | 64 | <Mixins index={0} /> 65 | 66 | ```scss 67 | @media (max-width: 767px) { 68 | /* Content */ 69 | } 70 | ``` 71 | 72 | ### `for-tablet-up` 73 | 74 | Style applies to devices with width tablet or above. 75 | 76 | <Mixins index={1} /> 77 | 78 | ```scss 79 | @media (min-width: 768px) { 80 | /* Content */ 81 | } 82 | ``` 83 | 84 | ### `for-desktop-up` 85 | 86 | Style applies to devices with width desktop or above. 87 | 88 | <Mixins index={2} /> 89 | 90 | ```scss 91 | @media (min-width: 1024px) { 92 | /* Content */ 93 | } 94 | ``` 95 | 96 | </ToastProvider> 97 | -------------------------------------------------------------------------------- /src/__stories__/2_style/generated/breakpointRange.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | params: 'for-phone-only', 4 | max: 767, 5 | prop: '$breakpoint-tablet', 6 | value: '768px', 7 | }, 8 | { 9 | params: 'for-tablet-up', 10 | min: 768, 11 | prop: '$breakpoint-tablet', 12 | value: '768px', 13 | }, 14 | { 15 | params: 'for-desktop-up', 16 | min: 1024, 17 | prop: '$breakpoint-desktop', 18 | value: '1024px', 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /src/__stories__/2_style/generated/breakpoints.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | '@include for-phone-only {\n // Content\n};', 3 | '@include for-tablet-up {\n // Content\n};', 4 | '@include for-desktop-up {\n // Content\n};', 5 | ]; 6 | -------------------------------------------------------------------------------- /src/__stories__/2_style/generated/mixins.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | '@include focus;', 3 | '@include focus-inset;', 4 | '@include absolute-fill;', 5 | '@include fixed-fill;', 6 | '@include flex-center;', 7 | '@include padding-bottom($paddingBottom, $grid-row-gap: 0px);', 8 | '@include skeleton;', 9 | '@include spinner;', 10 | '@include slide-in;', 11 | '@include opacity-transition;', 12 | '@include opacity-transition-fast;', 13 | ]; 14 | -------------------------------------------------------------------------------- /src/__stories__/2_style/styles.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react'; 2 | import font from '../../storybook-helpers/theme/font'; 3 | 4 | /* --- Styles. --- */ 5 | 6 | export const style: CSSProperties = { 7 | userSelect: 'none', 8 | padding: 8, 9 | backgroundColor: 'rgba(255, 255, 255, 0.7)', 10 | borderRadius: 4, 11 | cursor: 'pointer', 12 | display: 'flex', 13 | alignItems: 'center', 14 | fontFamily: font.fontCode, 15 | fontSize: 12, 16 | overflow: 'hidden', 17 | }; 18 | 19 | export const dividerStyle: CSSProperties = { 20 | ...style, 21 | cursor: 'default', 22 | backgroundColor: 'transparent', 23 | color: style.backgroundColor, 24 | fontFamily: font.fontBase, 25 | justifyContent: 'center', 26 | }; 27 | 28 | export const prefixStyle: CSSProperties = { 29 | ...dividerStyle, 30 | paddingLeft: 0, 31 | paddingRight: 0, 32 | marginRight: -8, 33 | justifyContent: 'flex-end', 34 | }; 35 | 36 | export const titleStyle: CSSProperties = { 37 | fontFamily: font.fontBase, 38 | margin: '20px 0 8px', 39 | padding: 0, 40 | position: 'relative', 41 | color: 'rgba(240,240,255,0.9)', 42 | fontSize: 20, 43 | }; 44 | -------------------------------------------------------------------------------- /src/__stories__/2_style/utils.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent } from 'react'; 2 | import { useToast } from '../../hooks'; 3 | 4 | async function fallbackCopyTextToClipboard(text: string) { 5 | const textArea = document.createElement('textarea'); 6 | textArea.value = text; 7 | textArea.style.position = 'fixed'; // avoid scrolling to bottom 8 | document.body.appendChild(textArea); 9 | textArea.focus(); 10 | textArea.select(); 11 | 12 | const successful = document.execCommand('copy'); 13 | if (!successful) throw new Error(); 14 | 15 | document.body.removeChild(textArea); 16 | } 17 | 18 | export function useCopy() { 19 | const toast = useToast(); 20 | 21 | return (text: string) => { 22 | return (e: MouseEvent<HTMLDivElement>) => { 23 | e.preventDefault(); 24 | e.stopPropagation(); 25 | const copyFunc = navigator.clipboard 26 | ? () => navigator.clipboard.writeText(text) 27 | : () => fallbackCopyTextToClipboard(text); 28 | copyFunc().then( 29 | () => { 30 | toast.remove(); 31 | toast.add({ 32 | heading: 'Copied Value', 33 | children: <code style={{ whiteSpace: 'pre-wrap' }}>{text}</code>, 34 | message: 'success', 35 | timeout: 2000, 36 | }); 37 | }, 38 | err => { 39 | toast.add({ 40 | heading: 'Error Copying Text', 41 | children: <code style={{ whiteSpace: 'pre-wrap' }}>{err}</code>, 42 | message: 'error', 43 | timeout: 2000, 44 | }); 45 | }, 46 | ); 47 | }; 48 | }; 49 | } 50 | 51 | export function getTitle(group: string) { 52 | let words = group.split(/(?=[A-Z])/); 53 | words = words.map(w => w.charAt(0).toUpperCase() + w.slice(1)); 54 | return words.join(' '); 55 | } 56 | -------------------------------------------------------------------------------- /src/__stories__/3_hooks/useFocusTrap.stories.mdx: -------------------------------------------------------------------------------- 1 | import { options, output } from './useFocusTrapHelpers'; 2 | import { Meta, Props } from '@storybook/addon-docs/blocks'; 3 | import { LinkTo } from '../../storybook-helpers/mdx'; 4 | 5 | <Meta title="Hooks/useFocusTrap" parameters={{previewTabs: {canvas: {hidden: true}}}} /> 6 | 7 | # useFocusTrap 8 | 9 | A hook that allows you to conditionally lock focus within an element. This is 10 | valuable for trapping focus inside modals, or any other use case which requires 11 | you to prevent the user from tabbing out of a context. 12 | 13 | The first argument is `locked`, a boolean determining whether focus is locked 14 | within the element you pass the ref to. 15 | 16 | This functionality is wrapped by the 17 | component <LinkTo title="Components/FocusTrap" story="Basic">FocusTrap</LinkTo> if you prefer 18 | to use a component rather than a hook. 19 | 20 | ```tsx 21 | const focusRef = useFocusTrap(locked, { /* UseFocusTrapOptions */ }); 22 | 23 | return ( 24 | <div ref={focusRef}> 25 | <input /> 26 | <button>Some button</button> 27 | </div> 28 | ); 29 | ``` 30 | 31 | ## `UseFocusTrapOptions` 32 | 33 | <Props of={options} /> 34 | 35 | ## Returns 36 | 37 | ```tsx 38 | RefObject<HTMLElement> 39 | ``` -------------------------------------------------------------------------------- /src/__stories__/3_hooks/useFocusTrapHelpers.ts: -------------------------------------------------------------------------------- 1 | import { UseFocusTrapOptions } from '../../hooks/useFocusTrap'; 2 | 3 | /* eslint-disable @typescript-eslint/no-unused-vars */ 4 | 5 | export function options(x: UseFocusTrapOptions) {} 6 | -------------------------------------------------------------------------------- /src/__stories__/3_hooks/useManagedFiles.stories.mdx: -------------------------------------------------------------------------------- 1 | import { options, output } from './useManagedFilesHelpers'; 2 | import { Meta, Props, Story, Preview } from '@storybook/addon-docs/blocks'; 3 | import { LinkTo } from '../../storybook-helpers/mdx'; 4 | 5 | <Meta title="Hooks/useManagedFiles" parameters={{previewTabs: {canvas: {hidden: true}}}} /> 6 | 7 | # useManagedFiles 8 | 9 | A hook that provides complex managing behavior for files. It is designed for use with `FileOrganizer`, but can be used anywhere you need to manage an array of files. The basic functionality it provides is: 10 | 11 | 1. File selection 12 | 1. File movement 13 | 1. Moving multiple selected files 14 | 15 | ```tsx 16 | const { fileOrganizerProps, getThumbnailSelectionProps } = useManagedFiles({ /* UseManagedFilesOptions */ }); 17 | 18 | return ( 19 | <FileOrganizer 20 | {...fileOrganizerProps} 21 | onRenderThumbnail={({ id, onRenderThumbnailProps }) => ( 22 | <Thumbnail 23 | {...getThumbnailSelectionProps(id)} 24 | {...onRenderThumbnailProps} 25 | /> 26 | )} 27 | /> 28 | ); 29 | ``` 30 | 31 | ## `UseManagedFilesOptions` 32 | 33 | <Props of={options} /> 34 | 35 | ## `UseManagedFilesOutput` (return value) 36 | 37 | <Props of={output} /> 38 | 39 | ## Example 40 | 41 | Here's the hook in action: 42 | 43 | > Note: See <LinkTo title="Components/FileOrganizer" story="Use Managed Files Hook">FileOrganizer</LinkTo> for more demos 44 | 45 | <Preview> 46 | <Story inline={false} height="550px" id="components-fileorganizer--use-managed-files-hook" /> 47 | </Preview> 48 | -------------------------------------------------------------------------------- /src/__stories__/3_hooks/useManagedFilesHelpers.ts: -------------------------------------------------------------------------------- 1 | import { UseManagedFilesOptions, UseManagedFilesOutput } from '../../hooks/useManagedFiles'; 2 | 3 | /* eslint-disable @typescript-eslint/no-unused-vars */ 4 | 5 | export function options<F>(x: UseManagedFilesOptions<F>) {} 6 | export function output<F>(x: UseManagedFilesOutput<F>) {} 7 | -------------------------------------------------------------------------------- /src/__stories__/3_hooks/useToast.stories.mdx: -------------------------------------------------------------------------------- 1 | import { options, output } from './useToastHelpers'; 2 | import { Meta, Props, Story, Preview } from '@storybook/addon-docs/blocks'; 3 | import { LinkTo } from '../../storybook-helpers/mdx'; 4 | 5 | <Meta title="Hooks/useToast" parameters={{previewTabs: {canvas: {hidden: true}}}} /> 6 | 7 | # useToast 8 | 9 | A hook that provides the context value 10 | of <LinkTo title="Components/ToastProvider" story="Basic">ToastProvider</LinkTo> in 11 | order to add or remove Toast notifications. Make sure that you have a 12 | `ToastProvider` somewhere further up in your React project, or this will not 13 | work. 14 | 15 | ```ts 16 | const { add, remove } = useToast(); 17 | 18 | const toastId = add({ 19 | heading: 'You are not able to do that', 20 | children: 'The action you are attempting to do is not possible.', 21 | message: 'warning', 22 | action: { text: 'Try Again', onClick: tryAgain }, 23 | closable: true, 24 | timeout: 4000, 25 | }); 26 | 27 | // Later, if you want to remove: 28 | remove(toastId); 29 | ``` 30 | 31 | ## `ToastContextValue` (return value) 32 | 33 | <Props of={output} /> 34 | 35 | ## `AddToast` 36 | 37 | <Props of={options} /> 38 | 39 | ## Example 40 | 41 | Here's the hook in action: 42 | 43 | <Preview> 44 | <Story inline={false} height="550px" id="components-toastprovider--basic" /> 45 | </Preview> 46 | -------------------------------------------------------------------------------- /src/__stories__/3_hooks/useToastHelpers.ts: -------------------------------------------------------------------------------- 1 | import { ToastProps } from '../../components'; 2 | import { AddToast, ToastContextValue } from '../../hooks/useToast'; 3 | import { Include } from '../../utils'; 4 | 5 | /* eslint-disable @typescript-eslint/no-unused-vars */ 6 | 7 | interface Test extends AddToast, Include<ToastProps, 'heading' | 'children' | 'message' | 'action'> {} 8 | 9 | export function options(x: Test) {} 10 | export function output(x: ToastContextValue) {} 11 | -------------------------------------------------------------------------------- /src/__stories__/4_data/file.stories.mdx: -------------------------------------------------------------------------------- 1 | import { options, output } from './fileHelpers'; 2 | import { Meta, Props, Story, Preview } from '@storybook/addon-docs/blocks'; 3 | import { LinkTo } from '../../storybook-helpers/mdx'; 4 | 5 | <Meta title="Data/File" parameters={{previewTabs: {canvas: {hidden: true}}}} /> 6 | 7 | # File 8 | 9 | The `File` class is a utility that encapsulates useful functionality from the 10 | WebViewer API. It exposes various properties and methods for manipulating the 11 | `File`. 12 | 13 | ```ts 14 | const file = new File({ /* FileDetails */ }); 15 | 16 | await file.rotate(); 17 | setThumbnail(file.thumbnail.get()); 18 | ``` 19 | 20 | ## Providing a License Key 21 | 22 | Since file uses [WebViewer APIs](https://www.pdftron.com/documentation/web/) 23 | internally, you must provide a license key in order to remove watermarking and 24 | enable the full functionality. This can be done by either setting a global 25 | license, or individually including licenses for files during initialization: 26 | 27 | ```ts 28 | // Globally set the license for all files (this is the best method) 29 | File.setGlobalLicense('your-license-key'); 30 | 31 | // Individually set license for a file instance (this will take priority if set) 32 | const file = new File({license: 'your-license-key'}) 33 | ``` 34 | 35 | ## `FileDetails` 36 | 37 | <Props of={options} /> 38 | 39 | ## `File` (class) 40 | 41 | <Props of={output} /> 42 | -------------------------------------------------------------------------------- /src/__stories__/4_data/fileHelpers.ts: -------------------------------------------------------------------------------- 1 | import { File, FileDetails } from '../../data/file'; 2 | import { Include } from '../../utils'; 3 | 4 | type Public = Include<File, any>; 5 | 6 | /* eslint-disable @typescript-eslint/no-unused-vars */ 7 | 8 | export function options(x: FileDetails) {} 9 | export function output(x: Public) {} 10 | -------------------------------------------------------------------------------- /src/__stories__/4_data/memoized-promise.stories.mdx: -------------------------------------------------------------------------------- 1 | import { options, output } from './memoizedPromiseHelpers'; 2 | import { Meta, Props, Story, Preview } from '@storybook/addon-docs/blocks'; 3 | import { LinkTo } from '../../storybook-helpers/mdx'; 4 | 5 | <Meta title="Data/MemoizedPromise" parameters={{previewTabs: {canvas: {hidden: true}}}} /> 6 | 7 | # MemoizedPromise 8 | 9 | The `MemoizedPromise` class is a utility that allows for caching of lazy 10 | promises. This is used internally in <LinkTo title="Data/File">File</LinkTo> in 11 | order to allow for lazy retrieval of potentially expensive properties. It allows 12 | for throttling, for example 13 | in <LinkTo title="Components/FileOrganizer" story="'Use Managed Files Hook">FileOrganizer</LinkTo> when 14 | lazy `Thumbnails` are not fetched until 500ms after entering view. 15 | 16 | ```ts 17 | const memoizedPromise = new MemoizedPromise(value, { /* MemoizeOptions */ }); 18 | 19 | if (memoizedPromise.done) { 20 | setValue(memoizedPromise.get()); 21 | } else { 22 | throttle(() => setValue(memoizedPromise.get())); 23 | } 24 | ``` 25 | 26 | ## `MemoizeOptions` 27 | 28 | <Props of={options} /> 29 | 30 | ## `MemoizedPromise` (class) 31 | 32 | <Props of={output} /> 33 | -------------------------------------------------------------------------------- /src/__stories__/4_data/memoizedPromiseHelpers.ts: -------------------------------------------------------------------------------- 1 | import { MemoizedPromise, MemoizeOptions } from '../../data/memoizedPromise'; 2 | import { Include } from '../../utils'; 3 | 4 | type Public<T> = Include<MemoizedPromise<T>, any>; 5 | 6 | /* eslint-disable @typescript-eslint/no-unused-vars */ 7 | 8 | export function options(x: MemoizeOptions) {} 9 | export function output<T>(x: Public<T>) {} 10 | -------------------------------------------------------------------------------- /src/components/Button/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean, select, text } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { action } from '../../storybook-helpers/action/action'; 4 | import { Button } from '../Button'; 5 | import readme from './README.md'; 6 | 7 | export default { title: 'Components/Button', component: Button, parameters: { readme } }; 8 | 9 | export const Basic = () => ( 10 | <Button 11 | buttonStyle={select('buttonStyle', ['default', 'borderless', 'outline'], 'default')} 12 | buttonSize={select('buttonSize', ['small', 'default', 'large'], 'default')} 13 | disabled={boolean('disabled', false)} 14 | onClick={action('onClick')} 15 | > 16 | {text('children', 'Button content')} 17 | </Button> 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/Button/Button.test.tsx: -------------------------------------------------------------------------------- 1 | import { mount, shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { spy } from 'sinon'; 4 | import { Button } from '../Button'; 5 | 6 | describe('Button component', () => { 7 | it('renders its contents', () => { 8 | const button = shallow(<Button />); 9 | expect(button.find('.ui__button')).toHaveLength(1); 10 | }); 11 | 12 | it('snapshot renders default button', () => { 13 | const button = shallow(<Button />); 14 | expect(button).toMatchSnapshot(); 15 | }); 16 | 17 | it('clicking button triggers onClick prop', () => { 18 | const onClick = spy(); 19 | shallow(<Button onClick={onClick} />).simulate('click'); 20 | expect(onClick.callCount).toBe(1); 21 | }); 22 | 23 | it('clicking disabled button does not trigger onClick prop', () => { 24 | const onClick = spy(); 25 | mount(<Button onClick={onClick} disabled />).simulate('click'); 26 | expect(onClick.callCount).toBe(0); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { ButtonHTMLAttributes, forwardRef } from 'react'; 3 | import { useAccessibleFocus } from '../../hooks'; 4 | 5 | export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { 6 | /** 7 | * Sets the visual appearance of the button. 8 | * @default "default" 9 | */ 10 | buttonStyle?: 'default' | 'borderless' | 'outline'; 11 | /** 12 | * Sets the size of the button. 13 | * @default "default" 14 | */ 15 | buttonSize?: 'small' | 'default' | 'large'; 16 | /** 17 | * Defaults to 'button' instead of 'submit' to prevent accidental submissions. 18 | * @default "button" 19 | */ 20 | type?: ButtonHTMLAttributes<HTMLButtonElement>['type']; 21 | } 22 | 23 | export const Button = forwardRef<HTMLButtonElement, ButtonProps>( 24 | ({ buttonStyle = 'default', buttonSize = 'default', type = 'button', className, children, ...buttonProps }, ref) => { 25 | const isUserTabbing = useAccessibleFocus(); 26 | 27 | const buttonClass = classnames( 28 | 'ui__base ui__button', 29 | `ui__button--style-${buttonStyle}`, 30 | `ui__button--size-${buttonSize}`, 31 | { 32 | 'ui__button--disabled': buttonProps.disabled, 33 | 'ui__button--tabbing': isUserTabbing, 34 | }, 35 | className, 36 | ); 37 | 38 | return ( 39 | <button {...buttonProps} className={buttonClass} type={type} ref={ref}> 40 | <div className="ui__button__internal">{children}</div> 41 | </button> 42 | ); 43 | }, 44 | ); 45 | -------------------------------------------------------------------------------- /src/components/Button/README.md: -------------------------------------------------------------------------------- 1 | A versatile button component that can adapt to various styles and sizes. 2 | -------------------------------------------------------------------------------- /src/components/Button/__snapshots__/Button.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Button component snapshot renders default button 1`] = ` 4 | <button 5 | className="ui__base ui__button ui__button--style-default ui__button--size-default" 6 | type="button" 7 | > 8 | <div 9 | className="ui__button__internal" 10 | /> 11 | </button> 12 | `; 13 | -------------------------------------------------------------------------------- /src/components/Button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Button'; 2 | -------------------------------------------------------------------------------- /src/components/ButtonGroup/ButtonGroup.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean, number, select } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { Button } from '../Button'; 4 | import { ButtonProps } from '../Button/Button'; 5 | import { ButtonGroup } from '../ButtonGroup'; 6 | import readme from './README.md'; 7 | 8 | export default { title: 'Components/ButtonGroup', component: ButtonGroup, parameters: { readme } }; 9 | 10 | export const Basic = () => { 11 | const numButtons = number('numButtons', 2, { min: 1, max: 5, range: true, step: 1 }); 12 | 13 | return ( 14 | <ButtonGroup 15 | position={select('position', ['left', 'center', 'right', 'space-between', 'space-around'], 'right')} 16 | reverseWrap={boolean('reverseWrap', false)} 17 | centerMobile={boolean('centerMobile', false)} 18 | > 19 | {Array.from({ length: numButtons }).map((_, index) => { 20 | if (index === 0) { 21 | return <Button key={index}>Accept</Button>; 22 | } 23 | if (index === 1) { 24 | return ( 25 | <Button key={index} buttonStyle="outline"> 26 | Cancel 27 | </Button> 28 | ); 29 | } 30 | return ( 31 | <Button 32 | key={index} 33 | buttonStyle={['default', 'outline', 'borderless'][index % 3] as ButtonProps['buttonStyle']} 34 | > 35 | Button {index + 1} 36 | </Button> 37 | ); 38 | })} 39 | </ButtonGroup> 40 | ); 41 | }; 42 | 43 | export const Nested = () => { 44 | return ( 45 | <ButtonGroup position="space-between" centerMobile> 46 | <Button>Other</Button> 47 | 48 | <ButtonGroup position="right" centerMobile> 49 | <Button>Accept</Button> 50 | <Button buttonStyle="outline">Cancel</Button> 51 | </ButtonGroup> 52 | </ButtonGroup> 53 | ); 54 | }; 55 | 56 | export const WithLargeButtons = () => { 57 | return ( 58 | <ButtonGroup 59 | position={select('position', ['left', 'center', 'right', 'space-between', 'space-around'], 'right')} 60 | reverseWrap={boolean('reverseWrap', false)} 61 | centerMobile={boolean('centerMobile', false)} 62 | > 63 | <Button>Accept But With Long Name So You Can See It Wrap</Button> 64 | <Button buttonStyle="outline">Cancel But With Long Name So You Can See It Wrap</Button> 65 | </ButtonGroup> 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/ButtonGroup/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { FC, HTMLAttributes } from 'react'; 3 | 4 | export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement> { 5 | /** 6 | * Position the buttons within the group. 7 | * @default "right" 8 | */ 9 | position?: 'left' | 'center' | 'right' | 'space-between' | 'space-around'; 10 | /** 11 | * If given, will wrap the buttons in reverse order. This is valuable if you 12 | * have an accept button on the left, but want it on the bottom when wrapped. 13 | */ 14 | reverseWrap?: boolean; 15 | /** 16 | * Center the buttons at mobile widths. 17 | */ 18 | centerMobile?: boolean; 19 | } 20 | 21 | export const ButtonGroup: FC<ButtonGroupProps> = ({ 22 | position = 'right', 23 | reverseWrap, 24 | centerMobile, 25 | children, 26 | className, 27 | ...props 28 | }) => { 29 | const buttonGroupClass = classnames( 30 | 'ui__base ui__buttonGroup', 31 | `ui__buttonGroup--position-${position}`, 32 | { 33 | 'ui__buttonGroup--reverse': reverseWrap, 34 | 'ui__buttonGroup--centerMobile': centerMobile, 35 | }, 36 | className, 37 | ); 38 | 39 | return ( 40 | <div {...props} className={buttonGroupClass}> 41 | {children} 42 | </div> 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/ButtonGroup/README.md: -------------------------------------------------------------------------------- 1 | Allows you to easily group buttons, with the ability to position them within the 2 | group. 3 | -------------------------------------------------------------------------------- /src/components/ButtonGroup/_ButtonGroup.scss: -------------------------------------------------------------------------------- 1 | .ui__buttonGroup { 2 | // do not mulply by -1 as it seems to cause issues for other libraries that uses this library 3 | margin: calc(1px - #{$padding-tiny} - 1px); 4 | display: flex; 5 | flex-wrap: wrap; 6 | 7 | // Undo negative margin on nested groups. 8 | > .ui__buttonGroup { 9 | margin: 0; 10 | } 11 | 12 | // Buttons inside are kept apart by padding. 13 | > button { 14 | margin: $padding-tiny; 15 | } 16 | 17 | &--reverse { 18 | flex-wrap: wrap-reverse; 19 | } 20 | 21 | &--centerMobile.ui__buttonGroup { 22 | @include for-phone-only { 23 | justify-content: center; 24 | } 25 | } 26 | 27 | &--position { 28 | &-left { 29 | justify-content: flex-start; 30 | } 31 | 32 | &-center { 33 | justify-content: center; 34 | } 35 | 36 | &-right { 37 | justify-content: flex-end; 38 | } 39 | 40 | &-space-between { 41 | justify-content: space-between; 42 | } 43 | 44 | &-space-around { 45 | justify-content: space-around; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/ButtonGroup/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ButtonGroup'; 2 | -------------------------------------------------------------------------------- /src/components/Choice/Choice.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { Choice } from '../Choice'; 4 | 5 | describe('Choice component', () => { 6 | it('renders its contents', () => { 7 | const choice = shallow(<Choice label="Label" />); 8 | expect(choice.find('.ui__choice')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default choice', () => { 12 | const choice = shallow(<Choice label="Label" />); 13 | expect(choice).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Choice/README.md: -------------------------------------------------------------------------------- 1 | Choice provides a component for either checkboxes or radio selection buttons. 2 | These function how native radio or checkbox components would. 3 | -------------------------------------------------------------------------------- /src/components/Choice/__snapshots__/Choice.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Choice component snapshot renders default choice 1`] = ` 4 | <span 5 | className="ui__base ui__choice" 6 | > 7 | <span 8 | className="ui__choice__input" 9 | > 10 | <div 11 | className="ui__choice__input__check" 12 | /> 13 | <input 14 | id="label_1" 15 | onBlur={[Function]} 16 | onChange={[Function]} 17 | onFocus={[Function]} 18 | type="checkbox" 19 | /> 20 | </span> 21 | <label 22 | className="ui__choice__label" 23 | htmlFor="label_1" 24 | > 25 | Label 26 | </label> 27 | </span> 28 | `; 29 | -------------------------------------------------------------------------------- /src/components/Choice/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Choice'; 2 | -------------------------------------------------------------------------------- /src/components/ClickableDiv/ClickableDiv.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean, text } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { action } from '../../storybook-helpers/action/action'; 4 | import { ClickableDiv } from '../ClickableDiv'; 5 | import readme from './README.md'; 6 | 7 | export default { title: 'Components/ClickableDiv', component: ClickableDiv, parameters: { readme } }; 8 | 9 | export const Basic = () => ( 10 | <ClickableDiv 11 | disabled={boolean('disabled', false)} 12 | noFocusStyle={boolean('noFocusStyle', false)} 13 | usePointer={boolean('usePointer', false)} 14 | onClick={action('onClick')} 15 | > 16 | {text('children', 'This is a clickable div!')} 17 | </ClickableDiv> 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/ClickableDiv/ClickableDiv.test.tsx: -------------------------------------------------------------------------------- 1 | import { mount, shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { spy } from 'sinon'; 4 | import { ClickableDiv } from '../ClickableDiv'; 5 | 6 | describe('ClickableDiv component', () => { 7 | it('renders its contents', () => { 8 | const clickableDiv = shallow(<ClickableDiv />); 9 | expect(clickableDiv.find('.ui__clickableDiv')).toHaveLength(1); 10 | }); 11 | 12 | it('snapshot renders default clickableDiv', () => { 13 | const clickableDiv = shallow(<ClickableDiv />); 14 | expect(clickableDiv).toMatchSnapshot(); 15 | }); 16 | 17 | it('clicking clickableDiv triggers onClick prop', () => { 18 | const onClick = spy(); 19 | mount(<ClickableDiv onClick={onClick} />).simulate('click'); 20 | expect(onClick.callCount).toBe(1); 21 | }); 22 | 23 | it('clicking disabled clickableDiv does not trigger onClick prop', () => { 24 | const onClick = spy(); 25 | mount(<ClickableDiv onClick={onClick} disabled />).simulate('click'); 26 | expect(onClick.callCount).toBe(0); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/ClickableDiv/ClickableDiv.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { forwardRef, HTMLAttributes, useImperativeHandle, useRef } from 'react'; 3 | import { useAccessibleFocus, useKeyForClick, useOnClick } from '../../hooks'; 4 | import { Remove } from '../../utils'; 5 | 6 | export interface ClickableDivProps extends Remove<HTMLAttributes<HTMLDivElement>, 'role'> { 7 | /** 8 | * Is the clickable div disabled. Disabled will stop the onClick callback from 9 | * firing (similar to a button). 10 | */ 11 | disabled?: boolean; 12 | /** 13 | * No style when focused. If true will have no focus outline. 14 | */ 15 | noFocusStyle?: boolean; 16 | /** 17 | * Specify whether clickable div uses a pointer cursor. Otherwise is default. 18 | */ 19 | usePointer?: boolean; 20 | } 21 | 22 | export const ClickableDiv = forwardRef<HTMLDivElement, ClickableDivProps>( 23 | ({ onClick, onKeyPress, disabled, noFocusStyle, usePointer, className, children, tabIndex, ...divProps }, ref) => { 24 | const clickableDivRef = useRef<HTMLDivElement>(null); 25 | useImperativeHandle(ref, () => clickableDivRef.current as HTMLDivElement); 26 | 27 | const handleOnClick = useOnClick(onClick, { disabled, stopPropagation: true }); 28 | 29 | const handleKeyPress = useKeyForClick(onKeyPress, clickableDivRef); 30 | 31 | const isUserTabbing = useAccessibleFocus(); 32 | 33 | const clickableDivClass = classnames( 34 | 'ui__base ui__clickableDiv', 35 | { 36 | 'ui__clickableDiv--disabled': disabled, 37 | 'ui__clickableDiv--tabbing': isUserTabbing, 38 | 'ui__clickableDiv--noFocusStyle': noFocusStyle, 39 | 'ui__clickableDiv--usePointer': usePointer && !disabled, 40 | }, 41 | className, 42 | ); 43 | 44 | return ( 45 | <div 46 | {...divProps} 47 | role="button" 48 | tabIndex={disabled ? -1 : tabIndex ?? 0} 49 | className={clickableDivClass} 50 | onClick={handleOnClick} 51 | onKeyPress={handleKeyPress} 52 | ref={clickableDivRef} 53 | > 54 | {children} 55 | </div> 56 | ); 57 | }, 58 | ); 59 | -------------------------------------------------------------------------------- /src/components/ClickableDiv/README.md: -------------------------------------------------------------------------------- 1 | A div that acts like a focusable button, but without appearing as a traditional 2 | button. 3 | -------------------------------------------------------------------------------- /src/components/ClickableDiv/_ClickableDiv.scss: -------------------------------------------------------------------------------- 1 | .ui__clickableDiv { 2 | display: inline-block; 3 | outline: none; 4 | border: none; 5 | transition: $focus-transition; 6 | 7 | // Don't show focus shadow if no focus style or disabled. 8 | &:focus.ui__clickableDiv--tabbing:not(.ui__clickableDiv--noFocusStyle):not(.ui__clickableDiv--disabled) { 9 | @include focus; 10 | } 11 | 12 | &--usePointer { 13 | cursor: pointer; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/ClickableDiv/__snapshots__/ClickableDiv.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ClickableDiv component snapshot renders default clickableDiv 1`] = ` 4 | <div 5 | className="ui__base ui__clickableDiv" 6 | onClick={[Function]} 7 | onKeyPress={[Function]} 8 | role="button" 9 | tabIndex={0} 10 | /> 11 | `; 12 | -------------------------------------------------------------------------------- /src/components/ClickableDiv/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ClickableDiv'; 2 | -------------------------------------------------------------------------------- /src/components/DndMultiProvider/DndMultiProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { DndProvider } from 'react-dnd'; 3 | import MultiBackend from 'react-dnd-multi-backend'; 4 | import HTML5toTouch from 'react-dnd-multi-backend/dist/cjs/HTML5toTouch'; 5 | 6 | export const DndMultiProvider: FC = ({ children }) => { 7 | return ( 8 | <DndProvider backend={MultiBackend} options={HTML5toTouch}> 9 | {children} 10 | </DndProvider> 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/DndMultiProvider/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DndMultiProvider'; 2 | -------------------------------------------------------------------------------- /src/components/DragLayer/DragLayer.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react'; 2 | import { DndMultiProvider } from '../DndMultiProvider'; 3 | import { Draggable } from '../Draggable'; 4 | import { DragLayer } from '../DragLayer'; 5 | import readme from './README.md'; 6 | 7 | export default { title: 'Components/DragLayer', component: DragLayer, parameters: { readme } }; 8 | 9 | const WIDTH = 200; 10 | const HEIGHT = 50; 11 | 12 | const commonStyle: CSSProperties = { 13 | padding: 16, 14 | background: 'lightgray', 15 | color: 'black', 16 | width: WIDTH, 17 | height: HEIGHT, 18 | textAlign: 'center', 19 | }; 20 | 21 | export const Basic = () => ( 22 | <DndMultiProvider> 23 | <Draggable index={0} hideDragPreview={true}> 24 | <div style={{ ...commonStyle, border: '1px solid red' }}>This div is draggable!</div> 25 | </Draggable> 26 | <DragLayer> 27 | <div style={{ ...commonStyle, border: '1px solid blue', opacity: 0.9, boxShadow: '0 0 26px 0 rgba(0,0,0,0.2)' }}> 28 | Custom preview! 29 | </div> 30 | </DragLayer> 31 | </DndMultiProvider> 32 | ); 33 | 34 | export const WithCustomTranslate = () => ( 35 | <DndMultiProvider> 36 | <Draggable index={0} hideDragPreview={true}> 37 | <div style={{ ...commonStyle, border: '1px solid red' }}>This div is draggable!</div> 38 | </Draggable> 39 | <DragLayer 40 | customTranslate={({ mousePosition }) => { 41 | const x = mousePosition.x - WIDTH / 2; 42 | const y = mousePosition.y - HEIGHT / 2; 43 | return { x, y }; 44 | }} 45 | > 46 | <div style={{ ...commonStyle, border: '1px solid blue', opacity: 0.9, boxShadow: '0 0 26px 0 rgba(0,0,0,0.2)' }}> 47 | Custom preview! 48 | </div> 49 | </DragLayer> 50 | </DndMultiProvider> 51 | ); 52 | -------------------------------------------------------------------------------- /src/components/DragLayer/DragLayer.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { CSSProperties, FC, HTMLAttributes, ReactNode, useMemo } from 'react'; 3 | import { useDragLayer, XYCoord } from 'react-dnd'; 4 | 5 | export interface DragLayerProps extends HTMLAttributes<HTMLDivElement> { 6 | /** 7 | * The children passed to the drag layer will be rendered whenever an item is 8 | * drag-and-dropped. You may have to disable their image preview or else you 9 | * will see both. 10 | */ 11 | children?: ReactNode; 12 | /** 13 | * If not given, will use `currentOffset.x` and `currentOffset.y`. Can return 14 | * custom translate coordinates. This allows you to always center on mouse 15 | * position, or translate to any coordinates. 16 | */ 17 | customTranslate?: (params: { currentOffset: XYCoord; mousePosition: XYCoord }) => { x: number; y: number }; 18 | } 19 | 20 | export const DragLayer: FC<DragLayerProps> = ({ children, customTranslate, className, ...divProps }) => { 21 | const { currentOffset, isDragging, mousePosition } = useDragLayer(monitor => ({ 22 | currentOffset: monitor.getSourceClientOffset(), 23 | isDragging: monitor.isDragging(), 24 | mousePosition: monitor.getClientOffset(), 25 | })); 26 | 27 | const style = useMemo<CSSProperties>(() => { 28 | if (!currentOffset || !mousePosition) return { display: 'none' }; 29 | const { x, y } = customTranslate?.({ currentOffset, mousePosition }) ?? currentOffset; 30 | const transform = `translate(${x}px, ${y}px)`; 31 | return { transform, WebkitTransform: transform }; 32 | }, [currentOffset, customTranslate, mousePosition]); 33 | 34 | if (!isDragging) return null; 35 | 36 | const dragLayerClass = classnames('ui__base ui__dragLayer', className); 37 | 38 | return ( 39 | <div {...divProps} className={dragLayerClass}> 40 | <div style={style}>{children}</div> 41 | </div> 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/DragLayer/README.md: -------------------------------------------------------------------------------- 1 | This is a drag layer that implements 2 | [react-dnd](https://react-dnd.github.io/react-dnd) internally. It allows you to 3 | have a custom drag preview for any drag-and-drop container. In this component 4 | library, it is used to generate the page drag icons in `FileOrganizer`. It must 5 | be within a 6 | [DndProvider](https://react-dnd.github.io/react-dnd/docs/api/dnd-provider) with 7 | a backend (in our example we are using our own internal component called 8 | `DndMultiProvider` which bundles together mouse and touch handlers). 9 | -------------------------------------------------------------------------------- /src/components/DragLayer/_DragLayer.scss: -------------------------------------------------------------------------------- 1 | .ui__dragLayer { 2 | pointer-events: none; 3 | @include fixed-fill; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/DragLayer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DragLayer'; 2 | -------------------------------------------------------------------------------- /src/components/Draggable/Draggable.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean } from '@storybook/addon-knobs'; 2 | import React, { CSSProperties } from 'react'; 3 | import { action } from '../../storybook-helpers/action/action'; 4 | import { DndMultiProvider } from '../DndMultiProvider'; 5 | import { Draggable } from '../Draggable'; 6 | import readme from './README.md'; 7 | 8 | export default { title: 'Components/Draggable', component: Draggable, parameters: { readme } }; 9 | 10 | const commonStyle: CSSProperties = { 11 | padding: 16, 12 | background: 'lightgray', 13 | color: 'black', 14 | width: 200, 15 | height: 50, 16 | textAlign: 'center', 17 | }; 18 | 19 | export const Basic = () => ( 20 | <DndMultiProvider> 21 | <Draggable 22 | index={0} 23 | onDragChange={action('onDragChange')} 24 | disableDrag={boolean('disableDrag', false)} 25 | hideDragPreview={boolean('hideDragPreview', false)} 26 | > 27 | <div style={{ ...commonStyle, border: '1px solid red' }}>This div is draggable!</div> 28 | </Draggable> 29 | </DndMultiProvider> 30 | ); 31 | 32 | export const WithOnRenderChildren = () => ( 33 | <DndMultiProvider> 34 | <Draggable 35 | index={0} 36 | onRenderChildren={isDragging => ( 37 | <div style={{ ...commonStyle, border: `1px solid ${isDragging ? 'blue' : 'red'}` }}> 38 | {isDragging ? 'This div is being dragged!' : 'This div is draggable!'} 39 | </div> 40 | )} 41 | onDragChange={action('onDragChange')} 42 | disableDrag={boolean('disableDrag', false)} 43 | hideDragPreview={boolean('hideDragPreview', false)} 44 | /> 45 | </DndMultiProvider> 46 | ); 47 | -------------------------------------------------------------------------------- /src/components/Draggable/README.md: -------------------------------------------------------------------------------- 1 | This is a draggable wrapper that implements 2 | [`react-dnd`](https://react-dnd.github.io/react-dnd) internally. It allows you 3 | to have drag-and-drop-able elements. In this component library, it is used to 4 | wrap the thumbnails of `FileOrganizer`. It must be within a 5 | [DndProvider](https://react-dnd.github.io/react-dnd/docs/api/dnd-provider) with 6 | a backend (in our example we are using our own internal component called 7 | `DndMultiProvider` which bundles together mouse and touch handlers). 8 | -------------------------------------------------------------------------------- /src/components/Draggable/_Draggable.scss: -------------------------------------------------------------------------------- 1 | .ui__draggable { 2 | display: inline-block; 3 | 4 | &__animated { 5 | &--inMotion { 6 | pointer-events: none; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Draggable/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Draggable'; 2 | -------------------------------------------------------------------------------- /src/components/EditableText/EditableText.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { EditableText } from '../EditableText'; 4 | 5 | describe('EditableText component', () => { 6 | it('renders its contents', () => { 7 | const editableText = shallow(<EditableText />); 8 | expect(editableText.find('.ui__editableText')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default editableText', () => { 12 | const editableText = shallow(<EditableText />); 13 | expect(editableText).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/EditableText/README.md: -------------------------------------------------------------------------------- 1 | An editable text block that allows in-line editing of values. 2 | -------------------------------------------------------------------------------- /src/components/EditableText/_EditableText.scss: -------------------------------------------------------------------------------- 1 | .ui__editableText { 2 | &--disabled { 3 | .ui__editableText__button { 4 | > span { 5 | opacity: 0.7; 6 | } 7 | } 8 | } 9 | 10 | &--centerText { 11 | .ui__editableText__button { 12 | justify-content: center; 13 | } 14 | 15 | .ui__editableText__field { 16 | text-align: center; 17 | } 18 | } 19 | 20 | &--bordered { 21 | .ui__editableText__button { 22 | border: 1px solid $color-blue-gray-2; 23 | } 24 | 25 | &:not(.ui__editableText--disabled) .ui__editableText__button { 26 | &:hover { 27 | background-color: $color-blue-gray-2; 28 | } 29 | 30 | &--placeholder { 31 | color: $color-blue-gray-3; 32 | } 33 | } 34 | } 35 | 36 | .ui__editableText__field { 37 | line-height: 1; 38 | } 39 | 40 | &__button, 41 | &__field { 42 | min-height: 28px; 43 | min-width: 100%; 44 | color: $color-font-primary; 45 | padding: $padding-tiny; 46 | border-radius: $border-radius; 47 | border: 1px solid transparent; 48 | } 49 | 50 | &__button { 51 | display: flex; 52 | align-items: center; 53 | transition: 0.1s background-color; 54 | transition: $focus-transition; 55 | line-height: 1.15; // FIX: match the normalize.css for input. 56 | 57 | > span { 58 | line-height: 1.15rem; 59 | white-space: nowrap; 60 | text-overflow: ellipsis; 61 | overflow: hidden; 62 | } 63 | 64 | &--noFocusTransition { 65 | // Prevents focus ring "stutter". Remove if transition is removed from focus. 66 | transition: none; 67 | } 68 | } 69 | 70 | &:not(.ui__editableText--disabled):not(.ui__editableText--locked) 71 | .ui__editableText__button { 72 | &:hover { 73 | border: 1px solid $color-blue-gray-2; 74 | } 75 | 76 | &--placeholder { 77 | color: $color-blue-gray-3; 78 | } 79 | } 80 | 81 | &__field { 82 | background-color: $color-blue-gray-2; 83 | outline: none; 84 | transition: $focus-transition; 85 | 86 | &:focus.ui__editableText__field--tabbing { 87 | @include focus; 88 | // Prevents focus ring "stutter". Remove if transition is removed from focus. 89 | transition: none; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/components/EditableText/__snapshots__/EditableText.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`EditableText component snapshot renders default editableText 1`] = ` 4 | <div 5 | className="ui__base ui__editableText" 6 | > 7 | <ForwardRef 8 | className="ui__editableText__button" 9 | onClick={[Function]} 10 | usePointer={true} 11 | > 12 | <span /> 13 | </ForwardRef> 14 | </div> 15 | `; 16 | -------------------------------------------------------------------------------- /src/components/EditableText/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EditableText'; 2 | -------------------------------------------------------------------------------- /src/components/FileOrganizer/README.md: -------------------------------------------------------------------------------- 1 | The file organizer allows you to view multiple page files and use drag-and-drop 2 | to re-order them. You can also use the arrow keys while focusing any part of the 3 | file thumbnail in order to focus on a new file, or tab to move through them one 4 | by one (tab will also focus internal elements, such as the editable text within 5 | `Thumbnail`). The `FileOrganizer` is virtualized, so there will be no 6 | performance issues even with thousands of thumbnails (see the stress test 7 | playground). 8 | 9 | ## Moving files 10 | 11 | You can click and drag to move items. If you are using `useManagedFiles` hook, 12 | you can hold <kbd>Shift</kbd> to multi-select items and then move them together. 13 | Unless `preventArrowsToMove` is set to true, you can hold the <kbd>⌘ 14 | Command</kbd> key on macOS, or the <kbd>Ctrl</kbd> key on Windows and use the 15 | arrow keys, `onMove` will be fired. 16 | 17 | ## Rendering Files 18 | 19 | The easiest way to get started would be to use the `Thumbnail` element as the 20 | item that is rendered in the `FileOrganizer`. The `Thumbnail` component includes 21 | many default props that integrate well with the `FileOrganizer`. 22 | 23 | ## Managing Files 24 | 25 | It is recommended that you use the `useManagedFiles` hook, as it exposes many 26 | valuable tools to save you effort when managing a list of files. 27 | -------------------------------------------------------------------------------- /src/components/FileOrganizer/_FileOrganizer.scss: -------------------------------------------------------------------------------- 1 | .ui__fileOrganizer { 2 | position: relative; 3 | outline: 0; 4 | height: 100%; 5 | 6 | &__draglayer { 7 | overflow: visible; 8 | @include flex-center; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/FileOrganizer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FileOrganizer'; 2 | -------------------------------------------------------------------------------- /src/components/FilePicker/FilePicker.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { action } from '../../storybook-helpers/action/action'; 4 | import { FilePicker } from '../FilePicker'; 5 | import readme from './README.md'; 6 | 7 | export default { title: 'Components/FilePicker', component: FilePicker, parameters: { readme } }; 8 | 9 | export const Basic = () => { 10 | const onRename = boolean('has onRename', false); 11 | const onDelete = boolean('has onDelete', false); 12 | const long = boolean('has long name', false); 13 | 14 | return ( 15 | <div style={{ width: 200 }}> 16 | <FilePicker 17 | items={[ 18 | { 19 | key: 0, 20 | name: `file_1${long ? '_with_long_name_to_show_cut_off' : ''}.pdf`, 21 | onRename: onRename ? action('onRename file 1') : undefined, 22 | onDelete: onDelete ? action('onDelete file 1') : undefined, 23 | }, 24 | { 25 | key: 1, 26 | name: `file_2${long ? '_with_long_name_to_show_cut_off' : ''}.pdf`, 27 | onRename: onRename ? action('onRename file 2') : undefined, 28 | onDelete: onDelete ? action('onDelete file 2') : undefined, 29 | }, 30 | ]} 31 | /> 32 | </div> 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/FilePicker/FilePicker.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { FilePicker } from '../FilePicker'; 4 | 5 | describe('FilePicker component', () => { 6 | it('renders its contents', () => { 7 | const filePicker = shallow(<FilePicker items={[]} />); 8 | expect(filePicker.find('.ui__filePicker')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default filePicker', () => { 12 | const filePicker = shallow(<FilePicker items={[]} />); 13 | expect(filePicker).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/FilePicker/FilePicker.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { FC, HTMLAttributes, ReactText } from 'react'; 3 | import { EditableTextProps, EditableText } from '../EditableText'; 4 | import { IconButton } from '../IconButton'; 5 | import { Icon } from '../Icon'; 6 | 7 | export interface FilePickerItem { 8 | key: ReactText; 9 | name: string; 10 | onRename?: EditableTextProps['onSave']; 11 | onDelete?: () => void; 12 | className?: string; 13 | } 14 | 15 | export interface FilePickerProps extends HTMLAttributes<HTMLDivElement> { 16 | items: FilePickerItem[]; 17 | } 18 | 19 | export const FilePicker: FC<FilePickerProps> = ({ items, className, ...props }) => { 20 | const filePickerClass = classnames('ui__base ui__filePicker', className); 21 | 22 | return ( 23 | <div {...props} className={filePickerClass}> 24 | {items.map(item => ( 25 | <div className={classnames('ui__filePicker__file', item.className)} key={item.key}> 26 | <EditableText 27 | className="ui__filePicker__file__text" 28 | value={item.name} 29 | onSave={item.onRename} 30 | locked={!item.onRename} 31 | /> 32 | {item.onDelete ? ( 33 | <IconButton className="ui__filePicker__file__delete" onClick={item.onDelete}> 34 | <Icon icon="Close" /> 35 | </IconButton> 36 | ) : ( 37 | undefined 38 | )} 39 | </div> 40 | ))} 41 | </div> 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/FilePicker/README.md: -------------------------------------------------------------------------------- 1 | Organizes multiple files in a column, with options to rename or delete. 2 | -------------------------------------------------------------------------------- /src/components/FilePicker/_FilePicker.scss: -------------------------------------------------------------------------------- 1 | .ui__filePicker { 2 | &__file { 3 | display: flex; 4 | align-items: center; 5 | min-height: 28px; 6 | 7 | &__text { 8 | flex: 1 1 auto; 9 | overflow: hidden; 10 | } 11 | 12 | &__delete.ui__button.ui__iconButton { 13 | height: 28px; 14 | width: 28px; 15 | min-height: 28px; 16 | min-width: 28px; 17 | margin-left: $padding-tiny; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/FilePicker/__snapshots__/FilePicker.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`FilePicker component snapshot renders default filePicker 1`] = ` 4 | <div 5 | className="ui__base ui__filePicker" 6 | /> 7 | `; 8 | -------------------------------------------------------------------------------- /src/components/FilePicker/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FilePicker'; 2 | -------------------------------------------------------------------------------- /src/components/FilePlaceholder/FilePlaceholder.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FilePlaceholder } from '../FilePlaceholder'; 3 | import readme from './README.md'; 4 | import { text } from '@storybook/addon-knobs'; 5 | 6 | export default { title: 'Components/FilePlaceholder', component: FilePlaceholder, parameters: { readme } }; 7 | 8 | export const Basic = () => <FilePlaceholder extension={text('extension', 'pdf')} />; 9 | -------------------------------------------------------------------------------- /src/components/FilePlaceholder/FilePlaceholder.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { FilePlaceholder } from '../FilePlaceholder'; 4 | 5 | describe('FilePlaceholder component', () => { 6 | it('renders its contents', () => { 7 | const filePlaceholder = shallow(<FilePlaceholder />); 8 | expect(filePlaceholder.find('.ui__filePlaceholder')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default filePlaceholder', () => { 12 | const filePlaceholder = shallow(<FilePlaceholder />); 13 | expect(filePlaceholder).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/FilePlaceholder/FilePlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { FC } from 'react'; 3 | 4 | export interface FilePlaceholderProps { 5 | /** 6 | * Classname of the placeholder wrapper. 7 | */ 8 | className?: string; 9 | /** 10 | * The file extension to display on the placeholder. 11 | */ 12 | extension?: string; 13 | } 14 | 15 | export const FilePlaceholder: FC<FilePlaceholderProps> = ({ className, extension }) => { 16 | const filePlaceholderClass = classnames('ui__base ui__filePlaceholder', className); 17 | 18 | const formattedExtension = extension && `.${extension.replace(/^\./, '')}`; 19 | 20 | return ( 21 | <div className={filePlaceholderClass}> 22 | <div className="ui__filePlaceholder__block ui__filePlaceholder__block--thumbnail" /> 23 | <div className="ui__filePlaceholder__block ui__filePlaceholder__block--line-sm" /> 24 | <div className="ui__filePlaceholder__block ui__filePlaceholder__block--line-xs" /> 25 | <div className="ui__filePlaceholder__block ui__filePlaceholder__block--line-df" /> 26 | <div className="ui__filePlaceholder__block ui__filePlaceholder__block--line-lgx" /> 27 | <div className="ui__filePlaceholder__block ui__filePlaceholder__block--line-lg" /> 28 | <div className="ui__filePlaceholder__block ui__filePlaceholder__block--line-df" /> 29 | 30 | {formattedExtension ? <div className="ui__filePlaceholder__extension">{formattedExtension}</div> : undefined} 31 | </div> 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/FilePlaceholder/README.md: -------------------------------------------------------------------------------- 1 | Placeholder for files. You can include an extension to add it to the bottom of 2 | the placeholder. These should be used when you are unable to generate thumbnails 3 | for a given file, for example an unsupported file type. 4 | -------------------------------------------------------------------------------- /src/components/FilePlaceholder/_FilePlaceholder.scss: -------------------------------------------------------------------------------- 1 | .ui__filePlaceholder { 2 | background-color: white; 3 | padding: 10px; 4 | height: 164px; 5 | width: 128px; 6 | position: relative; 7 | 8 | &__block { 9 | background-color: #ccc; 10 | 11 | &--thumbnail { 12 | width: 40px; 13 | height: 40px; 14 | float: left; 15 | } 16 | 17 | &--line-sm { 18 | margin-left: 50px; 19 | margin-top: 10px; 20 | height: 6px; 21 | } 22 | 23 | &--line-xs { 24 | margin-left: 50px; 25 | margin-top: 10px; 26 | width: 40px; 27 | height: 6px; 28 | } 29 | 30 | &--line-df { 31 | margin-top: 20px; 32 | width: 90px; 33 | height: 6px; 34 | } 35 | 36 | &--line-lg { 37 | margin-top: 10px; 38 | width: 90px; 39 | height: 6px; 40 | } 41 | 42 | &--line-lgx { 43 | margin-top: 10px; 44 | height: 6px; 45 | } 46 | } 47 | 48 | &__extension { 49 | position: absolute; 50 | color: #ccc; 51 | text-align: center; 52 | font-size: $font-size-large; 53 | right: 10px; 54 | left: 10px; 55 | bottom: 10px; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/FilePlaceholder/__snapshots__/FilePlaceholder.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`FilePlaceholder component snapshot renders default filePlaceholder 1`] = ` 4 | <div 5 | className="ui__base ui__filePlaceholder" 6 | > 7 | <div 8 | className="ui__filePlaceholder__block ui__filePlaceholder__block--thumbnail" 9 | /> 10 | <div 11 | className="ui__filePlaceholder__block ui__filePlaceholder__block--line-sm" 12 | /> 13 | <div 14 | className="ui__filePlaceholder__block ui__filePlaceholder__block--line-xs" 15 | /> 16 | <div 17 | className="ui__filePlaceholder__block ui__filePlaceholder__block--line-df" 18 | /> 19 | <div 20 | className="ui__filePlaceholder__block ui__filePlaceholder__block--line-lgx" 21 | /> 22 | <div 23 | className="ui__filePlaceholder__block ui__filePlaceholder__block--line-lg" 24 | /> 25 | <div 26 | className="ui__filePlaceholder__block ui__filePlaceholder__block--line-df" 27 | /> 28 | </div> 29 | `; 30 | -------------------------------------------------------------------------------- /src/components/FilePlaceholder/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FilePlaceholder'; 2 | -------------------------------------------------------------------------------- /src/components/FileSkeleton/FileSkeleton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FileSkeleton } from '../FileSkeleton'; 3 | import readme from './README.md'; 4 | 5 | export default { title: 'Components/FileSkeleton', component: FileSkeleton, parameters: { readme } }; 6 | 7 | export const Basic = () => <FileSkeleton />; 8 | -------------------------------------------------------------------------------- /src/components/FileSkeleton/FileSkeleton.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { FileSkeleton } from '../FileSkeleton'; 4 | 5 | describe('FileSkeleton component', () => { 6 | it('renders its contents', () => { 7 | const fileSkeleton = shallow(<FileSkeleton />); 8 | expect(fileSkeleton.find('.ui__fileSkeleton')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default fileSkeleton', () => { 12 | const fileSkeleton = shallow(<FileSkeleton />); 13 | expect(fileSkeleton).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/FileSkeleton/FileSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | 4 | export interface FileSkeletonProps { 5 | /** 6 | * Classname of the skeleton wrapper. 7 | */ 8 | className?: string; 9 | } 10 | 11 | export const FileSkeleton = ({ className }: FileSkeletonProps) => { 12 | const fileSkeletonClass = classnames('ui__base ui__fileSkeleton', className); 13 | 14 | return ( 15 | <div className={fileSkeletonClass}> 16 | <div className="ui__fileSkeleton__block ui__fileSkeleton__block--thumbnail" /> 17 | <div className="ui__fileSkeleton__block ui__fileSkeleton__block--line-sm" /> 18 | <div className="ui__fileSkeleton__block ui__fileSkeleton__block--line-xs" /> 19 | <div className="ui__fileSkeleton__block ui__fileSkeleton__block--line-df" /> 20 | <div className="ui__fileSkeleton__block ui__fileSkeleton__block--line-lgx" /> 21 | <div className="ui__fileSkeleton__block ui__fileSkeleton__block--line-lg" /> 22 | <div className="ui__fileSkeleton__block ui__fileSkeleton__block--line-df" /> 23 | </div> 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/FileSkeleton/README.md: -------------------------------------------------------------------------------- 1 | Skeleton loading placeholder for files or pages. 2 | -------------------------------------------------------------------------------- /src/components/FileSkeleton/_FileSkeleton.scss: -------------------------------------------------------------------------------- 1 | .ui__fileSkeleton { 2 | background-color: white; 3 | padding: 10px; 4 | height: 164px; 5 | width: 128px; 6 | 7 | &__block { 8 | @include skeleton; 9 | 10 | &--thumbnail { 11 | width: 40px; 12 | height: 40px; 13 | float: left; 14 | } 15 | 16 | &--line-sm { 17 | margin-left: 50px; 18 | margin-top: 10px; 19 | height: 6px; 20 | } 21 | 22 | &--line-xs { 23 | margin-left: 50px; 24 | margin-top: 10px; 25 | width: 40px; 26 | height: 6px; 27 | } 28 | 29 | &--line-df { 30 | margin-top: 20px; 31 | width: 90px; 32 | height: 6px; 33 | } 34 | 35 | &--line-lg { 36 | margin-top: 10px; 37 | width: 90px; 38 | height: 6px; 39 | } 40 | 41 | &--line-lgx { 42 | margin-top: 10px; 43 | height: 6px; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/FileSkeleton/__snapshots__/FileSkeleton.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`FileSkeleton component snapshot renders default fileSkeleton 1`] = ` 4 | <div 5 | className="ui__base ui__fileSkeleton" 6 | > 7 | <div 8 | className="ui__fileSkeleton__block ui__fileSkeleton__block--thumbnail" 9 | /> 10 | <div 11 | className="ui__fileSkeleton__block ui__fileSkeleton__block--line-sm" 12 | /> 13 | <div 14 | className="ui__fileSkeleton__block ui__fileSkeleton__block--line-xs" 15 | /> 16 | <div 17 | className="ui__fileSkeleton__block ui__fileSkeleton__block--line-df" 18 | /> 19 | <div 20 | className="ui__fileSkeleton__block ui__fileSkeleton__block--line-lgx" 21 | /> 22 | <div 23 | className="ui__fileSkeleton__block ui__fileSkeleton__block--line-lg" 24 | /> 25 | <div 26 | className="ui__fileSkeleton__block ui__fileSkeleton__block--line-df" 27 | /> 28 | </div> 29 | `; 30 | -------------------------------------------------------------------------------- /src/components/FileSkeleton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FileSkeleton'; 2 | -------------------------------------------------------------------------------- /src/components/FocusTrap/FocusTrap.stories.scss: -------------------------------------------------------------------------------- 1 | .App { 2 | font-family: sans-serif; 3 | text-align: center; 4 | 5 | &__lockzone { 6 | border: 1px solid gray; 7 | background-color: lightgray; 8 | padding: 16px; 9 | margin-top: 16px; 10 | } 11 | 12 | &__lockzone--locked { 13 | background-color: lightblue; 14 | } 15 | 16 | input { 17 | margin-right: 8px; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/FocusTrap/FocusTrap.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean } from '@storybook/addon-knobs'; 2 | import React, { useState } from 'react'; 3 | import { useFocusTrap } from '../../hooks'; 4 | import { FocusTrap } from '../FocusTrap'; 5 | import readme from './README.md'; 6 | 7 | export default { title: 'Components/FocusTrap', component: FocusTrap, parameters: { readme } }; 8 | 9 | export const Basic = () => { 10 | const [showLock, setShowLock] = useState(false); 11 | return ( 12 | <div className="App"> 13 | <input /> 14 | <button onClick={() => setShowLock(true)}>Lock from outside</button> 15 | <FocusTrap locked={showLock} focusLastOnUnlock={boolean('focusLastOnUnlock', false)}> 16 | <div className={showLock ? 'App__lockzone App__lockzone--locked' : 'App__lockzone'}> 17 | <p>Zone is {showLock ? 'locked' : 'unlocked'}</p> 18 | <input /> 19 | <button onClick={() => setShowLock(prev => !prev)}>{showLock ? 'Unlock' : 'Lock from inside'}</button> 20 | </div> 21 | </FocusTrap> 22 | </div> 23 | ); 24 | }; 25 | 26 | export const JustUseFocusTrapHook = () => { 27 | const [showLock, setShowLock] = useState(false); 28 | const focusRef = useFocusTrap<HTMLDivElement>(showLock, { focusLastOnUnlock: boolean('focusLastOnUnlock', false) }); 29 | return ( 30 | <div className="App"> 31 | <input /> 32 | <button onClick={() => setShowLock(true)}>Lock from outside</button> 33 | <div ref={focusRef} className={showLock ? 'App__lockzone App__lockzone--locked' : 'App__lockzone'}> 34 | <p>Zone is {showLock ? 'locked' : 'unlocked'}</p> 35 | <input /> 36 | <button onClick={() => setShowLock(prev => !prev)}>{showLock ? 'Unlock' : 'Lock from inside'}</button> 37 | </div> 38 | </div> 39 | ); 40 | }; 41 | 42 | JustUseFocusTrapHook.story = { name: 'Just useFocusTrap Hook' }; 43 | -------------------------------------------------------------------------------- /src/components/FocusTrap/FocusTrap.tsx: -------------------------------------------------------------------------------- 1 | import { cloneElement, FC, ReactElement, RefAttributes } from 'react'; 2 | import { useFocusTrap, UseFocusTrapOptions } from '../../hooks'; 3 | 4 | export interface FocusLockProps extends UseFocusTrapOptions { 5 | /** 6 | * When true, focus will be locked within the child element. 7 | */ 8 | locked?: boolean; 9 | /** 10 | * A child with a targetable ref to lock focus on. This component must accept 11 | * the `ref` prop in order to work with `FocusTrap`. 12 | */ 13 | children: ReactElement<RefAttributes<HTMLElement>>; 14 | } 15 | 16 | export const FocusTrap: FC<FocusLockProps> = ({ locked, focusLastOnUnlock, children }) => { 17 | const focusRef = useFocusTrap(locked, { focusLastOnUnlock }); 18 | 19 | return cloneElement(children, { ref: focusRef }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/FocusTrap/README.md: -------------------------------------------------------------------------------- 1 | Traps the focus within the child component when `locked` is true. This wraps the 2 | hook `useFocusTrap`, so you can just use that if you'd prefer a hook. 3 | 4 | <!-- 5 | ## Using `FocusTrap` 6 | 7 | > Make sure that only a single, non-conditional child is passed to `FocusTrap`, 8 | > and that the child is able to have a `ref` passed to it. 9 | 10 | ```jsx 11 | return ( 12 | <FocusTrap locked={locked} focusLastOnUnlock={focusLastOnUnlock}> 13 | <div> 14 | <input /> 15 | <button>Some button</button> 16 | </div> 17 | </FocusTrap> 18 | ); 19 | ``` 20 | 21 | ## Using `useFocusTrap` 22 | 23 | ```jsx 24 | const focusRef = useFocusTrap(locked, { focusLastOnUnlock }); 25 | 26 | return ( 27 | <div ref={focusRef}> 28 | <input /> 29 | <button>Some button</button> 30 | </div> 31 | ); 32 | ``` 33 | --> 34 | -------------------------------------------------------------------------------- /src/components/FocusTrap/_FocusTrap.scss: -------------------------------------------------------------------------------- 1 | .ui__focusTrap { 2 | color: blue; 3 | 4 | &--disabled { 5 | color: red; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/components/FocusTrap/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FocusTrap'; 2 | -------------------------------------------------------------------------------- /src/components/Icon/Icon.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { selectIcon } from '../../storybook-helpers/knobs/selectIcon'; 3 | import { Icon } from '../Icon'; 4 | import readme from './README.md'; 5 | 6 | export default { title: 'Components/Icon', component: Icon, parameters: { readme } }; 7 | 8 | export const Basic = () => <Icon icon={selectIcon('icon', 'Close')} />; 9 | -------------------------------------------------------------------------------- /src/components/Icon/Icon.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { Icon } from '../Icon'; 4 | 5 | describe('Icon component', () => { 6 | it('renders its contents', () => { 7 | const icon = shallow(<Icon icon="Close" />); 8 | expect(icon.find('.ui__icon')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default icon', () => { 12 | const icon = shallow(<Icon icon="Close" />); 13 | expect(icon).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { forwardRef, HTMLAttributes, ReactNode, SVGProps, useMemo } from 'react'; 3 | import * as icons from './../../icons'; 4 | 5 | export type AvailableIcons = keyof typeof icons; 6 | 7 | export interface IconProps extends HTMLAttributes<HTMLElement> { 8 | /** 9 | * Specify one of the included icons from the toolkit. If provided, do not add 10 | * `children` or they will override this. 11 | */ 12 | icon?: AvailableIcons; 13 | /** 14 | * Props that will be passed to the included icons. This will not be used if 15 | * `children` is provided. 16 | */ 17 | svgProps?: SVGProps<SVGSVGElement>; 18 | /** 19 | * Provide a custom child instead of a provided icon. Will override `icon` if 20 | * provided. 21 | */ 22 | children?: ReactNode; 23 | } 24 | 25 | export const Icon = forwardRef<HTMLButtonElement, IconProps>( 26 | ({ icon, svgProps, className, children, ...props }, ref) => { 27 | const iconClass = classnames('ui__base ui__icon', className); 28 | 29 | const child = useMemo(() => { 30 | if (children !== undefined) return children; 31 | if (icons === undefined) return undefined; 32 | const IconChild = icons[icon!]; 33 | return <IconChild {...svgProps} />; 34 | }, [children, icon, svgProps]); 35 | 36 | return ( 37 | <i {...props} className={iconClass} ref={ref}> 38 | {child} 39 | </i> 40 | ); 41 | }, 42 | ); 43 | -------------------------------------------------------------------------------- /src/components/Icon/README.md: -------------------------------------------------------------------------------- 1 | Icon allows you to specify one of the included icons using the `icon` prop, or 2 | add your own custom child icon. 3 | -------------------------------------------------------------------------------- /src/components/Icon/_Icon.scss: -------------------------------------------------------------------------------- 1 | .ui__icon { 2 | display: inline-flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Icon/__snapshots__/Icon.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Icon component snapshot renders default icon 1`] = ` 4 | <i 5 | className="ui__base ui__icon" 6 | > 7 | <Close /> 8 | </i> 9 | `; 10 | -------------------------------------------------------------------------------- /src/components/Icon/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Icon'; 2 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { action } from '../../storybook-helpers/action/action'; 4 | import { Icon } from '../Icon/Icon'; 5 | import { IconButton } from '../IconButton'; 6 | import readme from './README.md'; 7 | 8 | export default { title: 'Components/IconButton', component: IconButton, parameters: { readme } }; 9 | 10 | export const Basic = () => ( 11 | <IconButton disabled={boolean('disabled', false)} onClick={action('onClick')}> 12 | <Icon icon="Close" /> 13 | </IconButton> 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { mount, shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { spy } from 'sinon'; 4 | import { IconButton } from '../IconButton'; 5 | 6 | describe('IconButton component', () => { 7 | it('renders its contents', () => { 8 | const iconButton = shallow(<IconButton />); 9 | expect(iconButton.find('.ui__iconButton')).toHaveLength(1); 10 | }); 11 | 12 | it('snapshot renders default iconButton', () => { 13 | const iconButton = shallow(<IconButton />); 14 | expect(iconButton).toMatchSnapshot(); 15 | }); 16 | 17 | it('clicking iconButton triggers onClick prop', () => { 18 | const onClick = spy(); 19 | shallow(<IconButton onClick={onClick} />).simulate('click'); 20 | expect(onClick.callCount).toBe(1); 21 | }); 22 | 23 | it('clicking disabled iconButton does not trigger onClick prop', () => { 24 | const onClick = spy(); 25 | // full DOM mount so `iconButton` element will use disabled prop 26 | mount(<IconButton onClick={onClick} disabled />).simulate('click'); 27 | expect(onClick.callCount).toBe(0); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { ButtonHTMLAttributes, forwardRef } from 'react'; 3 | import { Button } from '../Button'; 4 | 5 | export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { 6 | /** 7 | * Defaults to 'button' instead of 'submit' to prevent accidental submissions. 8 | * @default "button" 9 | */ 10 | type?: ButtonHTMLAttributes<HTMLButtonElement>['type']; 11 | } 12 | 13 | export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(({ children, className, ...props }, ref) => { 14 | const iconButtonClass = classnames('ui__base ui__iconButton', className); 15 | 16 | return ( 17 | <Button {...props} className={iconButtonClass} buttonStyle="borderless" ref={ref}> 18 | {children} 19 | </Button> 20 | ); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/IconButton/README.md: -------------------------------------------------------------------------------- 1 | A wrapper for `Button` with custom styles to optimize it for icons. 2 | -------------------------------------------------------------------------------- /src/components/IconButton/_IconButton.scss: -------------------------------------------------------------------------------- 1 | .ui__button.ui__iconButton { 2 | width: auto; 3 | min-width: auto; 4 | padding: 0 3px; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/IconButton/__snapshots__/IconButton.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`IconButton component snapshot renders default iconButton 1`] = ` 4 | <ForwardRef 5 | buttonStyle="borderless" 6 | className="ui__base ui__iconButton" 7 | /> 8 | `; 9 | -------------------------------------------------------------------------------- /src/components/IconButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IconButton'; 2 | -------------------------------------------------------------------------------- /src/components/Image/Image.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react'; 2 | import { Image } from '../Image'; 3 | import { Spinner } from '../Spinner'; 4 | import readme from './README.md'; 5 | import { boolean } from '@storybook/addon-knobs'; 6 | 7 | export default { title: 'Components/Image', component: Image, parameters: { readme } }; 8 | 9 | const style: CSSProperties = { 10 | height: 250, 11 | width: 250, 12 | display: 'flex', 13 | justifyContent: 'center', 14 | alignItems: 'center', 15 | backgroundColor: 'lightgray', 16 | }; 17 | 18 | const IMAGE = 19 | 'https://www.webfx.com/blog/images/cdn.designinstruct.com/files/582-how-to-image-placeholders/generic-image-placeholder.png'; 20 | 21 | export const Basic = () => ( 22 | <div style={style}> 23 | <Image src={IMAGE} onRenderLoading={() => <Spinner />} pending={boolean('pending', false)} /> 24 | </div> 25 | ); 26 | 27 | export const WithSrcPromise = () => ( 28 | <div style={style}> 29 | <Image src={new Promise(res => setTimeout(() => res(IMAGE), 500))} onRenderLoading={() => <Spinner />} /> 30 | </div> 31 | ); 32 | 33 | export const SrcPromiseRejects = () => ( 34 | <div style={style}> 35 | <Image 36 | src={new Promise(rej => setTimeout(() => rej(), 500))} 37 | onRenderLoading={() => <Spinner />} 38 | onRenderFallback={() => 'Rejected source promise'} 39 | pending={boolean('pending', false)} 40 | /> 41 | </div> 42 | ); 43 | 44 | export const SrcPromiseReturnsFalsy = () => ( 45 | <div style={style}> 46 | <Image 47 | src={new Promise(res => setTimeout(() => res(''), 500))} 48 | onRenderLoading={() => <Spinner />} 49 | onRenderFallback={() => 'Falsy source'} 50 | pending={boolean('pending', false)} 51 | /> 52 | </div> 53 | ); 54 | -------------------------------------------------------------------------------- /src/components/Image/Image.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { Image } from '../Image'; 4 | 5 | describe('Image component', () => { 6 | it('snapshot renders default image', () => { 7 | const image = shallow(<Image />); 8 | expect(image).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { forwardRef, ImgHTMLAttributes, ReactNode, useCallback, useEffect, useState } from 'react'; 3 | import { FuturableOrLazy, futureableOrLazyToFuturable } from '../../data'; 4 | import { Remove } from '../../utils'; 5 | 6 | export interface ImageProps extends Remove<ImgHTMLAttributes<HTMLImageElement>, 'src'> { 7 | /** 8 | * The image source can be a `Futurable` or `LazyFuturable`, or undefined. If 9 | * undefined or if a promise will display as loading. 10 | */ 11 | src?: FuturableOrLazy<string | undefined>; 12 | /** 13 | * Manually set whether image should show loading state. 14 | */ 15 | pending?: boolean; 16 | /** 17 | * Render out an element to be shown while src is loading. 18 | */ 19 | onRenderLoading?(): ReactNode; 20 | /** 21 | * Render out an element to be shown if the image fails to load src, or src 22 | * is falsy. 23 | */ 24 | onRenderFallback?(): ReactNode; 25 | } 26 | 27 | export const Image = forwardRef<HTMLImageElement, ImageProps>( 28 | ({ src, pending, onRenderLoading, onRenderFallback, alt, className, ...imgProps }, ref) => { 29 | const sourceIsNotPromise = typeof src === 'string' || !src; 30 | const [loading, setLoading] = useState(!sourceIsNotPromise); 31 | const [source, setSource] = useState<string | undefined>( 32 | sourceIsNotPromise ? (src as string | undefined) : undefined, 33 | ); 34 | 35 | const getSource = useCallback(async (srcGetter: FuturableOrLazy<string | undefined>) => { 36 | setLoading(true); 37 | let fetchedSource = undefined; 38 | try { 39 | fetchedSource = await futureableOrLazyToFuturable(srcGetter); 40 | } catch {} 41 | setLoading(false); 42 | setSource(fetchedSource || undefined); 43 | }, []); 44 | 45 | useEffect(() => { 46 | if (sourceIsNotPromise) { 47 | setLoading(false); 48 | setSource((src as string | undefined) || undefined); 49 | return; 50 | } 51 | getSource(src); 52 | }, [getSource, sourceIsNotPromise, src]); 53 | 54 | const imageClass = classnames('ui__image', className); 55 | 56 | if (loading || pending) return <>{onRenderLoading?.()}</>; 57 | if (!source) return <>{onRenderFallback?.()}</>; 58 | return <img {...imgProps} alt={alt} src={source} className={imageClass} ref={ref} />; 59 | }, 60 | ); 61 | -------------------------------------------------------------------------------- /src/components/Image/README.md: -------------------------------------------------------------------------------- 1 | This is an image that handles multiple `src` parameters. Specifically, it can 2 | accept promises, as well as functions that return the `src` string or a promise 3 | for the string. If you want to throttle the fetching of the image, you can do so 4 | by giving a function for `src`. You can provide a loading placeholder using the 5 | `onRenderLoading` prop. 6 | -------------------------------------------------------------------------------- /src/components/Image/_Image.scss: -------------------------------------------------------------------------------- 1 | .ui__image { 2 | max-width: 100%; 3 | max-height: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Image/__snapshots__/Image.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Image component snapshot renders default image 1`] = `<Fragment />`; 4 | -------------------------------------------------------------------------------- /src/components/Image/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Image'; 2 | -------------------------------------------------------------------------------- /src/components/Input/Input.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { Input } from '../Input'; 4 | 5 | describe('Input component', () => { 6 | it('renders its contents', () => { 7 | const input = shallow(<Input />); 8 | expect(input.find('.ui__input')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default input', () => { 12 | const input = shallow(<Input />); 13 | expect(input).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Input/README.md: -------------------------------------------------------------------------------- 1 | A simple styled input with the ability to show error or warning feedback. 2 | -------------------------------------------------------------------------------- /src/components/Input/_Input.scss: -------------------------------------------------------------------------------- 1 | .ui__input__wrapper { 2 | width: 220px; 3 | 4 | &--fill { 5 | width: 100%; 6 | } 7 | 8 | &--pad { 9 | padding-bottom: 20px; 10 | } 11 | } 12 | 13 | .ui__input { 14 | border-radius: $border-radius; 15 | transition: $focus-transition; 16 | background-color: $color-gray-1; 17 | border: 1px solid $color-gray-4; 18 | display: inline-flex; 19 | align-items: center; 20 | width: 100%; 21 | 22 | &--message { 23 | &-default { 24 | &.ui__input--focused { 25 | border: 1px solid $color-theme-primary; 26 | } 27 | 28 | &.ui__input--focused { 29 | @include focus; 30 | } 31 | } 32 | 33 | &-warning { 34 | border-color: $color-message-warning; 35 | 36 | i { 37 | fill: $color-message-warning; 38 | } 39 | 40 | &.ui__input--focused { 41 | box-shadow: 0 0 0 2px var(--color-message-warning-focus-shadow); 42 | } 43 | } 44 | 45 | &-error { 46 | border-color: $color-message-error; 47 | 48 | i { 49 | fill: $color-message-error; 50 | } 51 | 52 | &.ui__input--focused { 53 | box-shadow: 0 0 0 2px var(--color-message-error-focus-shadow); 54 | } 55 | } 56 | } 57 | 58 | .ui__input__input { 59 | flex: 1 1 auto; 60 | padding: $padding-tiny; 61 | height: 28px; 62 | outline: none; 63 | border: none; 64 | background-color: transparent; 65 | color: $color-font-primary; 66 | min-height: 24px; 67 | 68 | &--disabled { 69 | opacity: 0.7; 70 | } 71 | } 72 | 73 | &__messageText { 74 | width: 100%; 75 | font-size: $font-size-small; 76 | margin-top: $padding-tiny; 77 | } 78 | 79 | // Override any icon buttons so they have no padding. 80 | .ui__iconButton { 81 | border: none; 82 | padding: 0; 83 | min-height: 0; 84 | } 85 | 86 | // Override any icons so that they have an internal padding. 87 | .ui__icon { 88 | padding: 0 2px; 89 | } 90 | 91 | svg { 92 | fill: $color-blue-gray-5; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/components/Input/__snapshots__/Input.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Input component snapshot renders default input 1`] = ` 4 | <div 5 | className="ui__base ui__input__wrapper" 6 | > 7 | <div 8 | className="ui__input ui__input--message-default" 9 | > 10 | <input 11 | className="ui__input__input" 12 | onBlur={[Function]} 13 | onFocus={[Function]} 14 | type="text" 15 | /> 16 | </div> 17 | </div> 18 | `; 19 | -------------------------------------------------------------------------------- /src/components/Input/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Input'; 2 | -------------------------------------------------------------------------------- /src/components/Label/Label.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean, text } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { Input } from '../Input'; 4 | import { Label } from '../Label'; 5 | import readme from './README.md'; 6 | 7 | export default { title: 'Components/Label', component: Label, parameters: { readme } }; 8 | 9 | export const Basic = () => ( 10 | <Label 11 | label={text('label', 'Labeled input field')} 12 | optionalText={boolean('has optionalText?', false) ? '- optional' : undefined} 13 | > 14 | <Input /> 15 | </Label> 16 | ); 17 | 18 | export const WithCustomId = () => ( 19 | <Label 20 | label={text('label', 'Labeled input field')} 21 | optionalText={boolean('has optionalText?', false) ? '- optional' : undefined} 22 | > 23 | <Input id="custom_input_id" /> 24 | </Label> 25 | ); 26 | 27 | export const WithDisabledFormElement = () => ( 28 | <Label 29 | label={text('label', 'Labeled input field')} 30 | optionalText={boolean('has optionalText?', false) ? '- optional' : undefined} 31 | > 32 | <Input id="custom_input_id" disabled={boolean('disabled', false)} /> 33 | </Label> 34 | ); 35 | 36 | export const Detached = () => ( 37 | <> 38 | <Label 39 | htmlFor="custom_input_id" 40 | label={text('label', 'Labeled input field')} 41 | optionalText={boolean('has optionalText?', false) ? '- optional' : undefined} 42 | /> 43 | <p>Some other stuff</p> 44 | <Input id="custom_input_id" disabled={boolean('disabled', false)} /> 45 | </> 46 | ); 47 | -------------------------------------------------------------------------------- /src/components/Label/Label.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { Label } from '../Label'; 4 | 5 | describe('Label component', () => { 6 | it('renders its contents', () => { 7 | const label = shallow(<Label label="" />); 8 | expect(label.find('.ui__label')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default label', () => { 12 | const label = shallow(<Label label="" />); 13 | expect(label).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Label/Label.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { cloneElement, FC, LabelHTMLAttributes, ReactElement, ReactNode } from 'react'; 3 | import { useID } from '../../hooks/useID'; 4 | 5 | export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> { 6 | /** 7 | * The label to apply to any form element. 8 | */ 9 | label: ReactNode; 10 | /** 11 | * Provide a string specifying that this field is optional. 12 | */ 13 | optionalText?: string; 14 | /** 15 | * Pass a child element which can accept an `id` prop. If you don't specify 16 | * the `id` prop, one will be generated to link the label to the element. If 17 | * you wish to link this label to a form field without passing children, you 18 | * should specify the `htmlFor` prop with the `id` of your form field. 19 | */ 20 | children?: ReactElement; 21 | } 22 | 23 | export const Label: FC<LabelProps> = ({ label, optionalText, children, className, htmlFor, ...props }) => { 24 | const childrenId = children?.props.id; 25 | const id = useID(childrenId); 26 | 27 | const labelClass = classnames( 28 | 'ui__base ui__label', 29 | { 'ui__label--disabled': children?.props.disabled, 'ui__label--attached': children }, 30 | className, 31 | ); 32 | 33 | return ( 34 | <> 35 | <label {...props} className={labelClass} htmlFor={htmlFor ?? id}> 36 | {label} 37 | {optionalText ? <span className="ui__label__optional">{optionalText}</span> : undefined} 38 | </label> 39 | {children ? cloneElement(children, { id }) : undefined} 40 | </> 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/Label/README.md: -------------------------------------------------------------------------------- 1 | A generic label for labelling form fields. If the child form field has an `id` 2 | specified, that will be automatically parsed and used in the label's `htmlFor` 3 | attribute to link the two. If no `id` is specified for the child, one will be 4 | automatically generated. 5 | 6 | You can also link the label to a form field without passing it as a child by 7 | giving the field an `id` and passing the same `id` to the `htmlFor` prop for the 8 | input. This is useful if the two elements do not sit near each other on the 9 | page. 10 | 11 | If the child element is disabled, the label will automatically change appearance 12 | to a disabled state. This will not happen if the form field is not the child. 13 | -------------------------------------------------------------------------------- /src/components/Label/_Label.scss: -------------------------------------------------------------------------------- 1 | .ui__label { 2 | &--attached::after { 3 | height: 2px; 4 | width: 1px; 5 | content: ''; 6 | display: block; 7 | } 8 | 9 | &--disabled { 10 | opacity: 0.7; 11 | } 12 | 13 | &__optional { 14 | font-size: $font-size-small; 15 | margin-left: $padding-tiny; 16 | color: $color-font-secondary; 17 | font-style: italic; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Label/__snapshots__/Label.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Label component snapshot renders default label 1`] = ` 4 | <Fragment> 5 | <label 6 | className="ui__base ui__label" 7 | htmlFor="label_1" 8 | /> 9 | </Fragment> 10 | `; 11 | -------------------------------------------------------------------------------- /src/components/Label/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Label'; 2 | -------------------------------------------------------------------------------- /src/components/Modal/Modal.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { Modal } from '../Modal'; 4 | 5 | describe('Modal component', () => { 6 | it('renders its contents when open', () => { 7 | const modal = shallow( 8 | <Modal heading="" open> 9 | children 10 | </Modal>, 11 | ); 12 | expect(modal.find('.ui__modal')).toHaveLength(1); 13 | }); 14 | 15 | it('hides its contents when closed', () => { 16 | const modal = shallow(<Modal heading="">children</Modal>); 17 | expect(modal.find('.ui__modal')).toHaveLength(0); 18 | }); 19 | 20 | it('snapshot renders default modal', () => { 21 | const modal = shallow(<Modal heading="">children</Modal>); 22 | expect(modal).toMatchSnapshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/Modal/README.md: -------------------------------------------------------------------------------- 1 | Modals provide the ability to add confirmation or display important information 2 | to the user. These can be easily dismissed by adding an `onClose`, as well as 3 | `closeOnBackgroundClick` and `closeOnEscape` at your discretion. 4 | 5 | Alternatively, they can enforce that the user completes the required action by 6 | removing the `onClose` and adding your own confirmation buttons to the 7 | `buttonGroup`. 8 | -------------------------------------------------------------------------------- /src/components/Modal/_Modal.scss: -------------------------------------------------------------------------------- 1 | .ui__modal__wrapper { 2 | @include absolute-fill; 3 | @include opacity-transition; 4 | opacity: 1; 5 | background-color: $color-overlay-canvas; 6 | overflow: auto; 7 | display: flex; 8 | 9 | padding: $padding $padding 0; 10 | 11 | @include for-tablet-up { 12 | padding: $padding-huge $padding-huge 0; 13 | } 14 | 15 | &--closed { 16 | opacity: 0; 17 | pointer-events: none !important; 18 | } 19 | 20 | &--fullWidth { 21 | .ui__modal__paddingFix { 22 | max-width: none !important; 23 | } 24 | } 25 | } 26 | 27 | // This fixes the firefox bottom padding bug. 28 | .ui__modal__paddingFix { 29 | padding: 0 0 $padding; 30 | margin: auto; 31 | width: 100%; 32 | 33 | @include for-tablet-up { 34 | padding: 0 0 $padding-huge; 35 | max-width: 500px; 36 | } 37 | } 38 | 39 | .ui__modal { 40 | background-color: $color-gray-2; 41 | border-radius: $border-radius; 42 | overflow: hidden; 43 | width: 100%; 44 | 45 | &--hidden { 46 | pointer-events: none; 47 | visibility: hidden; 48 | } 49 | 50 | &__top { 51 | display: flex; 52 | 53 | &__heading { 54 | font-size: $font-size-large; 55 | flex: 1 1 auto; 56 | padding: $padding-medium $padding; 57 | } 58 | 59 | &__close.ui__button { 60 | padding: 0 calc(#{$padding-medium} - 2px); 61 | margin: 2px; 62 | } 63 | } 64 | 65 | &__body { 66 | padding: $padding-medium $padding; 67 | background-color: $color-gray-1; 68 | border-top: 1px solid $color-gray-4; 69 | border-bottom: 1px solid $color-gray-4; 70 | 71 | &--noButton { 72 | border-bottom: none; 73 | } 74 | } 75 | 76 | &__buttonGroup { 77 | padding: $padding-medium $padding; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/components/Modal/__snapshots__/Modal.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Modal component snapshot renders default modal 1`] = ` 4 | <Component> 5 | <div 6 | className="ui__base ui__modal__wrapper ui__modal__wrapper--closed" 7 | onClick={[Function]} 8 | /> 9 | </Component> 10 | `; 11 | -------------------------------------------------------------------------------- /src/components/Modal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Modal'; 2 | -------------------------------------------------------------------------------- /src/components/Overlay/Overlay.scss: -------------------------------------------------------------------------------- 1 | .ui__overlay { 2 | @include fixed-fill; 3 | z-index: $z-index-overlay; 4 | pointer-events: none; 5 | 6 | > * { 7 | pointer-events: all; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Overlay/Overlay.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean } from '@storybook/addon-knobs'; 2 | import React, { CSSProperties } from 'react'; 3 | import { Overlay } from '../Overlay'; 4 | import readme from './README.md'; 5 | 6 | export default { title: 'Components/Overlay', component: Overlay, parameters: { readme } }; 7 | 8 | const style: CSSProperties = { 9 | backgroundColor: 'lightblue', 10 | color: 'black', 11 | margin: 32, 12 | padding: 32, 13 | textAlign: 'center', 14 | }; 15 | 16 | export const Basic = () => { 17 | const mounted = boolean('mounted', true); 18 | return ( 19 | <> 20 | {mounted && ( 21 | <Overlay> 22 | <div style={style}>Overlay #1</div> 23 | </Overlay> 24 | )} 25 | </> 26 | ); 27 | }; 28 | 29 | export const Multiple = () => { 30 | const mounted1 = boolean('#1 mounted', true); 31 | 32 | const mounted2 = boolean('#2 mounted', true); 33 | 34 | return ( 35 | <> 36 | {mounted1 ? ( 37 | <Overlay> 38 | <div style={style}>Overlay #1</div> 39 | </Overlay> 40 | ) : ( 41 | undefined 42 | )} 43 | {mounted2 ? ( 44 | <Overlay> 45 | <div style={style}>Overlay #2</div> 46 | </Overlay> 47 | ) : ( 48 | undefined 49 | )} 50 | </> 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/components/Overlay/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | export interface OverlayProps { 5 | /** 6 | * Optional className for the overlay container. 7 | */ 8 | className?: string; 9 | } 10 | 11 | function generateOverlayLayer() { 12 | let currentId = 1; 13 | const elements = new Set<number>(); 14 | const classes: { [className: string]: number } = {}; 15 | 16 | const overlayRoot = document.createElement('div'); 17 | overlayRoot.classList.add('ui__base', 'ui__overlay'); 18 | 19 | const appendElement = () => document.body.appendChild(overlayRoot); 20 | const removeElement = () => document.body.removeChild(overlayRoot); 21 | 22 | const addClass = (className?: string) => { 23 | if (!className) return; 24 | overlayRoot.classList.add(className); 25 | classes[className] = (classes[className] || 0) + 1; 26 | }; 27 | 28 | const removeClass = (className?: string) => { 29 | if (!className) return; 30 | classes[className] = (classes[className] || 0) - 1; 31 | if (classes[className] <= 0) { 32 | delete classes[className]; 33 | overlayRoot.classList.remove(className); 34 | } 35 | }; 36 | 37 | const add = (props: OverlayProps) => { 38 | const id = currentId++; 39 | 40 | addClass(props.className); 41 | elements.add(id); 42 | if (elements.size === 1) appendElement(); 43 | 44 | return () => { 45 | elements.delete(id); 46 | removeClass(props.className); 47 | if (elements.size === 0) removeElement(); 48 | }; 49 | }; 50 | 51 | return (({ children, className }) => { 52 | useEffect(() => add({ className }), [className]); 53 | return createPortal(children, overlayRoot); 54 | }) as FC<OverlayProps>; 55 | } 56 | 57 | let Overlay: FC<OverlayProps>; 58 | 59 | if (typeof window !== 'undefined') { 60 | Overlay = generateOverlayLayer(); 61 | } else { 62 | Overlay = () => null; 63 | } 64 | 65 | export { Overlay }; 66 | -------------------------------------------------------------------------------- /src/components/Overlay/README.md: -------------------------------------------------------------------------------- 1 | Passing children to `Overlay` will cause them to be rendered inside of an 2 | overlay on top of any other components. This can be used for modals, tooltips, 3 | or any other kind of overlay component. 4 | 5 | ## Multiple Components 6 | 7 | Since components use [React portals](https://reactjs.org/docs/portals.html) to 8 | mount into the overlay root, they are inserted in the order they are mounted. 9 | Therefore, if you un-mount and remount a component, it will be inserted as the 10 | last child, regardless of it's position in the React tree. 11 | -------------------------------------------------------------------------------- /src/components/Overlay/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Overlay'; 2 | -------------------------------------------------------------------------------- /src/components/Spinner/README.md: -------------------------------------------------------------------------------- 1 | Shows non-specific progress. Best used if there is an unknown amount of time 2 | before the intended action completes. 3 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.stories.tsx: -------------------------------------------------------------------------------- 1 | import { select } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { Spinner } from '../Spinner'; 4 | import readme from './README.md'; 5 | 6 | export default { title: 'Components/Spinner', component: Spinner, parameters: { readme } }; 7 | 8 | export const Basic = () => ( 9 | <Spinner spinnerSize={select('spinnerSize', ['tiny', 'small', 'default', 'large'], 'default')} /> 10 | ); 11 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { Spinner } from '../Spinner'; 4 | 5 | describe('Spinner component', () => { 6 | it('renders its contents', () => { 7 | const spinner = shallow(<Spinner />); 8 | expect(spinner.find('.ui__spinner')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default spinner', () => { 12 | const spinner = shallow(<Spinner />); 13 | expect(spinner).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | 4 | export interface SpinnerProps { 5 | /** 6 | * Set the size of the spinner. 7 | * @default "default" 8 | */ 9 | spinnerSize?: 'tiny' | 'small' | 'default' | 'large'; 10 | /** 11 | * Classname for the container div. 12 | */ 13 | className?: string; 14 | } 15 | 16 | export const Spinner = ({ spinnerSize = 'default', className }: SpinnerProps) => { 17 | const spinnerClass = classnames('ui__base ui__spinner', `ui__spinner--size-${spinnerSize}`, className); 18 | 19 | return ( 20 | <div className={spinnerClass}> 21 | <div className="ui__spinner__animated" /> 22 | </div> 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/Spinner/_Spinner.scss: -------------------------------------------------------------------------------- 1 | .ui__spinner { 2 | display: inline-block; 3 | 4 | &__animated { 5 | @include spinner; 6 | width: 100%; 7 | height: 100%; 8 | border-radius: 50%; 9 | border: solid; 10 | border-color: $color-blue-gray-4 $color-blue-gray-1 $color-blue-gray-1; 11 | } 12 | 13 | /* --- Spinner sizes. --- */ 14 | 15 | &--size { 16 | &-tiny { 17 | width: 12px; 18 | height: 12px; 19 | 20 | .ui__spinner__animated { 21 | border-width: 1px; 22 | } 23 | } 24 | 25 | &-small { 26 | width: 16px; 27 | height: 16px; 28 | 29 | .ui__spinner__animated { 30 | border-width: 2px; 31 | } 32 | } 33 | 34 | &-default { 35 | width: 24px; 36 | height: 24px; 37 | 38 | .ui__spinner__animated { 39 | border-width: 2px; 40 | } 41 | } 42 | 43 | &-large { 44 | width: 32px; 45 | height: 32px; 46 | 47 | .ui__spinner__animated { 48 | border-width: 2px; 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Spinner/__snapshots__/Spinner.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Spinner component snapshot renders default spinner 1`] = ` 4 | <div 5 | className="ui__base ui__spinner ui__spinner--size-default" 6 | > 7 | <div 8 | className="ui__spinner__animated" 9 | /> 10 | </div> 11 | `; 12 | -------------------------------------------------------------------------------- /src/components/Spinner/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Spinner'; 2 | -------------------------------------------------------------------------------- /src/components/Thumbnail/README.md: -------------------------------------------------------------------------------- 1 | A thumbnail representation of a file. This can be used as a good default for 2 | `FileOrganizer`. 3 | -------------------------------------------------------------------------------- /src/components/Thumbnail/Thumbnail.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean, text } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { action } from '../../storybook-helpers/action/action'; 4 | import { createFile, CreateFileOptions, FakeFile } from '../../storybook-helpers/data/files'; 5 | import { Icon } from '../Icon'; 6 | import { Thumbnail, ThumbnailProps } from '../Thumbnail'; 7 | import readme from './README.md'; 8 | 9 | export default { title: 'Components/Thumbnail', component: Thumbnail, parameters: { readme } }; 10 | 11 | const defaultProps = (options?: CreateFileOptions, index = 0, withToolButtons?: boolean): ThumbnailProps<FakeFile> => ({ 12 | file: createFile(index, options), 13 | selected: boolean('selected', false), 14 | disabled: boolean('disabled', false), 15 | dragging: boolean('dragging', false), 16 | otherDragging: boolean('otherDragging', false), 17 | onClick: boolean('has onClick', true) ? action('onClick') : undefined, 18 | onRename: boolean('has onRename', true) ? action('onRename') : undefined, 19 | buttonProps: withToolButtons 20 | ? [ 21 | { children: <Icon icon="RotateRight" />, onClick: action('rotate onClick'), key: 0 }, 22 | { children: <Icon icon="Close" />, onClick: action('close onClick'), key: 1 }, 23 | ] 24 | : undefined, 25 | }); 26 | 27 | export const Basic = () => <Thumbnail {...defaultProps()} />; 28 | 29 | export const Expensive = () => <Thumbnail {...defaultProps({ lazy: true })} />; 30 | 31 | export const Rejected = () => <Thumbnail {...defaultProps({ error: true })} />; 32 | 33 | export const WithToolButtons = () => <Thumbnail {...defaultProps(undefined, undefined, true)} />; 34 | 35 | export const WithLabel = () => <Thumbnail label={text('label', 'some_label')} {...defaultProps()} />; 36 | 37 | export const SelectedIcon = () => ( 38 | <Thumbnail 39 | {...defaultProps()} 40 | selectedIcon={<div style={{ color: 'red', fontSize: 20 }}>{text('selectedIcon', 'CUSTOM!')}</div>} 41 | /> 42 | ); 43 | 44 | export const Rotated = () => <Thumbnail {...defaultProps(undefined, 1)} />; 45 | 46 | export const RotatedThrottled = () => <Thumbnail {...defaultProps({ lazy: true }, 1)} />; 47 | -------------------------------------------------------------------------------- /src/components/Thumbnail/Thumbnail.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { spy } from 'sinon'; 4 | import { createFile } from '../../storybook-helpers/data/files'; 5 | import { Thumbnail } from '../Thumbnail'; 6 | 7 | const testFile = createFile(0); 8 | 9 | describe('Thumbnail component', () => { 10 | it('renders its contents', () => { 11 | const thumbnail = shallow(<Thumbnail file={testFile} />); 12 | expect(thumbnail.find('.ui__thumbnail')).toHaveLength(1); 13 | }); 14 | 15 | it('snapshot renders default thumbnail', () => { 16 | const thumbnail = shallow(<Thumbnail file={testFile} />); 17 | expect(thumbnail).toMatchSnapshot(); 18 | }); 19 | 20 | it('clicking thumbnail triggers onClick prop', () => { 21 | const onClick = spy(); 22 | shallow(<Thumbnail file={testFile} onClick={onClick} />).simulate('click'); 23 | expect(onClick.callCount).toBe(1); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/Thumbnail/__snapshots__/Thumbnail.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Thumbnail component snapshot renders default thumbnail 1`] = ` 4 | <ForwardRef 5 | className="ui__base ui__thumbnail" 6 | noFocusStyle={true} 7 | onBlur={[Function]} 8 | onFocus={[Function]} 9 | > 10 | <div 11 | className="ui__thumbnail__image" 12 | > 13 | <ForwardRef 14 | alt="file_1.pdf" 15 | onRenderFallback={[Function]} 16 | onRenderLoading={[Function]} 17 | pending={true} 18 | /> 19 | </div> 20 | <div 21 | className="ui__thumbnail__controls" 22 | /> 23 | <ForwardRef 24 | centerText={true} 25 | className="ui__thumbnail__label" 26 | locked={true} 27 | onCancel={[Function]} 28 | onEdit={[Function]} 29 | onSave={[Function]} 30 | value="file_1.pdf" 31 | /> 32 | </ForwardRef> 33 | `; 34 | -------------------------------------------------------------------------------- /src/components/Thumbnail/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Thumbnail'; 2 | -------------------------------------------------------------------------------- /src/components/ThumbnailDragLayer/README.md: -------------------------------------------------------------------------------- 1 | This is a thumbnail drag preview which displays the number of pages that are 2 | selected. 3 | -------------------------------------------------------------------------------- /src/components/ThumbnailDragLayer/ThumbnailDragLayer.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { integer } from '../../storybook-helpers/knobs/integer'; 3 | import { ThumbnailDragLayer } from '../ThumbnailDragLayer'; 4 | import readme from './README.md'; 5 | 6 | export default { title: 'Components/ThumbnailDragLayer', component: ThumbnailDragLayer, parameters: { readme } }; 7 | 8 | export const Basic = () => <ThumbnailDragLayer numFiles={integer('numFiles', 1)} />; 9 | -------------------------------------------------------------------------------- /src/components/ThumbnailDragLayer/ThumbnailDragLayer.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { ThumbnailDragLayer } from '../ThumbnailDragLayer'; 4 | 5 | describe('ThumbnailDragLayer component', () => { 6 | it('renders its contents', () => { 7 | const thumbnailDragLayer = shallow(<ThumbnailDragLayer />); 8 | expect(thumbnailDragLayer.find('.ui__thumbnailDragLayer')).toHaveLength(1); 9 | }); 10 | 11 | it('snapshot renders default thumbnailDragLayer', () => { 12 | const thumbnailDragLayer = shallow(<ThumbnailDragLayer />); 13 | expect(thumbnailDragLayer).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/ThumbnailDragLayer/ThumbnailDragLayer.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { HTMLAttributes, useEffect } from 'react'; 3 | import { MultiPage, SinglePage } from '../../icons'; 4 | 5 | export interface ThumbnailDragLayerProps extends HTMLAttributes<HTMLDivElement> { 6 | /** 7 | * Must be a positive integer (1, 2, 3...) or falsy to default. 8 | * @default 1 9 | */ 10 | numFiles?: number; 11 | } 12 | 13 | export const ThumbnailDragLayer = ({ numFiles = 1, className, ...divProps }: ThumbnailDragLayerProps) => { 14 | numFiles = numFiles || 1; 15 | useEffect(() => { 16 | if (!Number.isInteger(numFiles)) throw new RangeError('numFiles must be an integer'); 17 | if (!Number.isFinite(numFiles)) throw new RangeError('numFiles must not be infinite'); 18 | if (numFiles <= 0) throw new RangeError('numFiles must be a positive integer'); 19 | }, [numFiles]); 20 | 21 | const thumbnailDragLayerClass = classnames('ui__base ui__thumbnailDragLayer', className); 22 | 23 | return ( 24 | <div {...divProps} className={thumbnailDragLayerClass}> 25 | <div className="ui__thumbnailDragLayer__wrapper"> 26 | {numFiles === 1 ? ( 27 | <SinglePage className="ui__thumbnailDragLayer__icon" /> 28 | ) : ( 29 | <MultiPage className="ui__thumbnailDragLayer__icon" /> 30 | )} 31 | {numFiles > 1 ? ( 32 | <div className="ui__thumbnailDragLayer__numFiles"> 33 | <span className="ui__thumbnailDragLayer__numFiles__wrapper">{numFiles}</span> 34 | </div> 35 | ) : ( 36 | undefined 37 | )} 38 | </div> 39 | </div> 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/ThumbnailDragLayer/_ThumbnailDragLayer.scss: -------------------------------------------------------------------------------- 1 | .ui__thumbnailDragLayer { 2 | width: $thumbnail-dimensions; 3 | height: $thumbnail-dimensions; 4 | @include flex-center; 5 | 6 | &__icon { 7 | height: $thumbnail-dimensions / 4; 8 | width: $thumbnail-dimensions / 4; 9 | fill: $color-blue-gray-3; 10 | } 11 | 12 | &__numFiles { 13 | @include absolute-fill; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: flex-end; 17 | font-size: $font-size-default; 18 | 19 | &__wrapper { 20 | position: relative; 21 | top: -10px; 22 | right: -10px; 23 | padding: $padding-tiny; 24 | border-radius: $border-radius-large; 25 | background-color: $color-blue-gray-3; 26 | min-width: 20px; 27 | @include flex-center; 28 | } 29 | } 30 | 31 | &__wrapper { 32 | position: relative; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/ThumbnailDragLayer/__snapshots__/ThumbnailDragLayer.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ThumbnailDragLayer component snapshot renders default thumbnailDragLayer 1`] = ` 4 | <div 5 | className="ui__base ui__thumbnailDragLayer" 6 | > 7 | <div 8 | className="ui__thumbnailDragLayer__wrapper" 9 | > 10 | <SinglePage 11 | className="ui__thumbnailDragLayer__icon" 12 | /> 13 | </div> 14 | </div> 15 | `; 16 | -------------------------------------------------------------------------------- /src/components/ThumbnailDragLayer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ThumbnailDragLayer'; 2 | -------------------------------------------------------------------------------- /src/components/ThumbnailSkeleton/README.md: -------------------------------------------------------------------------------- 1 | A loading placeholder for `Thumbnail`. 2 | -------------------------------------------------------------------------------- /src/components/ThumbnailSkeleton/ThumbnailSkeleton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ThumbnailSkeleton } from '../ThumbnailSkeleton'; 3 | import readme from './README.md'; 4 | 5 | export default { title: 'Components/ThumbnailSkeleton', component: ThumbnailSkeleton, parameters: { readme } }; 6 | 7 | export const Basic = () => <ThumbnailSkeleton />; 8 | -------------------------------------------------------------------------------- /src/components/ThumbnailSkeleton/ThumbnailSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { HTMLAttributes } from 'react'; 3 | import { FileSkeleton } from '../FileSkeleton'; 4 | 5 | export function ThumbnailSkeleton({ className, ...divProps }: HTMLAttributes<HTMLDivElement>) { 6 | const thumbnailClass = classnames('ui__base ui__thumbnailSkeleton', className); 7 | 8 | return ( 9 | <div {...divProps} className={thumbnailClass}> 10 | <div className="ui__thumbnailSkeleton__image"> 11 | <FileSkeleton className="ui__thumbnailSkeleton__image__skeleton" /> 12 | </div> 13 | <div className="ui__thumbnailSkeleton__label" /> 14 | </div> 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/ThumbnailSkeleton/_ThumbnailSkeleton.scss: -------------------------------------------------------------------------------- 1 | .ui__thumbnailSkeleton { 2 | padding: $padding; 3 | position: relative; 4 | outline: none; 5 | width: $thumbnail-dimensions; 6 | height: $thumbnail-dimensions; 7 | transition: 0.1s background-color; 8 | display: flex; 9 | flex-direction: column; 10 | 11 | &:hover { 12 | background-color: $color-gray-4; 13 | } 14 | 15 | &__image { 16 | @include flex-center; 17 | flex-direction: column; 18 | height: 0; 19 | flex: 1 1 auto; 20 | 21 | .ui__thumbnailSkeleton__image__skeleton { 22 | border: 1px solid $color-blue-gray-1; 23 | transition: $focus-transition; 24 | } 25 | } 26 | 27 | &__label { 28 | margin-top: $padding; 29 | height: 28px; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/ThumbnailSkeleton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ThumbnailSkeleton'; 2 | -------------------------------------------------------------------------------- /src/components/Toast/README.md: -------------------------------------------------------------------------------- 1 | This is a configurable Toast, but it has no inherent animations or timers. In 2 | order to use the Toast as a notification system, you should use the `useToast` 3 | hook. 4 | -------------------------------------------------------------------------------- /src/components/Toast/Toast.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean, select, text } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { action } from '../../storybook-helpers/action/action'; 4 | import { Toast } from '../Toast'; 5 | import readme from './README.md'; 6 | 7 | export default { title: 'Components/Toast', component: Toast, parameters: { readme } }; 8 | 9 | export const Basic = () => ( 10 | <div style={{ display: 'flex' }}> 11 | <Toast 12 | message={select('message', ['info', 'success', 'warning', 'error'], 'info')} 13 | heading={text('heading', 'Information about the toast')} 14 | onClose={boolean('has onClose', false) ? action('onClose') : undefined} 15 | action={boolean('has action', false) ? { text: 'Action', onClick: action('action.onClick') } : undefined} 16 | > 17 | {text('children', '')} 18 | </Toast> 19 | </div> 20 | ); 21 | -------------------------------------------------------------------------------- /src/components/Toast/Toast.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { Toast } from '../Toast'; 4 | 5 | jest.mock('../../utils', () => ({ getStringId: (prefix: string) => `${prefix}_1234` })); 6 | 7 | describe('Toast component', () => { 8 | it('renders its contents', () => { 9 | const toast = shallow(<Toast heading={''} />); 10 | expect(toast.find('.ui__toast')).toHaveLength(1); 11 | }); 12 | 13 | it('snapshot renders default toast', () => { 14 | const toast = shallow(<Toast heading={''} />); 15 | expect(toast).toMatchSnapshot(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/Toast/__snapshots__/Toast.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Toast component snapshot renders default toast 1`] = ` 4 | <div 5 | aria-atomic={true} 6 | aria-live="polite" 7 | className="ui__base ui__toast ui__toast--message-info" 8 | role="status" 9 | > 10 | <div 11 | className="ui__toast__border" 12 | /> 13 | <ForwardRef 14 | className="ui__toast__icon" 15 | icon="Info" 16 | /> 17 | <div 18 | className="ui__toast__copy" 19 | /> 20 | </div> 21 | `; 22 | -------------------------------------------------------------------------------- /src/components/Toast/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Toast'; 2 | -------------------------------------------------------------------------------- /src/components/ToastProvider/README.md: -------------------------------------------------------------------------------- 1 | A provider to allow you to manage notification toasts in your application. Use 2 | in combination with the `useToast` hook. 3 | 4 | ## Using 5 | 6 | 1. Make sure that you have the `ToastProvider` placed at the root of your 7 | project 8 | 1. Use the `useToast` hook to access the `ToastContext` 9 | 10 | ## Details 11 | 12 | When hovering a toast, any existing timeout will be cancelled, and will restart 13 | once the mouse leaves the toast. 14 | -------------------------------------------------------------------------------- /src/components/ToastProvider/_ToastProvider.scss: -------------------------------------------------------------------------------- 1 | %toastProvider-bottom { 2 | position: absolute; 3 | bottom: 0; 4 | left: 0; 5 | right: 0; 6 | transform: translateY(100%); 7 | } 8 | 9 | .ui__toastProvider { 10 | opacity: 1; 11 | @include opacity-transition; 12 | position: relative; 13 | 14 | &--closing { 15 | opacity: 0; 16 | } 17 | 18 | &__toast { 19 | display: flex; 20 | padding: $padding; 21 | pointer-events: none !important; 22 | 23 | > * { 24 | pointer-events: all; 25 | } 26 | 27 | @include slide-in; 28 | 29 | &--position { 30 | &-top-left { 31 | transform: translateY(-100%); 32 | justify-content: flex-start; 33 | } 34 | 35 | &-top { 36 | transform: translateY(-100%); 37 | justify-content: center; 38 | } 39 | 40 | &-top-right { 41 | transform: translateY(-100%); 42 | justify-content: flex-end; 43 | } 44 | 45 | &-bottom-left { 46 | @extend %toastProvider-bottom; 47 | justify-content: flex-start; 48 | } 49 | 50 | &-bottom { 51 | @extend %toastProvider-bottom; 52 | justify-content: center; 53 | } 54 | 55 | &-bottom-right { 56 | @extend %toastProvider-bottom; 57 | justify-content: flex-end; 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/ToastProvider/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ToastProvider'; 2 | -------------------------------------------------------------------------------- /src/components/ToolButton/README.md: -------------------------------------------------------------------------------- 1 | A button designed to be used in small toolbars. This button would most likely 2 | take an icon as its child. 3 | -------------------------------------------------------------------------------- /src/components/ToolButton/ToolButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { boolean, select } from '@storybook/addon-knobs'; 2 | import React from 'react'; 3 | import { action } from '../../storybook-helpers/action/action'; 4 | import { Icon } from '../Icon'; 5 | import { ToolButton } from '../ToolButton'; 6 | import readme from './README.md'; 7 | 8 | export default { title: 'Components/ToolButton', component: ToolButton, parameters: { readme } }; 9 | 10 | export const Basic = () => ( 11 | <ToolButton 12 | disabled={boolean('disabled', false)} 13 | onClick={action('onClick')} 14 | expandProps={ 15 | boolean('has expandProps?', false) 16 | ? { 17 | position: select('expandProps.position', ['bottom', 'right'], 'bottom'), 18 | onClick: action('expandProps.onClick'), 19 | } 20 | : undefined 21 | } 22 | > 23 | <Icon icon="RotateRight" /> 24 | </ToolButton> 25 | ); 26 | -------------------------------------------------------------------------------- /src/components/ToolButton/ToolButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { mount, shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { spy } from 'sinon'; 4 | import { ToolButton } from '../ToolButton'; 5 | 6 | describe('ToolButton component', () => { 7 | it('renders its contents', () => { 8 | const toolButton = shallow(<ToolButton />); 9 | expect(toolButton.find('.ui__toolButton')).toHaveLength(1); 10 | }); 11 | 12 | it('snapshot renders default toolButton', () => { 13 | const toolButton = shallow(<ToolButton />); 14 | expect(toolButton).toMatchSnapshot(); 15 | }); 16 | 17 | it('clicking toolButton triggers onClick prop', () => { 18 | const onClick = spy(); 19 | mount(<ToolButton onClick={onClick} />) 20 | .find('button.ui__toolButton__action') 21 | .simulate('click'); 22 | expect(onClick.callCount).toBe(1); 23 | }); 24 | 25 | it('clicking disabled toolButton does not trigger onClick prop', () => { 26 | const onClick = spy(); 27 | mount(<ToolButton onClick={onClick} disabled />) 28 | .find('button.ui__toolButton__action') 29 | .simulate('click'); 30 | expect(onClick.callCount).toBe(0); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/ToolButton/_ToolButton.scss: -------------------------------------------------------------------------------- 1 | $tool-button-background: rgba(175, 175, 175, 0.3); 2 | 3 | %tool-button-background { 4 | &:hover:not(.ui__toolButton--disabled), 5 | &:focus.ui__toolButton--tabbing:not(.ui__toolButton--disabled), 6 | &:active:not(.ui__toolButton--disabled) { 7 | background-color: $tool-button-background; 8 | } 9 | } 10 | 11 | .ui__toolButton { 12 | display: inline-flex; 13 | flex-direction: column; 14 | 15 | &--expanded { 16 | &.ui__toolButton--right { 17 | flex-direction: row; 18 | } 19 | 20 | &.ui__toolButton--bottom { 21 | flex-direction: column; 22 | } 23 | } 24 | } 25 | 26 | .ui__toolButton__action { 27 | @extend %tool-button-background; 28 | 29 | &.ui__toolButton--expanded { 30 | &.ui__toolButton--right { 31 | border-radius: $border-radius 0 0 $border-radius; 32 | } 33 | 34 | &.ui__toolButton--bottom { 35 | border-radius: $border-radius $border-radius 0 0; 36 | } 37 | } 38 | 39 | &__internals { 40 | width: 100%; 41 | height: 100%; 42 | @include flex-center; 43 | } 44 | } 45 | 46 | .ui__toolButton__expand.ui__button { 47 | padding: 0; 48 | @include flex-center; 49 | min-height: 0; 50 | 51 | &.ui__toolButton--right { 52 | border-radius: 0 $border-radius $border-radius 0; 53 | } 54 | 55 | &.ui__toolButton--bottom { 56 | border-radius: 0 0 $border-radius $border-radius; 57 | } 58 | 59 | @extend %tool-button-background; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/ToolButton/__snapshots__/ToolButton.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ToolButton component snapshot renders default toolButton 1`] = ` 4 | <div 5 | className="ui__base ui__toolButton ui__toolButton--bottom" 6 | > 7 | <ForwardRef 8 | className="ui__toolButton__action ui__toolButton--bottom" 9 | onClick={[Function]} 10 | /> 11 | </div> 12 | `; 13 | -------------------------------------------------------------------------------- /src/components/ToolButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ToolButton'; 2 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Component imports: DO NOT MODIFY. 3 | */ 4 | 5 | // <import-start> 6 | export * from './Button'; 7 | export * from './ButtonGroup'; 8 | export * from './Choice'; 9 | export * from './ClickableDiv'; 10 | export * from './DndMultiProvider'; 11 | export * from './DragLayer'; 12 | export * from './Draggable'; 13 | export * from './EditableText'; 14 | export * from './FileOrganizer'; 15 | export * from './FilePicker'; 16 | export * from './FilePlaceholder'; 17 | export * from './FileSkeleton'; 18 | export * from './FocusTrap'; 19 | export * from './Icon'; 20 | export * from './IconButton'; 21 | export * from './Image'; 22 | export * from './Input'; 23 | export * from './Label'; 24 | export * from './Modal'; 25 | export * from './Overlay'; 26 | export * from './Spinner'; 27 | export * from './Thumbnail'; 28 | export * from './ThumbnailDragLayer'; 29 | export * from './ThumbnailSkeleton'; 30 | export * from './Toast'; 31 | export * from './ToastProvider'; 32 | export * from './ToolButton'; 33 | // <import-end> 34 | -------------------------------------------------------------------------------- /src/data/futurable.ts: -------------------------------------------------------------------------------- 1 | import { MemoizedPromise } from './memoizedPromise'; 2 | 3 | /** Either a promise to return a type `T`, or `T` itself. */ 4 | export type Futurable<T> = Promise<T> | T; 5 | 6 | /** Function that returns a promise of `T`, or `T` itself. */ 7 | export type LazyFuturable<T> = () => Futurable<T>; 8 | 9 | /** A promise of `T`, or `T` itself, or a function to return either. */ 10 | export type FuturableOrLazy<T> = Futurable<T> | LazyFuturable<T>; 11 | 12 | /** 13 | * Returns a futurable from a futurable or a lazy futurable. If lazy, will call 14 | * to convert to futurable. Use this at evaluation time only, as any lazy 15 | * futurables will be called at this point. 16 | * @param futurableOrLazy A `Futurable` or a `LazyFuturable`. 17 | */ 18 | export function futureableOrLazyToFuturable<T>(futurableOrLazy: FuturableOrLazy<T>): Futurable<T> { 19 | return futurableOrLazy instanceof Function ? futurableOrLazy() : futurableOrLazy; 20 | } 21 | 22 | /** 23 | * If the MemoizedPromise is done, will turn into Promise, otherwise will turn 24 | * into lazy Promise 25 | * @param memoizedPromise The memoized promise to convert. 26 | */ 27 | export function memoizedPromiseToFuturableOrLazy<T>(memoizedPromise: MemoizedPromise<T>): FuturableOrLazy<T> { 28 | if (memoizedPromise.done) return memoizedPromise.get(); 29 | return memoizedPromise.get; 30 | } 31 | -------------------------------------------------------------------------------- /src/data/index.ts: -------------------------------------------------------------------------------- 1 | export * from './file'; 2 | export * from './futurable'; 3 | export * from './memoizedPromise'; 4 | -------------------------------------------------------------------------------- /src/data/memoizedPromise.ts: -------------------------------------------------------------------------------- 1 | import { Futurable, FuturableOrLazy, futureableOrLazyToFuturable, memoizedPromiseToFuturableOrLazy } from './futurable'; 2 | 3 | export interface MemoizeOptions { 4 | /** 5 | * If true, will immediately process the input, even if it's a lazy promise ( 6 | * a function to retrieve a promise). 7 | */ 8 | preprocess?: boolean; 9 | } 10 | 11 | /** 12 | * This class is responsible for wrapping tasks in a promise that won't be 13 | * executed until the result is actually required. Calling .get() on the class 14 | * will start the task, and resolve with the result. If the task has already 15 | * been executed once, it will resolve immediatly with the last result. 16 | */ 17 | export class MemoizedPromise<T> { 18 | private _futurableOrLazy: FuturableOrLazy<T>; 19 | private _result?: Futurable<T>; 20 | private _done: boolean; 21 | 22 | constructor(futurableOrLazy: MemoizedPromise<T> | FuturableOrLazy<T>, options: MemoizeOptions = {}) { 23 | if (futurableOrLazy instanceof MemoizedPromise) { 24 | this._futurableOrLazy = memoizedPromiseToFuturableOrLazy(futurableOrLazy); 25 | } else { 26 | this._futurableOrLazy = futurableOrLazy; 27 | } 28 | 29 | this._result = undefined; 30 | this._done = false; 31 | 32 | if (options.preprocess || typeof this._futurableOrLazy !== 'function') { 33 | this._result = futureableOrLazyToFuturable(this._futurableOrLazy); 34 | this._done = true; 35 | } 36 | } 37 | 38 | /** Is true if the value is memoized. */ 39 | get done() { 40 | return this._done; 41 | } 42 | 43 | /** Resolves with a promise for the value. */ 44 | get = async () => { 45 | if (this._done) return this._result as Futurable<T>; 46 | this._result = futureableOrLazyToFuturable(this._futurableOrLazy); 47 | this._done = true; 48 | return this._result; 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useAccessibleFocus'; 2 | export * from './useCurrentRef'; 3 | export * from './useFile'; 4 | export * from './useFileSubscribe'; 5 | export * from './useFocus'; 6 | export * from './useFocusTrap'; 7 | export * from './useKeyForClick'; 8 | export * from './useManagedFiles'; 9 | export * from './useOnClick'; 10 | export * from './useToast'; 11 | export * from './useUnmountDelay'; 12 | -------------------------------------------------------------------------------- /src/hooks/useCurrentRef.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function useCurrentRef<T>(toRef: T) { 4 | const toRefRef = useRef<T>(toRef); 5 | useEffect(() => { 6 | toRefRef.current = toRef; 7 | }); 8 | return toRefRef; 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/useFile.ts: -------------------------------------------------------------------------------- 1 | import { Core } from '@pdftron/webviewer'; 2 | import { useMemo } from 'react'; 3 | import { FileLike } from '../data'; 4 | import { useFileSubscribe } from './useFileSubscribe'; 5 | 6 | /** The output of this hook is an object representing a file. */ 7 | interface FileHook<F> { 8 | /** The entire file class. */ 9 | file: F; 10 | /** The file id. */ 11 | id: string; 12 | /** The file originalName. */ 13 | originalName: string; 14 | /** The file extension. */ 15 | extension: string; 16 | /** The file name. */ 17 | name?: string; 18 | /** The resolved file thumbnail or undefined until it is resolved. */ 19 | thumbnail?: string; 20 | /** The resolved file fileObj or undefined until it is resolved. */ 21 | fileObj?: Blob; 22 | /** The resolved file documentObj or undefined until it is resolved. */ 23 | documentObj?: Core.Document; 24 | errors: { 25 | name?: any; 26 | thumbnail?: any; 27 | fileObj?: any; 28 | documentObj?: any; 29 | }; 30 | } 31 | 32 | /** 33 | * This hook converts a file class with async values into a React-friendly hook 34 | * with async values set to undefined until they are fetched. 35 | * @param file The file to convert to react observable values. 36 | */ 37 | export function useFile<F extends FileLike>(file: F): FileHook<F> { 38 | const [name, nameErr] = useFileSubscribe(file, f => f.name, 'onnamechange'); 39 | const [thumbnail, thumbnailErr] = useFileSubscribe(file, f => f.thumbnail, 'onthumbnailchange'); 40 | const [fileObj, fileObjErr] = useFileSubscribe(file, f => f.fileObj, 'onfileobjchange'); 41 | const [documentObj, documentObjErr] = useFileSubscribe(file, f => f.documentObj, 'ondocumentobjchange'); 42 | 43 | const fileValue = useMemo<FileHook<F>>( 44 | () => ({ 45 | file, 46 | id: file.id, 47 | originalName: file.originalName, 48 | extension: file.extension, 49 | name, 50 | thumbnail, 51 | fileObj, 52 | documentObj, 53 | errors: { 54 | name: nameErr, 55 | thumbnail: thumbnailErr, 56 | fileObj: fileObjErr, 57 | documentObj: documentObjErr, 58 | }, 59 | }), 60 | [documentObj, documentObjErr, file, fileObj, fileObjErr, name, nameErr, thumbnail, thumbnailErr], 61 | ); 62 | 63 | return fileValue; 64 | } 65 | -------------------------------------------------------------------------------- /src/hooks/useFocus.ts: -------------------------------------------------------------------------------- 1 | import { FocusEvent, FocusEventHandler, useCallback, useState } from 'react'; 2 | 3 | /** 4 | * Returns handlers for onFocus and onBlur, as well as a property focused which 5 | * is true if the component or any child is being focused. 6 | * @param onFocus The onFocus prop if it's available. 7 | * @param onBlur The onBlur prop if it's available. 8 | */ 9 | export function useFocus<T>(onFocus?: FocusEventHandler<T>, onBlur?: FocusEventHandler<T>) { 10 | const [focused, setFocused] = useState(false); 11 | 12 | const handleOnFocus = useCallback( 13 | (event: FocusEvent<T>) => { 14 | setFocused(true); 15 | onFocus?.(event); 16 | }, 17 | [onFocus], 18 | ); 19 | 20 | const handleOnBlur = useCallback( 21 | (event: FocusEvent<T>) => { 22 | setFocused(false); 23 | onBlur?.(event); 24 | }, 25 | [onBlur], 26 | ); 27 | 28 | return { focused, handleOnFocus, handleOnBlur }; 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/useID.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { getStringId } from '../utils'; 3 | 4 | /** 5 | * If an ID is not given, will generate and memoize an ID to use for a11y 6 | * or any other purpose. 7 | * @param id The optional ID provided by props. 8 | */ 9 | export function useID(id?: string) { 10 | return useMemo(() => id || getStringId('label'), [id]); 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useKeyForClick.ts: -------------------------------------------------------------------------------- 1 | import { KeyboardEvent, KeyboardEventHandler, RefObject, useCallback } from 'react'; 2 | import { generateClickEventFromKeyboardEvent } from '../utils'; 3 | 4 | /** 5 | * Returns the handler for onKeyPress. If it hears a space or Enter key, it will 6 | * fire onClick. If you provide a ref, will compare the target and make sure it 7 | * is the same as the ref, then will fire onClick on the ref. Otherwise will 8 | * call it on the event target. 9 | * @param onKeyPress The onKeyPress prop if it's available. 10 | * @param ref If given, will compare event target to prevent any bubbling events. 11 | */ 12 | export function useKeyForClick<T extends HTMLElement>(onKeyPress?: KeyboardEventHandler<T>, ref?: RefObject<T>) { 13 | const handler = useCallback( 14 | (event: KeyboardEvent<T>) => { 15 | // Fire click on space or enter press. 16 | if (event.key === ' ' || event.key === 'Enter') { 17 | const clickEvent = generateClickEventFromKeyboardEvent(event); 18 | 19 | // If ref is provided and it matches the event target, click ref. 20 | if (ref && event.target === ref.current) { 21 | ref.current.dispatchEvent(clickEvent); 22 | // Stop scrolling if space is pressed. 23 | if (event.key === ' ') event.preventDefault(); 24 | } else if (!ref) { 25 | (event.target as HTMLElement).dispatchEvent(clickEvent); 26 | } 27 | } 28 | onKeyPress?.(event); 29 | }, 30 | [ref, onKeyPress], 31 | ); 32 | 33 | return handler; 34 | } 35 | -------------------------------------------------------------------------------- /src/hooks/useOnClick.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, MouseEventHandler, useCallback } from 'react'; 2 | 3 | export interface UseOnClickOptions { 4 | stopPropagation?: boolean; 5 | preventDefault?: boolean; 6 | blurOnClick?: boolean; 7 | disabled?: boolean; 8 | } 9 | 10 | type UseOnClickOutput<T> = (event: MouseEvent<T>) => void; 11 | 12 | /** 13 | * Returns the handler for onClick. Allows you to add specific options to the 14 | * event before passing it through to the default onClick. 15 | * @param onClick The onClick prop if it's available. 16 | * @param options UseOnClickOptions for the hook. 17 | */ 18 | export function useOnClick<T>(onClick?: MouseEventHandler<T>, options: UseOnClickOptions = {}) { 19 | const stopPropagation = !!options.stopPropagation; 20 | const preventDefault = !!options.preventDefault; 21 | const blurOnClick = !!options.blurOnClick; 22 | const disabled = !!options.disabled; 23 | 24 | const handler = useCallback<UseOnClickOutput<T>>( 25 | event => { 26 | if (preventDefault) event.preventDefault(); 27 | if (stopPropagation) event.stopPropagation(); 28 | if (disabled) return; 29 | if (blurOnClick) { 30 | const focused = document.activeElement as HTMLElement | null; 31 | focused?.blur(); 32 | } 33 | onClick?.(event); 34 | }, 35 | [preventDefault, stopPropagation, blurOnClick, onClick, disabled], 36 | ); 37 | 38 | return handler; 39 | } 40 | -------------------------------------------------------------------------------- /src/hooks/useUnmountDelay.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react'; 2 | 3 | export function useUnmountDelay(on = false, delay = 250) { 4 | const [mounted, setMounted] = useState(on); 5 | 6 | useEffect(() => { 7 | if (!on) { 8 | const timeoutId = window.setTimeout(() => { 9 | setMounted(false); 10 | }, delay); 11 | 12 | return () => window.clearTimeout(timeoutId); 13 | } 14 | setMounted(true); 15 | return; 16 | }, [delay, on]); 17 | 18 | const value = useMemo( 19 | () => ({ 20 | mounted, 21 | unmounting: mounted && !on, 22 | }), 23 | [mounted, on], 24 | ); 25 | 26 | return value; 27 | } 28 | -------------------------------------------------------------------------------- /src/icons/Check.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Check(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M10 15.586L6.707 12.293 5.293 13.707 10 18.414 19.707 8.707 18.293 7.293z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/ChevronDown.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function ChevronDown(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" {...props}> 6 | <path d="M5.67,8.35a.44.44,0,0,0,.66,0L9.93,4a.3.3,0,0,0,0-.35A.4.4,0,0,0,9.6,3.5H2.4a.4.4,0,0,0-.35.18.3.3,0,0,0,0,.35Z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/Circle.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Circle(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M12,2C6.486,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.514,2,12,2z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/Close.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Close(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M16.192 6.344L11.949 10.586 7.707 6.344 6.293 7.758 10.535 12 6.293 16.242 7.707 17.656 11.949 13.414 16.192 17.656 17.606 16.242 13.364 12 17.606 7.758z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/Download.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Download(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M19 9L15 9 15 3 9 3 9 9 5 9 12 17zM4 19H20V21H4z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/Error.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Error(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M9.172 16.242L12 13.414 14.828 16.242 16.242 14.828 13.414 12 16.242 9.172 14.828 7.758 12 10.586 9.172 7.758 7.758 9.172 10.586 12 7.758 14.828z" /> 7 | <path d="M12,22c5.514,0,10-4.486,10-10S17.514,2,12,2S2,6.486,2,12S6.486,22,12,22z M12,4c4.411,0,8,3.589,8,8s-3.589,8-8,8 s-8-3.589-8-8S7.589,4,12,4z" /> 8 | </svg> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/icons/Hide.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Hide(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M12 19c.946 0 1.81-.103 2.598-.281l-1.757-1.757C12.568 16.983 12.291 17 12 17c-5.351 0-7.424-3.846-7.926-5 .204-.47.674-1.381 1.508-2.297L4.184 8.305c-1.538 1.667-2.121 3.346-2.132 3.379-.069.205-.069.428 0 .633C2.073 12.383 4.367 19 12 19zM12 5c-1.837 0-3.346.396-4.604.981L3.707 2.293 2.293 3.707l18 18 1.414-1.414-3.319-3.319c2.614-1.951 3.547-4.615 3.561-4.657.069-.205.069-.428 0-.633C21.927 11.617 19.633 5 12 5zM16.972 15.558l-2.28-2.28C14.882 12.888 15 12.459 15 12c0-1.641-1.359-3-3-3-.459 0-.888.118-1.277.309L8.915 7.501C9.796 7.193 10.814 7 12 7c5.351 0 7.424 3.846 7.926 5C19.624 12.692 18.76 14.342 16.972 15.558z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/Info.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Info(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M12,2C6.486,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.514,2,12,2z M12,20c-4.411,0-8-3.589-8-8s3.589-8,8-8 s8,3.589,8,8S16.411,20,12,20z" /> 7 | <path d="M11 11H13V17H11zM11 7H13V9H11z" /> 8 | </svg> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/icons/Menu.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Menu(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M4 6H20V8H4zM4 11H20V13H4zM4 16H20V18H4z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/MultiPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function MultiPage(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M20.3,9.5 L20.3,20.3 C20.3,21.2 19.6,22 18.6,22 L11.1,22 L11.1,20.3 L18.6,20.3 L18.6,9.5 L20.3,9.5 Z M15.3,18.7 L5.3,18.7 C4.4,18.7 3.6,18 3.6,17 L3.6,3.7 C3.7,2.7 4.4,2 5.3,2 L12,2 L17,7 L17,17 C17,17.9 16.3,18.7 15.3,18.7 Z M12,7.8 L15.3,7.8 L11.1,3.6 L11.1,7.8 C11.2,7.8 11.5,7.8 12,7.8 Z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/README.md: -------------------------------------------------------------------------------- 1 | # Icons 2 | 3 | Many of the icons are modified from [Boxicons](https://boxicons.com/). 4 | 5 | They are licensed under CC 4.0. For license information, see 6 | [Boxicons license](https://github.com/atisawd/boxicons#license). 7 | -------------------------------------------------------------------------------- /src/icons/RotateRight.tsx: -------------------------------------------------------------------------------- 1 | // <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path fill='white' d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z"/></svg> 2 | 3 | import React, { SVGProps } from 'react'; 4 | 5 | export function RotateRight(props: SVGProps<SVGSVGElement>) { 6 | return ( 7 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 8 | <path d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z" /> 9 | </svg> 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/icons/Search.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Search(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M17.69,15.79a8.63,8.63,0,1,0-1.52,1.51l4.42,4.42a.42.42,0,0,0,.57,0l.94-.95a.39.39,0,0,0,0-.56Zm-5.28,1A6.42,6.42,0,1,1,17.19,12,6.43,6.43,0,0,1,12.41,16.8Z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/Show.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Show(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M12,9c-1.642,0-3,1.359-3,3c0,1.642,1.358,3,3,3c1.641,0,3-1.358,3-3C15,10.359,13.641,9,12,9z" /> 7 | <path d="M12,5c-7.633,0-9.927,6.617-9.948,6.684L1.946,12l0.105,0.316C2.073,12.383,4.367,19,12,19s9.927-6.617,9.948-6.684 L22.054,12l-0.105-0.316C21.927,11.617,19.633,5,12,5z M12,17c-5.351,0-7.424-3.846-7.926-5C4.578,10.842,6.652,7,12,7 c5.351,0,7.424,3.846,7.926,5C19.422,13.158,17.348,17,12,17z" /> 8 | </svg> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/icons/SinglePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function SinglePage(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M14,2 L6,2 C4.9,2 4,2.9 4,4 L4,20 C4,21.1 4.9,22 6,22 L18,22 C19.1,22 20,21.1 20,20 L20,8 L14,2 Z M14,9 C13.4,9 13,9 13,9 L13,4 L18,9 L14,9 Z" /> 7 | </svg> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/Success.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Success(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M12,2C6.486,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.514,2,12,2z M12,20c-4.411,0-8-3.589-8-8s3.589-8,8-8 s8,3.589,8,8S16.411,20,12,20z" /> 7 | <path d="M9.999 13.587L7.7 11.292 6.288 12.708 10.001 16.413 16.707 9.707 15.293 8.293z" /> 8 | </svg> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/icons/Warning.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export function Warning(props: SVGProps<SVGSVGElement>) { 4 | return ( 5 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> 6 | <path d="M11.001 10H13.001V15H11.001zM11 16H13V18H11z" /> 7 | <path d="M13.768,4.2C13.42,3.545,12.742,3.138,12,3.138s-1.42,0.407-1.768,1.063L2.894,18.064 c-0.331,0.626-0.311,1.361,0.054,1.968C3.313,20.638,3.953,21,4.661,21h14.678c0.708,0,1.349-0.362,1.714-0.968 c0.364-0.606,0.385-1.342,0.054-1.968L13.768,4.2z M4.661,19L12,5.137L19.344,19H4.661z" /> 8 | </svg> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Check'; 2 | export * from './ChevronDown'; 3 | export * from './Circle'; 4 | export * from './Close'; 5 | export * from './Download'; 6 | export * from './Error'; 7 | export * from './Hide'; 8 | export * from './Info'; 9 | export * from './Menu'; 10 | export * from './MultiPage'; 11 | export * from './RotateRight'; 12 | export * from './Search'; 13 | export * from './Show'; 14 | export * from './SinglePage'; 15 | export * from './Success'; 16 | export * from './Warning'; 17 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | // Theme variables. 2 | @import './styles/theme'; 3 | 4 | // Style variables. 5 | @import './styles/variables'; 6 | 7 | // Mixins. 8 | @import './styles/mixins'; 9 | 10 | // Mixins. 11 | @import './styles/breakpoints'; 12 | 13 | // Base style. 14 | @import './styles/base'; 15 | 16 | // Global constants style. 17 | @import './styles/globals'; 18 | 19 | /** 20 | * Component style imports: DO NOT MODIFY. 21 | */ 22 | 23 | // <import-start> 24 | @import './components/Button/Button'; 25 | @import './components/ButtonGroup/ButtonGroup'; 26 | @import './components/Choice/Choice'; 27 | @import './components/ClickableDiv/ClickableDiv'; 28 | @import './components/DragLayer/DragLayer'; 29 | @import './components/Draggable/Draggable'; 30 | @import './components/EditableText/EditableText'; 31 | @import './components/FileOrganizer/FileOrganizer'; 32 | @import './components/FilePicker/FilePicker'; 33 | @import './components/FilePlaceholder/FilePlaceholder'; 34 | @import './components/FileSkeleton/FileSkeleton'; 35 | @import './components/FocusTrap/FocusTrap'; 36 | @import './components/Icon/Icon'; 37 | @import './components/IconButton/IconButton'; 38 | @import './components/Image/Image'; 39 | @import './components/Input/Input'; 40 | @import './components/Label/Label'; 41 | @import './components/Modal/Modal'; 42 | @import './components/Overlay/Overlay'; 43 | @import './components/Spinner/Spinner'; 44 | @import './components/Thumbnail/Thumbnail'; 45 | @import './components/ThumbnailDragLayer/ThumbnailDragLayer'; 46 | @import './components/ThumbnailSkeleton/ThumbnailSkeleton'; 47 | @import './components/Toast/Toast'; 48 | @import './components/ToastProvider/ToastProvider'; 49 | @import './components/ToolButton/ToolButton'; 50 | // <import-end> 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | export * from './data'; 3 | export * from './hooks'; 4 | export * from './icons'; 5 | export * from './utils'; 6 | -------------------------------------------------------------------------------- /src/internal.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: string; 3 | export default content; 4 | } 5 | 6 | declare module '*.md' { 7 | const content: string; 8 | export default content; 9 | } 10 | 11 | declare module '*.png' { 12 | const content: string; 13 | export default content; 14 | } 15 | -------------------------------------------------------------------------------- /src/storybook-helpers/action/action.ts: -------------------------------------------------------------------------------- 1 | import { action as _action, ActionOptions } from '@storybook/addon-actions'; 2 | import { SyntheticEvent } from 'react'; 3 | 4 | /** 5 | * Async actions for increased performance. Doesn't block thread. 6 | * @param name Action name. Should match your prop name. 7 | * @param options Options for the action. 8 | */ 9 | export function action(name: string, options?: ActionOptions) { 10 | const primedAction = _action(name, options); 11 | return (event?: SyntheticEvent<HTMLElement> | any, ...args: any[]) => { 12 | if (event && event.nativeEvent) event = event.nativeEvent; 13 | setTimeout(() => primedAction(event, ...args)); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/storybook-helpers/data/files.ts: -------------------------------------------------------------------------------- 1 | import { Core } from '@pdftron/webviewer'; 2 | import { FileLike } from '../../data/file'; 3 | import { FuturableOrLazy } from '../../data/futurable'; 4 | import { MemoizedPromise } from '../../data/memoizedPromise'; 5 | import testPdfThumbnailRotated from '../images/pdf-preview-2.png'; 6 | import testPdfThumbnail from '../images/pdf-preview.png'; 7 | 8 | export interface CreateFileOptions { 9 | lazy?: boolean; // Expensive operation 10 | error?: boolean; 11 | } 12 | 13 | export function createFile(index: number, options: CreateFileOptions = {}) { 14 | return new FakeFile(index, options); 15 | } 16 | 17 | export class FakeFile implements FileLike { 18 | id: string; 19 | name: string; 20 | originalName: string; 21 | extension: string; 22 | thumbnail: MemoizedPromise<string>; 23 | fileObj: MemoizedPromise<Blob>; 24 | documentObj: MemoizedPromise<Core.Document>; 25 | fullDocumentObj: Core.Document | undefined; 26 | pageNumber: number | undefined; 27 | 28 | constructor(index: number, options: CreateFileOptions = {}) { 29 | this.id = `file_${index + 1}`; 30 | this.name = `file_${index + 1}.pdf`; 31 | this.originalName = `file_${index + 1}`; 32 | this.extension = 'pdf'; 33 | this.thumbnail = this._getParameter(index % 2 ? testPdfThumbnailRotated : testPdfThumbnail, options); 34 | this.fileObj = new MemoizedPromise(new Blob()); 35 | this.documentObj = new MemoizedPromise<Core.Document>(('' as unknown) as Core.Document); 36 | } 37 | 38 | private _getParameter<T>(parameter: T, options: CreateFileOptions) { 39 | const internals = (() => { 40 | if (options.lazy) return () => new Promise(res => setTimeout(() => res(parameter), 500)); 41 | if (options.error) return () => new Promise((_res, rej) => setTimeout(() => rej('Some error.'), 500)); 42 | return parameter; 43 | })(); 44 | return new MemoizedPromise(internals as FuturableOrLazy<T>); 45 | } 46 | 47 | subscribe() { 48 | return () => {}; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/storybook-helpers/images/pdf-preview-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApryseSDK/webviewer-react-toolkit/a96e3be370d02d7c9c7144eaf279c6d39f1574d2/src/storybook-helpers/images/pdf-preview-2.png -------------------------------------------------------------------------------- /src/storybook-helpers/images/pdf-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApryseSDK/webviewer-react-toolkit/a96e3be370d02d7c9c7144eaf279c6d39f1574d2/src/storybook-helpers/images/pdf-preview.png -------------------------------------------------------------------------------- /src/storybook-helpers/knobs/forwardAction.ts: -------------------------------------------------------------------------------- 1 | import { action, ActionOptions } from '@storybook/addon-actions'; 2 | 3 | export function forwardAction<T extends any[]>(name: string, callback: (...args: T) => any, options?: ActionOptions) { 4 | return (...args: T) => { 5 | action(name, options)(...args); 6 | return callback(...args); 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/storybook-helpers/knobs/integer.ts: -------------------------------------------------------------------------------- 1 | import { number } from '@storybook/addon-knobs'; 2 | 3 | export function integer(name: string, value: number, groupId?: string) { 4 | const num = number(name, value, undefined, groupId); 5 | if (!Number.isInteger(num) || !Number.isFinite(num) || num < 0) return undefined; 6 | return num; 7 | } 8 | -------------------------------------------------------------------------------- /src/storybook-helpers/knobs/selectIcon.ts: -------------------------------------------------------------------------------- 1 | import { select } from '@storybook/addon-knobs'; 2 | import { AvailableIcons } from '../../components'; 3 | import * as icons from '../../icons'; 4 | 5 | const availableIcons = Object.keys(icons) as AvailableIcons[]; 6 | 7 | export function selectIcon(name: string, value: AvailableIcons, groupId?: string): AvailableIcons { 8 | return select(name, availableIcons, value, groupId); 9 | } 10 | -------------------------------------------------------------------------------- /src/storybook-helpers/mdx/LinkTo.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import Link from '@storybook/addon-links/react'; 3 | import React, { FC } from 'react'; 4 | 5 | export const LinkTo: FC<{ title: string; story?: string }> = ({ children, title, story = 'page' }) => { 6 | return ( 7 | <Link 8 | style={{ cursor: 'pointer', display: 'inline', color: '#00a3e3', textDecoration: 'none' }} 9 | kind={title} 10 | story={story} 11 | > 12 | {children} 13 | </Link> 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/storybook-helpers/mdx/index.ts: -------------------------------------------------------------------------------- 1 | export { LinkTo } from './LinkTo'; 2 | -------------------------------------------------------------------------------- /src/storybook-helpers/theme/font.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | fontBase: 'Lato,Tahoma,sans-serif', 3 | fontCode: '"IBM Plex Mono","Fira Code Retina","Fira Code","FiraCode-Retina",monospace', 4 | }; 5 | -------------------------------------------------------------------------------- /src/styles/_base.scss: -------------------------------------------------------------------------------- 1 | // Scoped to base class at the root of each component. 2 | .ui__base { 3 | // Scope normalize css into the base. 4 | @import '~normalize.css'; 5 | 6 | box-sizing: border-box; 7 | font-size: $font-size-default; 8 | line-height: 1.25; 9 | font-family: inherit; 10 | color: $color-font-primary; 11 | fill: $color-font-primary; 12 | font-weight: $font-weight-normal; 13 | font-family: $font-family; 14 | -webkit-font-smoothing: auto; 15 | -moz-osx-font-smoothing: grayscale; 16 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 17 | -webkit-overflow-scrolling: touch; 18 | 19 | *, 20 | *:before, 21 | *:after { 22 | box-sizing: inherit; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/styles/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | $breakpoint-tablet: 768px; 2 | $breakpoint-desktop: 1024px; 3 | 4 | // Try not to use this, as it breaks "mobile first" principles. Applies only to mobile devices. 5 | @mixin for-phone-only { 6 | @media (max-width: $breakpoint-tablet - 1) { 7 | @content; 8 | } 9 | } 10 | 11 | // Style applies to devices with width tablet or above. 12 | @mixin for-tablet-up { 13 | @media (min-width: $breakpoint-tablet) { 14 | @content; 15 | } 16 | } 17 | 18 | // Style applies to devices with width desktop or above. 19 | @mixin for-desktop-up { 20 | @media (min-width: $breakpoint-desktop) { 21 | @content; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/styles/_globals.scss: -------------------------------------------------------------------------------- 1 | $thumbnail-dimensions: 240px; 2 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Core } from '@pdftron/webviewer'; 2 | 3 | export {}; // Required to indicate that the file is a module. 4 | 5 | declare global { 6 | interface Window { 7 | Core: typeof Core; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/constantUtils.ts: -------------------------------------------------------------------------------- 1 | /** The default throttle timeout for lazy async items. */ 2 | export const DEFAULT_THROTTLE_TIMEOUT = 500; 3 | 4 | /** The width of a thumbnail element. */ 5 | export const THUMBNAIL_WIDTH = 240; 6 | 7 | /** An object with a string `id` attribute. */ 8 | export type ObjectWithId = { id: string }; 9 | -------------------------------------------------------------------------------- /src/utils/debugUtils.ts: -------------------------------------------------------------------------------- 1 | export function logExecTime(tag: string): () => number { 2 | const now = performance.now(); 3 | return () => { 4 | const now2 = performance.now(); 5 | console.log(`${tag} took ${now2 - now}ms`); 6 | return now2 - now; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the extension of a filename. 3 | */ 4 | export const getExtension = (filename = '') => { 5 | const split = filename.split('.'); 6 | return split.pop() as string; 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/gridUtils.ts: -------------------------------------------------------------------------------- 1 | export function getItemIndex(rowIndex: number, columnIndex: number, numColumns: number) { 2 | return rowIndex * numColumns + columnIndex; 3 | } 4 | 5 | export function getRowAndColumnIndex(index: number, numColumns: number) { 6 | return { 7 | rowIndex: Math.floor(index / numColumns), 8 | columnIndex: index % numColumns, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/idUtils.ts: -------------------------------------------------------------------------------- 1 | let currentId = 0; 2 | 3 | /** 4 | * Generates a sequential string to use as a unique identifier. This should be 5 | * used over `getId` if you need to use it as a DOM id, or a React key. 6 | * 7 | * @param prefix Optional. Prefix for the string id. 8 | */ 9 | export function getStringId(prefix = 'id'): string { 10 | return `${prefix}_${(currentId++).toString(16)}`; 11 | } 12 | 13 | export type UniqueIdentifier = symbol | string; 14 | 15 | /** 16 | * Returns a Symbol to uniquely identify something. Will fallback to using 17 | * string. 18 | * 19 | * @param description Description of the Symbol. Used as string prefix if not supported. 20 | */ 21 | export function getId(description?: string | number): UniqueIdentifier { 22 | if (typeof Symbol === 'function') return Symbol(description); 23 | return `${description}_${getStringId()}`; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './arrayUtils'; 2 | export * from './constantUtils'; 3 | export * from './domUtils'; 4 | export * from './fileUtils'; 5 | export * from './gridUtils'; 6 | export * from './idUtils'; 7 | export * from './typeUtils'; 8 | export * from './webviewerUtils'; 9 | -------------------------------------------------------------------------------- /src/utils/typeUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove the specified Keys of T. Like Omit, but with autocompletion. 3 | * 4 | * @example 5 | * type SomeProps = {field1: string; field2: string}; 6 | * const x: Remove<SomeProps, 'field2'> = {field1: 'Hello, World!'}; 7 | */ 8 | export type Remove<T, Keys extends keyof T> = Pick<T, Exclude<keyof T, Keys>>; 9 | 10 | /** 11 | * Include only the specified Keys of T. 12 | * 13 | * @example 14 | * type SomeProps = {field1: string; field2: string}; 15 | * const x: Include<SomeProps, 'field2'> = {field2: 'Hello, World!'}; 16 | */ 17 | export type Include<T, Keys extends keyof T> = Pick<T, Extract<keyof T, Keys>>; 18 | 19 | /** 20 | * Extract the common item type in array of similar items T. 21 | * 22 | * @example 23 | * const x = [{a: 'a', b: 'b'}, {a: 'aa', b: 'bb'}]; 24 | * const y: ItemOf<typeof x> = {a: 'Hello'; b: 'World'}; 25 | */ 26 | export type ItemOf<T extends any[]> = T[number]; 27 | 28 | /** 29 | * Extracts an array of argument types of T. 30 | * 31 | * @example 32 | * const x = (a: string, b: number) => a + b; 33 | * const x: ArgumentTypes<typeof x> = ['Hello, World!', 100]; 34 | */ 35 | // eslint-disable-next-line @typescript-eslint/ban-types 36 | export type ArgumentTypes<T extends Function> = T extends (...args: infer A) => any ? A : never; 37 | 38 | /** 39 | * Requires at least one of the specified Keys of T. If no keys provided, will 40 | * require at least one of any keyof T. 41 | * 42 | * @example 43 | * type SomeProps = {field1?: string; field2?: string}; 44 | * const x: RequireAtLeastOne<SomeProps, 'hello' | 'world'> = {field1: 'Hello, World!'}; 45 | */ 46 | export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Remove<T, Keys> & 47 | { [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>> }[Keys]; 48 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./configs/tsconfig", 3 | "include": ["./src"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./configs/tsconfig", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /webpack.style.config.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL } from './webpack.config'; 2 | import webpack from 'webpack'; 3 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 4 | 5 | const config: webpack.Configuration = { 6 | mode: 'production', 7 | watch: true, 8 | entry: [`./${BASE_URL}/index.scss`], 9 | plugins: [new MiniCssExtractPlugin({ filename: `css/style.css` })], 10 | resolve: { 11 | extensions: ['.scss'], 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.scss$/, 17 | use: [ 18 | { loader: MiniCssExtractPlugin.loader }, 19 | { loader: require.resolve('css-loader') }, 20 | { loader: require.resolve('postcss-loader') }, 21 | { loader: require.resolve('sass-loader') }, 22 | ], 23 | }, 24 | ], 25 | }, 26 | }; 27 | 28 | export default config; 29 | --------------------------------------------------------------------------------