├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── actions │ └── setup-node │ │ └── action.yml └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .node-version ├── .prettierignore ├── .scaffdog ├── config.js ├── ui-component.md └── ui-icon.md ├── .storybook ├── main.ts ├── preview-head.html └── preview.tsx ├── LICENSE ├── README.md ├── docs └── images │ ├── close.png │ ├── open.png │ └── viewer.png ├── eslint.config.js ├── index.html ├── package.json ├── public ├── actual │ ├── nest │ │ ├── deep │ │ │ └── sample.png │ │ └── sample.png │ ├── sample.1.png │ ├── sample.3.png │ ├── sample.png │ └── sample │ │ └── sample.png ├── detector.wasm ├── diff │ ├── nest │ │ ├── deep │ │ │ └── sample.png │ │ └── sample.png │ └── sample.png ├── expected │ ├── nest │ │ ├── deep │ │ │ └── sample.png │ │ └── sample.png │ ├── sample foo bar.png │ ├── sample.1.png │ ├── sample.2.png │ ├── sample.3.png │ └── sample.png └── worker.js ├── src ├── App │ ├── App.css.ts │ ├── App.tsx │ └── index.tsx ├── components │ ├── Card │ │ ├── Card.css.ts │ │ ├── Card.stories.tsx │ │ ├── Card.tsx │ │ └── index.ts │ ├── ChoiceGroup │ │ ├── ChoiceGroup.css.ts │ │ ├── ChoiceGroup.stories.tsx │ │ ├── ChoiceGroup.tsx │ │ ├── index.ts │ │ └── internal │ │ │ └── ChoiceButton │ │ │ ├── ChoiceButton.css.ts │ │ │ ├── ChoiceButton.tsx │ │ │ └── index.ts │ ├── Container │ │ ├── Container.css.ts │ │ ├── Container.tsx │ │ └── index.ts │ ├── Dialog │ │ ├── Dialog.css.ts │ │ ├── Dialog.stories.tsx │ │ ├── Dialog.tsx │ │ └── index.ts │ ├── Footer │ │ ├── Footer.css.ts │ │ ├── Footer.stories.tsx │ │ ├── Footer.tsx │ │ └── index.ts │ ├── Header │ │ ├── Header.css.ts │ │ ├── Header.stories.tsx │ │ ├── Header.tsx │ │ └── index.ts │ ├── HelpDialog │ │ ├── HelpDialog.css.ts │ │ ├── HelpDialog.stories.tsx │ │ ├── HelpDialog.tsx │ │ └── index.ts │ ├── IconButton │ │ ├── IconButton.css.ts │ │ ├── IconButton.stories.tsx │ │ ├── IconButton.tsx │ │ └── index.ts │ ├── Image │ │ ├── Image.css.ts │ │ ├── Image.stories.tsx │ │ ├── Image.tsx │ │ └── index.ts │ ├── Link │ │ ├── Link.tsx │ │ └── index.ts │ ├── List │ │ ├── Expandable.css.ts │ │ ├── Expandable.tsx │ │ ├── Item.css.ts │ │ ├── Item.tsx │ │ ├── List.css.ts │ │ ├── List.stories.tsx │ │ ├── List.tsx │ │ └── index.ts │ ├── Logo │ │ ├── Logo.css.ts │ │ ├── Logo.stories.tsx │ │ ├── Logo.tsx │ │ └── index.ts │ ├── Main │ │ ├── Main.css.ts │ │ ├── Main.tsx │ │ └── index.ts │ ├── Menu │ │ ├── Item.css.ts │ │ ├── Item.tsx │ │ ├── Menu.css.ts │ │ ├── Menu.stories.tsx │ │ ├── Menu.tsx │ │ └── index.ts │ ├── Notification │ │ ├── Notification.css.ts │ │ ├── Notification.stories.tsx │ │ ├── Notification.tsx │ │ └── index.tsx │ ├── PoweredBy │ │ ├── PoweredBy.css.ts │ │ ├── PoweredBy.stories.tsx │ │ ├── PoweredBy.tsx │ │ └── index.ts │ ├── SearchBox │ │ ├── SearchBox.css.ts │ │ ├── SearchBox.stories.tsx │ │ ├── SearchBox.tsx │ │ └── index.ts │ ├── Sidebar │ │ ├── Sidebar.tsx │ │ ├── index.ts │ │ ├── internal │ │ │ ├── Desktop │ │ │ │ ├── Desktop.css.ts │ │ │ │ ├── Desktop.tsx │ │ │ │ └── index.ts │ │ │ ├── Mobile │ │ │ │ ├── Mobile.css.ts │ │ │ │ ├── Mobile.tsx │ │ │ │ └── index.ts │ │ │ ├── SidebarInner │ │ │ │ ├── SidebarInner.css.ts │ │ │ │ ├── SidebarInner.tsx │ │ │ │ └── index.ts │ │ │ ├── Summary │ │ │ │ ├── Summary.tsx │ │ │ │ └── index.ts │ │ │ └── Toggle │ │ │ │ ├── Toggle.css.ts │ │ │ │ ├── Toggle.stories.tsx │ │ │ │ ├── Toggle.tsx │ │ │ │ └── index.ts │ │ └── types.ts │ ├── Sign │ │ ├── Sign.css.ts │ │ ├── Sign.stories.tsx │ │ ├── Sign.tsx │ │ └── index.ts │ ├── Slider │ │ ├── Slider.css.ts │ │ ├── Slider.stories.tsx │ │ ├── Slider.tsx │ │ └── index.ts │ ├── Snackbar │ │ ├── Snackbar.css.ts │ │ ├── Snackbar.stories.tsx │ │ ├── Snackbar.tsx │ │ └── index.ts │ ├── Spacer │ │ ├── Spacer.css.ts │ │ ├── Spacer.stories.tsx │ │ ├── Spacer.tsx │ │ └── index.ts │ ├── Spinner │ │ ├── Spinner.stories.tsx │ │ ├── Spinner.tsx │ │ └── index.ts │ ├── Switch │ │ ├── Switch.css.ts │ │ ├── Switch.stories.tsx │ │ ├── Switch.tsx │ │ └── index.ts │ ├── VGrid │ │ ├── VGrid.tsx │ │ └── index.ts │ ├── Viewer │ │ ├── Viewer.css.ts │ │ ├── Viewer.stories.tsx │ │ ├── Viewer.tsx │ │ ├── constants.ts │ │ ├── index.tsx │ │ └── internal │ │ │ └── ComparisonView │ │ │ ├── Blend.css.ts │ │ │ ├── Blend.tsx │ │ │ ├── ComparisonView.css.ts │ │ │ ├── ComparisonView.stories.tsx │ │ │ ├── ComparisonView.tsx │ │ │ ├── Diff.tsx │ │ │ ├── Markers.css.ts │ │ │ ├── Markers.tsx │ │ │ ├── Slide.css.ts │ │ │ ├── Slide.tsx │ │ │ ├── Toggle.css.ts │ │ │ ├── Toggle.tsx │ │ │ ├── TwoUp.css.ts │ │ │ ├── TwoUp.tsx │ │ │ ├── index.ts │ │ │ └── useComparisonImage.ts │ ├── VisuallyHidden │ │ ├── VisuallyHidden.css.ts │ │ ├── VisuallyHidden.stories.tsx │ │ ├── VisuallyHidden.tsx │ │ └── index.ts │ ├── icons │ │ ├── ArrowDownIcon.tsx │ │ ├── ArrowLeftIcon.tsx │ │ ├── ArrowRightIcon.tsx │ │ ├── ArrowUpIcon.tsx │ │ ├── CloseIcon.tsx │ │ ├── HelpIcon.tsx │ │ ├── LinkIcon.tsx │ │ ├── MoreIcon.tsx │ │ ├── SearchIcon.tsx │ │ ├── SignChangedIcon.tsx │ │ ├── SignDeletedIcon.tsx │ │ ├── SignNewIcon.tsx │ │ └── SignPassedIcon.tsx │ └── internal │ │ ├── BaseButton │ │ ├── BaseButton.css.ts │ │ ├── BaseButton.tsx │ │ └── index.ts │ │ ├── Collapse │ │ ├── Collapse.css.ts │ │ ├── Collapse.stories.tsx │ │ ├── Collapse.tsx │ │ └── index.ts │ │ ├── Ellipsis │ │ ├── Ellipsis.css.ts │ │ ├── Ellipsis.tsx │ │ └── index.ts │ │ ├── Portal │ │ ├── Portal.stories.tsx │ │ ├── Portal.tsx │ │ └── index.ts │ │ └── Transparent │ │ ├── Transparent.css.ts │ │ ├── Transparent.tsx │ │ └── index.ts ├── constants.ts ├── context │ ├── AnchorScrollContext.tsx │ ├── HistoryContext.tsx │ └── WorkerContext.ts ├── detector-wrapper │ ├── index.ts │ └── util.ts ├── global.css ├── hooks │ ├── useHistory.ts │ ├── useKey.ts │ ├── useMedia.ts │ ├── useMergeRefs.ts │ └── usePrevious.ts ├── index.tsx ├── mocks.ts ├── states │ ├── entity.ts │ ├── notification.ts │ ├── sidebar.ts │ └── worker.ts ├── styles │ └── variables.css.ts ├── supports.ts ├── types │ ├── event.ts │ ├── reg.ts │ └── store.ts ├── utils │ ├── __tests__ │ │ └── transformer.test.ts │ ├── focus.ts │ ├── selector.ts │ ├── transformer.ts │ └── types.ts ├── worker-client.ts └── worker-main.ts ├── tsconfig.json ├── types ├── .gitkeep └── resize-observer.d.ts ├── vite.config.source.js ├── vite.config.worker.js └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Describe the bug 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | ## Reproduced step 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ## Actual behavior 27 | 28 | A clear and concise description of what you actual to happen. 29 | 30 | ## Screenshots 31 | 32 | If applicable, add screenshots to help explain your problem. 33 | 34 | ## Desktop (please complete the following information) 35 | 36 | - OS: [e.g. iOS] 37 | - Browser [e.g. chrome, safari] 38 | - Version [e.g. 22] 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## Is your feature request related to a problem? Please describe. 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ## Describe the solution you'd like 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | ## Describe alternatives you've considered 18 | 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | ## Additional context 22 | 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What does this change? 2 | 3 | A clear and concise description of what the changes is. 4 | 5 | ## References 6 | 7 | - If you have links to other resources, please list them here. (e.g. issue url, related pull request url, documents) 8 | 9 | ## Screenshots 10 | 11 | If applicable, add screenshots to help explain your changes. 12 | 13 | ## What can I check for bug fixes? 14 | 15 | Please briefly describe how you can confirm the resolution of the bug. 16 | -------------------------------------------------------------------------------- /.github/actions/setup-node/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Node 2 | 3 | runs: 4 | using: 'composite' 5 | steps: 6 | - uses: actions/setup-node@v4 7 | with: 8 | node-version-file: '.node-version' 9 | cache: 'yarn' 10 | 11 | - shell: bash 12 | run: yarn --frozen-lockfile --check-files 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | setup: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: ./.github/actions/setup-node 15 | 16 | test: 17 | runs-on: ubuntu-latest 18 | needs: [setup] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: ./.github/actions/setup-node 22 | - run: yarn test 23 | 24 | lint: 25 | runs-on: ubuntu-latest 26 | needs: [setup] 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: ./.github/actions/setup-node 30 | - run: yarn lint 31 | 32 | typecheck: 33 | runs-on: ubuntu-latest 34 | needs: [setup] 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: ./.github/actions/setup-node 38 | - run: yarn typecheck 39 | 40 | build: 41 | runs-on: ubuntu-latest 42 | needs: [setup] 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: ./.github/actions/setup-node 46 | - run: yarn build 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | public/cv-wasm_* 3 | public/worker-dev.js 4 | 5 | # Created by https://www.gitignore.io/api/osx,windows,node 6 | # Edit at https://www.gitignore.io/?templates=osx,windows,node 7 | 8 | ### Node ### 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | 68 | # parcel-bundler cache (https://parceljs.org/) 69 | .cache 70 | 71 | # next.js build output 72 | .next 73 | 74 | # nuxt.js build output 75 | .nuxt 76 | 77 | # vuepress build output 78 | .vuepress/dist 79 | 80 | # Serverless directories 81 | .serverless 82 | 83 | # FuseBox cache 84 | .fusebox/ 85 | 86 | ### OSX ### 87 | # General 88 | .DS_Store 89 | .AppleDouble 90 | .LSOverride 91 | 92 | # Icon must end with two \r 93 | Icon 94 | 95 | # Thumbnails 96 | ._* 97 | 98 | # Files that might appear in the root of a volume 99 | .DocumentRevisions-V100 100 | .fseventsd 101 | .Spotlight-V100 102 | .TemporaryItems 103 | .Trashes 104 | .VolumeIcon.icns 105 | .com.apple.timemachine.donotpresent 106 | 107 | # Directories potentially created on remote AFP share 108 | .AppleDB 109 | .AppleDesktop 110 | Network Trash Folder 111 | Temporary Items 112 | .apdisk 113 | 114 | ### Windows ### 115 | # Windows thumbnail cache files 116 | Thumbs.db 117 | ehthumbs.db 118 | ehthumbs_vista.db 119 | 120 | # Dump file 121 | *.stackdump 122 | 123 | # Folder config file 124 | [Dd]esktop.ini 125 | 126 | # Recycle Bin used on file shares 127 | $RECYCLE.BIN/ 128 | 129 | # Windows Installer files 130 | *.cab 131 | *.msi 132 | *.msix 133 | *.msm 134 | *.msp 135 | 136 | # Windows shortcuts 137 | *.lnk 138 | 139 | # End of https://www.gitignore.io/api/osx,windows,node 140 | 141 | *storybook.log 142 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v18.19.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.scaffdog/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | files: ['./*'], 3 | }; 4 | -------------------------------------------------------------------------------- /.scaffdog/ui-component.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'ui-component' 3 | root: 'src/components' 4 | output: '**/*' 5 | ignore: 6 | - 'src/components/icons' 7 | - '**/{A..Z}*' 8 | - '**/__tests__' 9 | questions: 10 | name: 'Please enter a component name.' 11 | --- 12 | 13 | # Variables 14 | 15 | - name: `{{ inputs.name | pascal }}` 16 | 17 | # `{{ name }}/index.ts` 18 | 19 | ```typescript 20 | export * from './{{ name }}'; 21 | ``` 22 | 23 | # `{{ name }}/{{ name }}.tsx` 24 | 25 | ```typescript 26 | import React from 'react'; 27 | import * as styles from './{{ name }}.css'; 28 | 29 | export type Props = React.PropsWithChildren<{}>; 30 | 31 | export const {{ name }} = ({ children, ...rest }: Props) => { 32 | return ( 33 |
{children}
34 | ); 35 | }; 36 | ``` 37 | 38 | # `{{ name }}/{{ name }}.css.ts` 39 | 40 | ```typescript 41 | import { style } from '@vanilla-extract/css'; 42 | 43 | export const wrapper = style({ 44 | // TODO: Add styles 45 | }); 46 | ``` 47 | 48 | # `{{ name }}/{{ name }}.stories.tsx` 49 | 50 | ```typescript 51 | import type { Meta, StoryObj } from '@storybook/react'; 52 | import { {{ name }} } from './'; 53 | 54 | type Component = typeof {{ name }}; 55 | type Story = StoryObj; 56 | 57 | export default { 58 | component: {{ name }}, 59 | args: {}, 60 | } satisfies Meta; 61 | 62 | export const Overview: Story = {}; 63 | ``` 64 | -------------------------------------------------------------------------------- /.scaffdog/ui-icon.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'ui-icon' 3 | root: 'src/components/icons' 4 | output: '.' 5 | questions: 6 | name: 'Please enter a icon name.' 7 | --- 8 | 9 | # Variables 10 | 11 | - name: `{{ inputs.name | pascal }}Icon` 12 | 13 | # `{{ name }}.tsx` 14 | 15 | ```typescript 16 | import React from 'react'; 17 | 18 | export type Props = React.ComponentProps<'svg'>; 19 | 20 | export const {{ name }} = ({ fill, ...rest }: Props) => ( 21 | 22 | {/* FIXME */} 23 | 24 | 25 | ); 26 | ``` 27 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; 3 | 4 | const config: StorybookConfig = { 5 | stories: ['../src/**/*.stories.tsx'], 6 | addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], 7 | framework: { 8 | name: '@storybook/react-vite', 9 | options: {}, 10 | }, 11 | docs: { 12 | autodocs: false, 13 | }, 14 | async viteFinal(config) { 15 | const { mergeConfig } = await import('vite'); 16 | 17 | return mergeConfig(config, { 18 | plugins: [ 19 | vanillaExtractPlugin({ 20 | identifiers: 'debug', 21 | }), 22 | ], 23 | }); 24 | }, 25 | }; 26 | 27 | export default config; 28 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import '../src/global.css'; 2 | import React from 'react'; 3 | import type { Preview } from '@storybook/react'; 4 | import { HistoryContextProvider } from '../src/context/HistoryContext'; 5 | 6 | const preview: Preview = { 7 | decorators: [ 8 | (Story) => ( 9 | 10 | 11 | 12 | ), 13 | ], 14 | }; 15 | 16 | export default preview; 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 reg-viz. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reg-cli-report-ui 2 | 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/reg-viz/reg-cli-report-ui/ci.yml?branch=main&style=flat-square&link=https%3A%2F%2Fgithub.com%2Freg-viz%2Freg-cli-report-ui%2Factions%2Fworkflows%2Fci.yml) 4 | 5 | > :gem: New face of reg-cli report UI. 6 | 7 | This repository is the Report UI of [reg-viz/reg-cli][reg-cli] 8 | 9 | ## Screenshots 10 | 11 | ![open](./docs/images/open.png) 12 | ![close](./docs/images/close.png) 13 | ![viewer](./docs/images/viewer.png) 14 | 15 | ## Available scripts 16 | 17 | The following list is scripts used during development. 18 | 19 | ### `yarn dev` 20 | 21 | ```bash 22 | $ yarn dev 23 | ``` 24 | 25 | Launch the Report UI with the mock data. 26 | 27 | ### `yarn build` 28 | 29 | ```bash 30 | $ yarn build 31 | ``` 32 | 33 | Generate the JavaScript file required to embed Report UI. This is usually called in [reg-viz/reg-cli][reg-cli]. 34 | 35 | ### `yarn typecheck` 36 | 37 | ```bash 38 | $ yarn typecheck 39 | ``` 40 | 41 | Run static type checking using TypeScript. 42 | 43 | ### `yarn lint` 44 | 45 | ```bash 46 | $ yarn lint 47 | ``` 48 | 49 | Run Lint with ESLint. 50 | 51 | ### `yarn format` 52 | 53 | ```bash 54 | $ yarn format 55 | ``` 56 | 57 | Format the source code using Prettier and ESLint. 58 | 59 | ### `yarn storybook` 60 | 61 | ```bash 62 | $ yarn storybook 63 | ``` 64 | 65 | Launch the component catalog with Storybook. 66 | 67 | ### `yarn scaffold` 68 | 69 | ```bash 70 | $ yarn scaffold 71 | ``` 72 | 73 | Generate a component template. 74 | 75 | ## Contribute 76 | 77 | PRs welcome. 78 | 79 | ## License 80 | 81 | [MIT License @ reg-viz](./LICENSE) 82 | 83 | ![reg-viz](https://raw.githubusercontent.com/reg-viz/artwork/master/repository/footer.png) 84 | 85 | [reg-cli]: https://github.com/reg-viz/reg-cli 86 | -------------------------------------------------------------------------------- /docs/images/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/docs/images/close.png -------------------------------------------------------------------------------- /docs/images/open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/docs/images/open.png -------------------------------------------------------------------------------- /docs/images/viewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/docs/images/viewer.png -------------------------------------------------------------------------------- /public/actual/nest/deep/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/actual/nest/deep/sample.png -------------------------------------------------------------------------------- /public/actual/nest/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/actual/nest/sample.png -------------------------------------------------------------------------------- /public/actual/sample.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/actual/sample.1.png -------------------------------------------------------------------------------- /public/actual/sample.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/actual/sample.3.png -------------------------------------------------------------------------------- /public/actual/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/actual/sample.png -------------------------------------------------------------------------------- /public/actual/sample/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/actual/sample/sample.png -------------------------------------------------------------------------------- /public/detector.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/detector.wasm -------------------------------------------------------------------------------- /public/diff/nest/deep/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/diff/nest/deep/sample.png -------------------------------------------------------------------------------- /public/diff/nest/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/diff/nest/sample.png -------------------------------------------------------------------------------- /public/diff/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/diff/sample.png -------------------------------------------------------------------------------- /public/expected/nest/deep/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/expected/nest/deep/sample.png -------------------------------------------------------------------------------- /public/expected/nest/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/expected/nest/sample.png -------------------------------------------------------------------------------- /public/expected/sample foo bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/expected/sample foo bar.png -------------------------------------------------------------------------------- /public/expected/sample.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/expected/sample.1.png -------------------------------------------------------------------------------- /public/expected/sample.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/expected/sample.2.png -------------------------------------------------------------------------------- /public/expected/sample.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/expected/sample.3.png -------------------------------------------------------------------------------- /public/expected/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/public/expected/sample.png -------------------------------------------------------------------------------- /public/worker.js: -------------------------------------------------------------------------------- 1 | self.wasmUrl = '/cv-wasm_browser.wasm'; 2 | 3 | importScripts('/worker-dev.js', '/cv-wasm_browser.js'); 4 | -------------------------------------------------------------------------------- /src/App/App.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens } from '../styles/variables.css'; 3 | 4 | export const brand = style({ 5 | position: 'absolute', 6 | top: Space * 3, 7 | right: Space * 3, 8 | }); 9 | 10 | export const layout = style({ 11 | display: 'flex', 12 | height: '100%', 13 | isolation: 'isolate', 14 | }); 15 | 16 | export const content = style({ 17 | flex: '1 0 auto', 18 | maxWidth: '100%', 19 | }); 20 | 21 | export const help = style({ 22 | position: 'fixed', 23 | right: Space * 3, 24 | bottom: Space * 3, 25 | borderRadius: '50%', 26 | background: tokens.color.brandSecondary, 27 | boxShadow: tokens.shadow.lv2, 28 | zIndex: 10, 29 | }); 30 | -------------------------------------------------------------------------------- /src/App/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef, useState } from 'react'; 2 | import { Footer } from '../components/Footer'; 3 | import { HelpDialog } from '../components/HelpDialog'; 4 | import { IconButton } from '../components/IconButton'; 5 | import { Logo } from '../components/Logo'; 6 | import { Main } from '../components/Main'; 7 | import { Notification } from '../components/Notification'; 8 | import { Sidebar } from '../components/Sidebar'; 9 | import { Viewer } from '../components/Viewer'; 10 | import { HelpIcon } from '../components/icons/HelpIcon'; 11 | import { useKey } from '../hooks/useKey'; 12 | import { useEntities } from '../states/entity'; 13 | import { useSidebarMutators } from '../states/sidebar'; 14 | import { Color } from '../styles/variables.css'; 15 | import { findFirstFocusable } from '../utils/selector'; 16 | import * as styles from './App.css'; 17 | 18 | export type Props = {}; 19 | 20 | export const App = () => { 21 | const { toggle: toggleSidebar } = useSidebarMutators(); 22 | 23 | const { newItems, failedItems, deletedItems, passedItems } = useEntities(); 24 | 25 | const [helpDialogOpen, setHelpDialogOpen] = useState(false); 26 | 27 | const filterRef = useRef(null); 28 | const listRef = useRef(null); 29 | 30 | const handleHelpClick = useCallback((e: React.MouseEvent) => { 31 | e.preventDefault(); 32 | setHelpDialogOpen(true); 33 | }, []); 34 | 35 | const handleHelpClose = useCallback(() => { 36 | setHelpDialogOpen(false); 37 | }, []); 38 | 39 | useKey(null, ['/', 's'], (e) => { 40 | e.preventDefault(); 41 | 42 | if (filterRef.current != null) { 43 | filterRef.current.focus(); 44 | } 45 | }); 46 | 47 | useKey(null, ['g s'], () => { 48 | const { current: list } = listRef; 49 | if (list == null) { 50 | return; 51 | } 52 | 53 | const first = findFirstFocusable(list); 54 | if (first == null) { 55 | return; 56 | } 57 | 58 | first.focus(); 59 | }); 60 | 61 | useKey(null, ['g c'], () => { 62 | if (failedItems.length > 0) { 63 | window.location.hash = 'changed'; 64 | } 65 | }); 66 | 67 | useKey(null, ['g n'], () => { 68 | if (newItems.length > 0) { 69 | window.location.hash = 'new'; 70 | } 71 | }); 72 | 73 | useKey(null, ['g d'], () => { 74 | if (deletedItems.length > 0) { 75 | window.location.hash = 'deleted'; 76 | } 77 | }); 78 | 79 | useKey(null, ['g p'], () => { 80 | if (passedItems.length > 0) { 81 | window.location.hash = 'passed'; 82 | } 83 | }); 84 | 85 | useKey(null, ['f'], () => { 86 | toggleSidebar(); 87 | }); 88 | 89 | useKey(null, ['Shift+?'], () => { 90 | setHelpDialogOpen(true); 91 | }); 92 | 93 | return ( 94 | <> 95 | 96 | 97 | 98 | 99 |
100 | 101 |
102 |
103 |
104 |
105 |
106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | ); 118 | }; 119 | -------------------------------------------------------------------------------- /src/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'jotai'; 2 | import React from 'react'; 3 | import { AnchorScrollProvider } from '../context/AnchorScrollContext'; 4 | import { HistoryContextProvider } from '../context/HistoryContext'; 5 | import type { Store } from '../types/store'; 6 | import { App as Component } from './App'; 7 | 8 | export type Props = { 9 | store: Store; 10 | }; 11 | 12 | export const App = ({ store }: Props) => ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /src/components/Card/Card.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { BreakPoint, Space, tokens } from '../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | position: 'relative', 6 | }); 7 | 8 | export const inner = style({ 9 | display: 'block', 10 | width: '100%', 11 | borderWidth: 0, 12 | borderRadius: 6, 13 | background: tokens.color.white, 14 | boxShadow: tokens.shadow.lv3, 15 | color: tokens.color.textBase, 16 | fontSize: 'inherit', 17 | textDecoration: 'none', 18 | cursor: 'pointer', 19 | transition: `box-shadow ${tokens.duration.fadeIn} ${tokens.easing.standard}`, 20 | ':hover': { 21 | boxShadow: tokens.shadow.lv1, 22 | }, 23 | ':focus-visible': { 24 | boxShadow: tokens.state.focus, 25 | }, 26 | }); 27 | 28 | export const sign = style({ 29 | position: 'absolute', 30 | top: Space * 1, 31 | left: Space * 1, 32 | zIndex: 10, 33 | }); 34 | 35 | export const image = style({ 36 | position: 'relative', 37 | overflow: 'hidden', 38 | height: '260px', 39 | borderRadius: '6px 6px 0 0', 40 | }); 41 | 42 | export const imageInner = style({ 43 | position: 'absolute', 44 | top: 0, 45 | right: 0, 46 | bottom: 0, 47 | left: 0, 48 | zIndex: 2, 49 | }); 50 | 51 | export const title = style({ 52 | padding: Space * 2, 53 | textAlign: 'left', 54 | '@media': { 55 | [`(min-width: ${BreakPoint.MEDIUM}px)`]: { 56 | padding: Space * 3, 57 | }, 58 | }, 59 | }); 60 | 61 | export const menu = style({ 62 | position: 'absolute', 63 | top: Space * 0.5, 64 | right: Space * 0.5, 65 | zIndex: 10, 66 | }); 67 | -------------------------------------------------------------------------------- /src/components/Card/Card.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { action } from '@storybook/addon-actions'; 3 | import { createRegEntity } from '../../mocks'; 4 | import { Card } from './'; 5 | 6 | const defaultEntity = createRegEntity({ 7 | id: 'id', 8 | variant: 'changed', 9 | name: 'filename.png', 10 | diff: 'https://via.placeholder.com/700x400?text=diff', 11 | before: 'https://via.placeholder.com/700x400?text=before', 12 | after: 'https://via.placeholder.com/700x400?text=after', 13 | }); 14 | 15 | type Component = typeof Card; 16 | type Story = StoryObj; 17 | 18 | export default { 19 | component: Card, 20 | args: { 21 | href: '?id=storybook', 22 | entity: defaultEntity, 23 | menus: [], 24 | onCopy: action('onCopy'), 25 | }, 26 | } satisfies Meta; 27 | 28 | export const Overview: Story = {}; 29 | 30 | export const WithLongName: Story = { 31 | args: { 32 | entity: { 33 | ...defaultEntity, 34 | name: 'abcdef'.repeat(20), 35 | }, 36 | }, 37 | }; 38 | 39 | export const WithNew: Story = { 40 | args: { 41 | entity: { 42 | ...defaultEntity, 43 | variant: 'new', 44 | }, 45 | }, 46 | }; 47 | 48 | export const WithDeleted: Story = { 49 | args: { 50 | entity: { 51 | ...defaultEntity, 52 | variant: 'deleted', 53 | }, 54 | }, 55 | }; 56 | 57 | export const WithPassed: Story = { 58 | args: { 59 | entity: { 60 | ...defaultEntity, 61 | variant: 'passed', 62 | }, 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/Card/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Card'; 2 | -------------------------------------------------------------------------------- /src/components/ChoiceGroup/ChoiceGroup.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { BreakPoint, Space, tokens } from '../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | display: 'grid', 6 | gridTemplateColumns: 'repeat(auto-fit, minmax(48px, 1fr))', 7 | gridGap: Space * 0.5, 8 | margin: 0, 9 | padding: Space * 0.5, 10 | borderRadius: '26px', 11 | background: tokens.color.white, 12 | boxShadow: tokens.shadow.lv2, 13 | listStyle: 'none', 14 | '@media': { 15 | [`(min-width: ${BreakPoint.SMALL}px)`]: { 16 | gridTemplateColumns: 'repeat(auto-fill, minmax(80px, 1fr))', 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/ChoiceGroup/ChoiceGroup.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import React, { useState } from 'react'; 3 | import type { Meta, StoryObj } from '@storybook/react'; 4 | import { Spacer } from '../Spacer'; 5 | import { ChoiceGroup } from './'; 6 | 7 | const options3 = [ 8 | { 9 | value: 'value1', 10 | label: 'Item 1', 11 | }, 12 | { 13 | value: 'value2', 14 | label: 'Item 2', 15 | }, 16 | { 17 | value: 'value3', 18 | label: 'Item 3', 19 | }, 20 | ]; 21 | 22 | const options4 = [ 23 | ...options3, 24 | { 25 | value: 'value4', 26 | label: 'Item 4', 27 | }, 28 | ]; 29 | 30 | type Component = typeof ChoiceGroup; 31 | type Story = StoryObj; 32 | 33 | export default { 34 | component: ChoiceGroup, 35 | args: { 36 | options: options3, 37 | onChange: action('onChange'), 38 | }, 39 | } satisfies Meta; 40 | 41 | export const Overview: Story = { 42 | render: () => { 43 | const [value, setValue] = useState(options3[1].value); 44 | 45 | return ( 46 | { 50 | setValue(val); 51 | }} 52 | /> 53 | ); 54 | }, 55 | }; 56 | 57 | export const WithSelected: Story = { 58 | render: (args) => ( 59 | <> 60 | 61 | 62 | 63 | 64 | 65 | 66 | ), 67 | }; 68 | 69 | export const With4Options: Story = { 70 | render: (args) => ( 71 | <> 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | ), 81 | }; 82 | -------------------------------------------------------------------------------- /src/components/ChoiceGroup/ChoiceGroup.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createRef, 3 | useCallback, 4 | useEffect, 5 | useMemo, 6 | useRef, 7 | } from 'react'; 8 | import { useKey } from '../../hooks/useKey'; 9 | import type { Modify } from '../../utils/types'; 10 | import * as styles from './ChoiceGroup.css'; 11 | import { ChoiceButton } from './internal/ChoiceButton'; 12 | 13 | const getValueIndex = (options: ChoiceOption[], value: string) => { 14 | return options.findIndex((opts) => opts.value === value); 15 | }; 16 | 17 | export type ChoiceOption = { 18 | value: string; 19 | label: React.ReactNode; 20 | }; 21 | 22 | export type Props = Modify< 23 | React.ComponentPropsWithoutRef<'ul'>, 24 | { 25 | value: string; 26 | options: ChoiceOption[]; 27 | onChange: (value: string, index: number) => void; 28 | } 29 | >; 30 | 31 | export const ChoiceGroup = ({ value, options, onChange, ...rest }: Props) => { 32 | const rootRef = useRef(null); 33 | 34 | const changedByKey = useRef(false); 35 | const buttonRefList = useMemo(() => { 36 | return options.map(() => 37 | createRef(), 38 | ); 39 | }, [options]); 40 | 41 | const handleItemClick = useCallback( 42 | (e: React.MouseEvent) => { 43 | e.preventDefault(); 44 | e.stopPropagation(); 45 | 46 | const { value: val } = e.currentTarget; 47 | 48 | onChange(val, getValueIndex(options, val)); 49 | }, 50 | [onChange, options], 51 | ); 52 | 53 | useEffect(() => { 54 | if (!changedByKey.current) { 55 | return; 56 | } 57 | 58 | const index = getValueIndex(options, value); 59 | const ref = buttonRefList[index]; 60 | if (ref != null && ref.current != null) { 61 | ref.current.focus(); 62 | } 63 | 64 | changedByKey.current = false; 65 | }, [buttonRefList, options, value]); 66 | 67 | useKey(rootRef, ['right', 'l'], (e) => { 68 | e.stopPropagation(); 69 | 70 | const current = getValueIndex(options, value); 71 | const next = current + 1; 72 | if (next >= options.length) { 73 | return; 74 | } 75 | 76 | changedByKey.current = true; 77 | onChange(options[next].value, next); 78 | }); 79 | 80 | useKey(rootRef, ['left', 'h'], (e) => { 81 | e.stopPropagation(); 82 | 83 | const current = getValueIndex(options, value); 84 | const previous = current - 1; 85 | if (previous < 0) { 86 | return; 87 | } 88 | 89 | changedByKey.current = true; 90 | onChange(options[previous].value, previous); 91 | }); 92 | 93 | return ( 94 |
    95 | {options.map((opts, index) => ( 96 |
  • 97 | 103 | {opts.label} 104 | 105 |
  • 106 | ))} 107 |
108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /src/components/ChoiceGroup/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChoiceGroup'; 2 | -------------------------------------------------------------------------------- /src/components/ChoiceGroup/internal/ChoiceButton/ChoiceButton.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from '@vanilla-extract/css'; 2 | import { 3 | BreakPoint, 4 | Space, 5 | tokens, 6 | typography, 7 | } from '../../../../styles/variables.css'; 8 | 9 | const SIZE = 44; 10 | 11 | const wrapperBase = style([ 12 | typography.button, 13 | { 14 | position: 'relative', 15 | display: 'flex', 16 | justifyContent: 'center', 17 | alignItems: 'center', 18 | width: '100%', 19 | height: `${SIZE}px`, 20 | padding: `0 ${Space * 1}px`, 21 | borderWidth: 0, 22 | borderRadius: `${SIZE}px`, 23 | background: 'transparent', 24 | textAlign: 'center', 25 | color: tokens.color.textBase, 26 | cursor: 'pointer', 27 | userSelect: 'auto', 28 | '::before': { 29 | position: 'absolute', 30 | top: 0, 31 | right: 0, 32 | bottom: 0, 33 | left: 0, 34 | zIndex: 0, 35 | display: 'block', 36 | borderRadius: `${SIZE}px`, 37 | background: tokens.color.brandPrimary, 38 | content: '""', 39 | opacity: 0, 40 | transition: `all ${tokens.duration.smallOut} ${tokens.easing.back}`, 41 | transform: 'scale(0.9)', 42 | }, 43 | ':hover': { 44 | background: tokens.color.hoverBlack, 45 | }, 46 | '@media': { 47 | [`(min-width: ${BreakPoint.SMALL}px)`]: { 48 | padding: `0 ${Space * 2}px`, 49 | }, 50 | }, 51 | }, 52 | ]); 53 | 54 | export const wrapper = styleVariants({ 55 | default: [wrapperBase, {}], 56 | active: [ 57 | wrapperBase, 58 | { 59 | color: tokens.color.white, 60 | cursor: 'default', 61 | userSelect: 'none', 62 | '::before': { 63 | opacity: 1, 64 | transform: 'scale(1)', 65 | }, 66 | }, 67 | ], 68 | }); 69 | 70 | export const inner = style({ 71 | position: 'relative', 72 | zIndex: 1, 73 | }); 74 | -------------------------------------------------------------------------------- /src/components/ChoiceGroup/internal/ChoiceButton/ChoiceButton.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import React, { forwardRef } from 'react'; 3 | import type { Modify } from '../../../../utils/types'; 4 | import type { Props as BaseButtonProps } from '../../../internal/BaseButton'; 5 | import { BaseButton } from '../../../internal/BaseButton'; 6 | import * as styles from './ChoiceButton.css'; 7 | 8 | export type Props = Modify< 9 | BaseButtonProps, 10 | { 11 | active?: boolean; 12 | } 13 | >; 14 | 15 | export const ChoiceButton = forwardRef< 16 | HTMLButtonElement | HTMLAnchorElement, 17 | Props 18 | >(({ active = false, children, ...rest }, ref) => ( 19 | 27 | {children} 28 | 29 | )); 30 | -------------------------------------------------------------------------------- /src/components/ChoiceGroup/internal/ChoiceButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChoiceButton'; 2 | -------------------------------------------------------------------------------- /src/components/Container/Container.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { BreakPoint, Space } from '../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | paddingRight: `${Space * 3}px`, 6 | paddingLeft: `${Space * 3}px`, 7 | '@media': { 8 | [`(min-width: ${BreakPoint.MEDIUM}px)`]: { 9 | paddingRight: `${Space * 5}px`, 10 | paddingLeft: `${Space * 5}px`, 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/Container/Container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Modify } from '../../utils/types'; 3 | import * as styles from './Container.css'; 4 | 5 | export type Props = Modify< 6 | React.HTMLAttributes, 7 | { 8 | children: React.ReactNode; 9 | } 10 | >; 11 | 12 | export const Container = ({ children, ...rest }: Props) => ( 13 |
14 | {children} 15 |
16 | ); 17 | -------------------------------------------------------------------------------- /src/components/Container/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Container'; 2 | -------------------------------------------------------------------------------- /src/components/Dialog/Dialog.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import React, { useState } from 'react'; 4 | import { Dialog } from './'; 5 | 6 | type Component = typeof Dialog; 7 | type Story = StoryObj; 8 | 9 | export default { 10 | component: Dialog, 11 | args: { 12 | id: 'dialog', 13 | open: true, 14 | title: 'Dialog', 15 | onRequestClose: action('onRequestClose'), 16 | }, 17 | } satisfies Meta; 18 | 19 | export const Overview: Story = { 20 | render: (args) => { 21 | const [open, setOpen] = useState(false); 22 | 23 | return ( 24 | <> 25 | 32 | 33 | { 37 | setOpen(false); 38 | }} 39 | > 40 |

Dialog content

41 |
42 | 43 | ); 44 | }, 45 | }; 46 | 47 | export const WithOpen: Story = { 48 | render: (args) => ( 49 | 50 |

51 | Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo 52 | ligula eget dolor. Aenean massa. 53 |

54 |

55 | Cum sociis natoque penatibus et magnis dis parturient montes, nascetur 56 | ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium 57 | quis, sem. 58 |

59 |

60 | Nulla consequat massa quis enim. Donec pede justo, fringilla vel, 61 | aliquet nec, vulputate eget, arcu. 62 |

63 |

64 | In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam 65 | dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. 66 |

67 |

68 | Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean 69 | leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. 70 |

71 |

72 | Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. 73 | Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean 74 | imperdiet. 75 |

76 |

77 | Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. 78 | Nam eget dui. Etiam rhoncus. 79 |

80 |

81 | Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper 82 | libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit 83 | vel, luctus pulvinar, hendrerit id, lorem. 84 |

85 |

86 | Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero 87 | venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros 88 | faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec 89 | sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue 90 | velit cursus nunc, 91 |

92 |
93 | ), 94 | }; 95 | -------------------------------------------------------------------------------- /src/components/Dialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Dialog'; 2 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens, typography } from '../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | padding: `${Space * 18}px 0 ${Space * 15}px`, 6 | textAlign: 'center', 7 | }); 8 | 9 | export const label = style([ 10 | typography.body2, 11 | { 12 | margin: `${Space * 2}px 0 0`, 13 | }, 14 | ]); 15 | 16 | export const link = style({ 17 | color: tokens.color.textLink, 18 | textDecoration: 'none', 19 | ':hover': { 20 | textDecoration: 'underline', 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { Footer } from './'; 3 | 4 | type Component = typeof Footer; 5 | type Story = StoryObj; 6 | 7 | export default { 8 | component: Footer, 9 | } satisfies Meta; 10 | 11 | export const Overview: Story = {}; 12 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container } from '../Container'; 3 | import { Logo } from '../Logo'; 4 | import * as styles from './Footer.css'; 5 | 6 | export type Props = {}; 7 | 8 | export const Footer = () => ( 9 | 20 | ); 21 | -------------------------------------------------------------------------------- /src/components/Footer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Footer'; 2 | -------------------------------------------------------------------------------- /src/components/Header/Header.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { 3 | BreakPoint, 4 | Space, 5 | tokens, 6 | typography, 7 | } from '../../styles/variables.css'; 8 | 9 | export const wrapper = style({ 10 | display: 'grid', 11 | gridTemplateColumns: '1fr auto', 12 | gridGap: Space * 1, 13 | height: tokens.size.headerHeight, 14 | background: tokens.color.white, 15 | boxShadow: tokens.shadow.lv1, 16 | '@media': { 17 | [`(min-width: ${BreakPoint.SMALL}px)`]: { 18 | gridTemplateColumns: '2fr 1fr 2fr', 19 | }, 20 | }, 21 | }); 22 | 23 | export const left = style({ 24 | alignSelf: 'center', 25 | overflow: 'hidden', 26 | paddingLeft: Space * 2, 27 | }); 28 | 29 | export const center = style([ 30 | typography.body2, 31 | { 32 | display: 'none', 33 | alignSelf: 'center', 34 | textAlign: 'center', 35 | '@media': { 36 | [`(min-width: ${BreakPoint.SMALL}px)`]: { 37 | display: 'block', 38 | }, 39 | }, 40 | }, 41 | ]); 42 | 43 | export const right = style({ 44 | display: 'flex', 45 | flexDirection: 'row-reverse', 46 | justifyContent: 'flex-start', 47 | alignItems: 'center', 48 | alignSelf: 'center', 49 | paddingRight: Space * 2, 50 | textAlign: 'right', 51 | }); 52 | 53 | export const title = style([ 54 | typography.subTitle2, 55 | { 56 | display: 'flex', 57 | alignItems: 'center', 58 | margin: 0, 59 | }, 60 | ]); 61 | 62 | export const titleSign = style({ 63 | flex: '0 0 auto', 64 | marginLeft: Space * 1, 65 | }); 66 | 67 | export const titleText = style({ 68 | display: 'block', 69 | flex: '0 1 auto', 70 | overflow: 'hidden', 71 | marginLeft: Space * 1, 72 | }); 73 | 74 | export const markersToggele = style({ 75 | marginRight: Space * 1, 76 | lineHeight: 0, 77 | }); 78 | -------------------------------------------------------------------------------- /src/components/Header/Header.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { Header } from './'; 4 | 5 | type Component = typeof Header; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: Header, 10 | args: { 11 | variant: 'changed', 12 | title: 'atoms/reg-suit-component.png', 13 | current: 2, 14 | max: 302, 15 | markersEnabled: true, 16 | onRequestClose: action('onRequestClose') as any, 17 | onMarkersToggle: action('onMarkersToggle') as any, 18 | }, 19 | } satisfies Meta; 20 | 21 | export const Overview: Story = {}; 22 | -------------------------------------------------------------------------------- /src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useMedia } from '../../hooks/useMedia'; 3 | import type { RegVariant } from '../../types/reg'; 4 | import { IconButton } from '../IconButton'; 5 | import { Sign } from '../Sign'; 6 | import { Switch } from '../Switch'; 7 | import { CloseIcon } from '../icons/CloseIcon'; 8 | import { Ellipsis } from '../internal/Ellipsis'; 9 | import { BreakPoint, Color } from '../../styles/variables.css'; 10 | import * as styles from './Header.css'; 11 | 12 | export type Props = { 13 | variant: RegVariant; 14 | title: string; 15 | current: number; 16 | max: number; 17 | markersEnabled: boolean; 18 | onRequestClose: () => void; 19 | onMarkersToggle: () => void; 20 | }; 21 | 22 | export const Header = ({ 23 | variant, 24 | title, 25 | current, 26 | max, 27 | markersEnabled, 28 | onRequestClose, 29 | onMarkersToggle, 30 | }: Props) => { 31 | const isSmallViewport = useMedia(`(max-width: ${BreakPoint.SMALL - 1}px)`); 32 | 33 | const handleCloseClick = useCallback( 34 | (e: React.MouseEvent) => { 35 | e.preventDefault(); 36 | onRequestClose(); 37 | }, 38 | [onRequestClose], 39 | ); 40 | 41 | const handleToggle = useCallback(() => { 42 | onMarkersToggle(); 43 | }, [onMarkersToggle]); 44 | 45 | return ( 46 |
47 |
48 |

49 | 50 | 51 | 52 | 53 | {title} 54 | 55 |

56 |
57 | 58 |
59 | {current} / {max} 60 |
61 | 62 |
63 | 64 | 65 | 66 | 67 |
68 | 74 |
75 |
76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/Header/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Header'; 2 | -------------------------------------------------------------------------------- /src/components/HelpDialog/HelpDialog.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens, typography } from '../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | paddingBottom: Space * 1, 6 | }); 7 | 8 | export const table = style({ 9 | width: '100%', 10 | borderCollapse: 'collapse', 11 | }); 12 | 13 | export const headerCell = style([ 14 | typography.subTitle2, 15 | { 16 | padding: `${Space * 4}px 0 ${Space * 1}px`, 17 | textAlign: 'left', 18 | selectors: { 19 | 'tr:first-child &': { 20 | paddingTop: 0, 21 | }, 22 | }, 23 | }, 24 | ]); 25 | 26 | export const dataCell = style([ 27 | typography.body2, 28 | { 29 | padding: `${Space * 1}px 0`, 30 | borderBottom: `1px solid ${tokens.color.border}`, 31 | textAlign: 'left', 32 | }, 33 | ]); 34 | 35 | export const key = style({ 36 | display: 'inline-block', 37 | padding: '8px 14px', 38 | borderRadius: '3px', 39 | border: '1px solid #f4f4f4', 40 | background: 'linear-gradient(180deg, #f3f3f3 0%, #ececec 100%)', 41 | boxShadow: '0px 2px 0px rgba(0, 0, 0, 0.25)', 42 | fontWeight: 'bold', 43 | fontFamily: tokens.fontFamily.monospace, 44 | lineHeight: 1, 45 | selectors: { 46 | '& + &': { 47 | marginLeft: '0.5em', 48 | }, 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /src/components/HelpDialog/HelpDialog.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { HelpDialog } from './'; 4 | 5 | type Component = typeof HelpDialog; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: HelpDialog, 10 | args: { 11 | open: true, 12 | onRequestClose: action('onRequestClose'), 13 | }, 14 | } satisfies Meta; 15 | 16 | export const Overview: Story = {}; 17 | -------------------------------------------------------------------------------- /src/components/HelpDialog/HelpDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Props as DialogProps } from '../Dialog'; 3 | import { Dialog } from '../Dialog'; 4 | import * as styles from './HelpDialog.css'; 5 | 6 | const Title = ({ children }: React.PropsWithChildren) => ( 7 | 8 | 9 | {children} 10 | 11 | 12 | ); 13 | 14 | const Item = ({ 15 | description, 16 | shortcuts, 17 | }: { 18 | description: React.ReactNode; 19 | shortcuts: (string | string[])[]; 20 | }) => { 21 | return ( 22 | 23 | {description} 24 | 25 | {shortcuts.map((key, i) => { 26 | if (Array.isArray(key)) { 27 | return key.map((k) => ( 28 | 29 | {k} 30 | 31 | )); 32 | } 33 | 34 | return ( 35 | 36 | {i > 0 && or } 37 | {key} 38 | 39 | ); 40 | })} 41 | 42 | 43 | ); 44 | }; 45 | 46 | export type Props = Omit; 47 | 48 | export const HelpDialog = ({ ...rest }: Props) => ( 49 | 50 |
51 | 52 | 53 | Application 54 | 55 | 56 | 57 | 58 | Navigation 59 | 60 | 61 | 62 | 63 | 64 | 65 | Viewer 66 | 67 | 68 | 69 | 70 |
71 |
72 |
73 | ); 74 | -------------------------------------------------------------------------------- /src/components/HelpDialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HelpDialog'; 2 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { tokens } from '../../styles/variables.css'; 3 | 4 | const SIZE = 40; 5 | 6 | export const wrapper = style({ 7 | display: 'inline-flex', 8 | justifyContent: 'center', 9 | alignItems: 'center', 10 | width: SIZE, 11 | height: SIZE, 12 | margin: 0, 13 | padding: 0, 14 | borderWidth: 0, 15 | borderRadius: '50%', 16 | fontSize: 0, 17 | lineHeight: 0, 18 | }); 19 | 20 | export const wrapperLight = style({ 21 | background: 'transparent', 22 | ':hover': { 23 | background: tokens.color.hoverBlack, 24 | }, 25 | }); 26 | 27 | export const wrapperDark = style({ 28 | background: 'transparent', 29 | ':hover': { 30 | background: tokens.color.hoverWhite, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import React from 'react'; 3 | import { CloseIcon } from '../icons/CloseIcon'; 4 | import { HelpIcon } from '../icons/HelpIcon'; 5 | import { MoreIcon } from '../icons/MoreIcon'; 6 | import { Color } from '../../styles/variables.css'; 7 | import { IconButton } from './'; 8 | 9 | type Component = typeof IconButton; 10 | type Story = StoryObj; 11 | 12 | export default { 13 | component: IconButton, 14 | } satisfies Meta; 15 | 16 | export const Overview: Story = { 17 | render: () => ( 18 | <> 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ), 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { clsx } from 'clsx'; 3 | import type { Modify } from '../../utils/types'; 4 | import type { Props as BaseButtonProps } from '../internal/BaseButton'; 5 | import { BaseButton } from '../internal/BaseButton'; 6 | import * as styles from './IconButton.css'; 7 | 8 | export type Props = Modify< 9 | BaseButtonProps, 10 | { 11 | variant?: 'light' | 'dark'; 12 | } 13 | >; 14 | 15 | export const IconButton = forwardRef< 16 | HTMLButtonElement | HTMLAnchorElement, 17 | Props 18 | >(({ className, variant = 'light', children, ...rest }, ref) => ( 19 | 27 | {children} 28 | 29 | )); 30 | -------------------------------------------------------------------------------- /src/components/IconButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IconButton'; 2 | -------------------------------------------------------------------------------- /src/components/Image/Image.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from '@vanilla-extract/css'; 2 | 3 | export const wrapper = style({ 4 | position: 'relative', 5 | display: 'grid', 6 | justifyContent: 'center', 7 | alignItems: 'center', 8 | }); 9 | 10 | export const loading = style({ 11 | position: 'absolute', 12 | top: '50%', 13 | left: '50%', 14 | zIndex: 2, 15 | lineHeight: 0, 16 | transform: 'translate(-50%, -50%)', 17 | }); 18 | 19 | const imageBase = style({ 20 | position: 'relative', 21 | zIndex: 1, 22 | maxWidth: '100%', 23 | verticalAlign: 'bottom', 24 | }); 25 | 26 | export const image = styleVariants({ 27 | default: [imageBase, {}], 28 | full: [ 29 | imageBase, 30 | { 31 | width: '100%', 32 | height: '100%', 33 | }, 34 | ], 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/Image/Image.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import React from 'react'; 3 | import { Image } from './'; 4 | 5 | type Component = typeof Image; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: Image, 10 | } satisfies Meta; 11 | 12 | export const Overview: Story = { 13 | args: { 14 | width: 500, 15 | height: 200, 16 | src: 'https://via.placeholder.com/500x200', 17 | }, 18 | }; 19 | 20 | export const WithLazy: Story = { 21 | render: () => ( 22 | <> 23 |
24 | 31 |
32 | 33 | ), 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import React, { forwardRef, useEffect, useRef, useState } from 'react'; 3 | import { useIntersection } from 'use-intersection'; 4 | import { supportsLoading } from '../../supports'; 5 | import { Spinner } from '../Spinner'; 6 | import * as styles from './Image.css'; 7 | 8 | const srcCache = new Set(); 9 | 10 | const size2str = (size: SizeValue | undefined) => { 11 | if (size == null) { 12 | return undefined; 13 | } 14 | 15 | if (typeof size === 'number') { 16 | return `${size}px`; 17 | } 18 | 19 | return size; 20 | }; 21 | 22 | type SizeValue = number | string; 23 | type ObjectFitValue = 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'; 24 | 25 | export type Props = Omit, 'width' | 'height'> & { 26 | width?: SizeValue; 27 | height?: SizeValue; 28 | fit?: ObjectFitValue; 29 | lazy?: boolean; 30 | }; 31 | 32 | type InnerProps = Omit; 33 | 34 | const ImmediatelyImage = forwardRef( 35 | ({ src, width, height, fit, ...rest }, ref) => { 36 | const wrapperRef = useRef(null); 37 | const [loaded, setLoaded] = useState(srcCache.has(src)); 38 | 39 | useEffect(() => { 40 | const { current: wrapper } = wrapperRef; 41 | if (loaded || wrapper == null) { 42 | return; 43 | } 44 | 45 | const img = wrapper.firstElementChild; 46 | if (img == null || !(img instanceof HTMLImageElement)) { 47 | return; 48 | } 49 | 50 | if (img.complete) { 51 | return setLoaded(true); 52 | } 53 | 54 | img.onload = () => { 55 | if (img != null) { 56 | setLoaded(true); 57 | } 58 | srcCache.add(src); 59 | }; 60 | }, [loaded, src]); 61 | 62 | const full = width != null && height != null; 63 | 64 | return ( 65 | 73 | 86 | 87 | {!loaded && ( 88 | 89 | 90 | 91 | )} 92 | 93 | ); 94 | }, 95 | ); 96 | 97 | const LazyImage = forwardRef((props, ref) => { 98 | const wrapperRef = useRef(null); 99 | const intersected = useIntersection(wrapperRef, { 100 | rootMargin: '250px', 101 | once: true, 102 | }); 103 | 104 | return ( 105 | 106 | {intersected ? : null} 107 | 108 | ); 109 | }); 110 | 111 | export const Image = forwardRef( 112 | ({ lazy = false, ...rest }, ref) => 113 | lazy && !supportsLoading ? ( 114 | 115 | ) : ( 116 | 117 | ), 118 | ); 119 | -------------------------------------------------------------------------------- /src/components/Image/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Image'; 2 | -------------------------------------------------------------------------------- /src/components/Link/Link.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useCallback } from 'react'; 2 | import { useHistory } from '../../hooks/useHistory'; 3 | 4 | const HTTP_URL_REG = /^https?:\/\//; 5 | const HASH_URL_REG = /^#/; 6 | 7 | const isModifiedMouseEvent = (e: MouseEvent | React.MouseEvent) => 8 | e.metaKey || e.altKey || e.ctrlKey || e.shiftKey || e.defaultPrevented; 9 | 10 | export type Props = React.ComponentProps<'a'>; 11 | 12 | export const Link = forwardRef( 13 | ({ href, target, rel, children, onClick, ...rest }, ref) => { 14 | const history = useHistory(); 15 | 16 | const handleClick = useCallback( 17 | (e: React.MouseEvent) => { 18 | if (onClick != null) { 19 | onClick(e); 20 | } 21 | 22 | if ( 23 | href == null || 24 | !!target || 25 | e.button !== 0 || 26 | isModifiedMouseEvent(e) 27 | ) { 28 | return; 29 | } 30 | 31 | if (HASH_URL_REG.test(href) || HTTP_URL_REG.test(href)) { 32 | return; 33 | } 34 | 35 | e.preventDefault(); 36 | 37 | history.push(href); 38 | }, 39 | [history, href, target, onClick], 40 | ); 41 | 42 | return ( 43 | 51 | {children} 52 | 53 | ); 54 | }, 55 | ); 56 | 57 | Link.displayName = 'Link'; 58 | -------------------------------------------------------------------------------- /src/components/Link/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Link'; 2 | -------------------------------------------------------------------------------- /src/components/List/Expandable.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from '@vanilla-extract/css'; 2 | import { Space, tokens, typography } from '../../styles/variables.css'; 3 | 4 | const buttonBase = style({ 5 | display: 'flex', 6 | justifyContent: 'center', 7 | alignItems: 'center', 8 | width: '100%', 9 | height: 44, 10 | paddingLeft: `calc(${Space * 2}px + ${Space * 2}px * var(--expandable-depth))`, 11 | paddingRight: Space * 2, 12 | background: 'transparent', 13 | borderWidth: 0, 14 | color: tokens.color.textBase, 15 | textAlign: 'left', 16 | ':hover': { 17 | backgroundColor: tokens.color.hoverBlack, 18 | }, 19 | }); 20 | 21 | export const button = styleVariants({ 22 | default: [buttonBase, typography.subTitle3, {}], 23 | large: [buttonBase, typography.subTitle2, {}], 24 | }); 25 | 26 | const arrowIconBase = style({ 27 | marginRight: Space * 1, 28 | lineHeight: 0, 29 | transition: `transform ${tokens.duration.smallIn} ${tokens.easing.standard}`, 30 | }); 31 | 32 | export const arrowIcon = styleVariants({ 33 | close: [arrowIconBase, { transform: 'rotate(-180deg)' }], 34 | open: [arrowIconBase, { transform: 'rotate(0deg)' }], 35 | }); 36 | 37 | export const label = style({ 38 | display: 'block', 39 | flex: '1 1 auto', 40 | overflow: 'hidden', 41 | }); 42 | 43 | export const meta = style([ 44 | typography.subHead, 45 | { 46 | marginLeft: Space * 1, 47 | color: tokens.color.textSub, 48 | whiteSpace: 'nowrap', 49 | }, 50 | ]); 51 | 52 | export const icon = style({ 53 | marginLeft: Space * 1, 54 | lineHeight: 0, 55 | }); 56 | 57 | export const innerList = style({ 58 | margin: 0, 59 | padding: 0, 60 | listStyle: 'none', 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/List/Expandable.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import React, { useCallback, useEffect, useState } from 'react'; 3 | import { ArrowUpIcon } from '../icons/ArrowUpIcon'; 4 | import { BaseButton } from '../internal/BaseButton'; 5 | import { Collapse } from '../internal/Collapse'; 6 | import { Ellipsis } from '../internal/Ellipsis'; 7 | import { Color, Duration } from '../../styles/variables.css'; 8 | import * as styles from './Expandable.css'; 9 | 10 | export type Props = React.PropsWithChildren<{ 11 | large?: boolean; 12 | depth?: number; 13 | open?: boolean; 14 | defaultOpen?: boolean; 15 | label: React.ReactNode; 16 | meta?: React.ReactNode; 17 | icon?: React.ReactNode; 18 | onChange?: (open: boolean) => void; 19 | }>; 20 | 21 | export const Expandable = ({ 22 | open: openProp, 23 | defaultOpen, 24 | large, 25 | depth = 0, 26 | label, 27 | meta, 28 | icon, 29 | children, 30 | onChange, 31 | }: Props) => { 32 | const [open, setOpen] = useState(defaultOpen ?? false); 33 | 34 | const handleClick = useCallback( 35 | (e: React.MouseEvent) => { 36 | e.preventDefault(); 37 | 38 | if (openProp == null) { 39 | setOpen((prev) => !prev); 40 | } 41 | 42 | if (onChange != null) { 43 | onChange(!openProp); 44 | } 45 | }, 46 | [openProp, onChange], 47 | ); 48 | 49 | useEffect(() => { 50 | if (openProp != null && openProp != open) { 51 | setOpen(openProp); 52 | } 53 | }, [open, openProp]); 54 | 55 | return ( 56 |
  • 57 | 69 | 75 | 76 | 77 | 78 | 79 | {label} 80 | 81 | 82 | {meta && {meta}} 83 | {icon && {icon}} 84 | 85 | 86 | 90 |
      {children}
    91 |
    92 |
  • 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/components/List/Item.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens, typography } from '../../styles/variables.css'; 3 | 4 | export const link = style([ 5 | typography.body2, 6 | { 7 | display: 'flex', 8 | alignItems: 'center', 9 | width: '100%', 10 | height: 44, 11 | paddingLeft: `calc(${Space * 2}px + ${Space * 2}px * var(--item-depth))`, 12 | paddingRight: Space * 2, 13 | color: tokens.color.textBase, 14 | textAlign: 'left', 15 | ':hover': { 16 | backgroundColor: tokens.color.hoverBlack, 17 | }, 18 | }, 19 | ]); 20 | -------------------------------------------------------------------------------- /src/components/List/Item.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Props as BaseButtonProps } from '../internal/BaseButton'; 3 | import { BaseButton } from '../internal/BaseButton'; 4 | import { Ellipsis } from '../internal/Ellipsis'; 5 | import * as styles from './Item.css'; 6 | 7 | export type Props = BaseButtonProps & { 8 | depth?: number; 9 | }; 10 | 11 | export const Item = ({ depth = 0, href, children, ...rest }: Props) => ( 12 |
  • 13 | 23 | {children} 24 | 25 |
  • 26 | ); 27 | -------------------------------------------------------------------------------- /src/components/List/List.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens, typography } from '../../styles/variables.css'; 3 | 4 | export const list = style({ 5 | margin: 0, 6 | padding: 0, 7 | listStyle: 'none', 8 | }); 9 | 10 | export const header = style([ 11 | typography.subHead, 12 | { 13 | marginBottom: Space * 0.5, 14 | padding: `0 ${Space * 2}px`, 15 | color: tokens.color.textSub, 16 | }, 17 | ]); 18 | -------------------------------------------------------------------------------- /src/components/List/List.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { Color } from '../../styles/variables.css'; 4 | import { SignChangedIcon } from '../icons/SignChangedIcon'; 5 | import { List } from './'; 6 | 7 | type Component = typeof List; 8 | type Story = StoryObj; 9 | 10 | export default { 11 | component: List, 12 | } satisfies Meta; 13 | 14 | export const Overview: Story = { 15 | render: () => ( 16 | 17 | Item 1 18 | Item 2 19 | } 25 | > 26 | 27 | Nest Item 1 28 | 29 | 30 | Nest Item 2 31 | 32 | 33 | 34 | 35 | Nest Item 1 36 | 37 | 38 | Nest Item 2 39 | 40 | 41 | 42 | Nest Item 1 43 | 44 | 45 | Nest Item 2 46 | 47 | 48 | 49 | 50 | 51 | Nest Item 1 52 | 53 | 54 | Nest Item 2 55 | 56 | 57 | 58 | ), 59 | }; 60 | 61 | export const WithLongText: Story = { 62 | render: () => ( 63 | 64 | {'Long text '.repeat(20).trim()} 65 | 69 | 70 | {'Long text '.repeat(20).trim()} 71 | 72 | 73 | } 77 | > 78 |
  • hidden content
  • 79 |
    80 |
    81 | ), 82 | }; 83 | 84 | export const WithControledExpandable: Story = { 85 | render: () => ( 86 | 87 | 88 | 89 | Nest Item 1 90 | 91 | 92 | 93 | 94 | Nest Item 1 95 | 96 | 97 | 98 | ), 99 | }; 100 | -------------------------------------------------------------------------------- /src/components/List/List.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Expandable } from './Expandable'; 3 | import { Item } from './Item'; 4 | import * as styles from './List.css'; 5 | 6 | export type Props = React.PropsWithChildren<{ 7 | header?: React.ReactNode; 8 | }>; 9 | 10 | export const List = ({ header, children, ...rest }: Props) => ( 11 |
    12 | {header == null ? null :
    {header}
    } 13 |
      {children}
    14 |
    15 | ); 16 | 17 | List.Item = Item; 18 | List.Expandable = Expandable; 19 | -------------------------------------------------------------------------------- /src/components/List/index.ts: -------------------------------------------------------------------------------- 1 | export * from './List'; 2 | -------------------------------------------------------------------------------- /src/components/Logo/Logo.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | 3 | export const wrapper = style({ 4 | display: 'inline-block', 5 | fontSize: 0, 6 | verticalAlign: 'bottom', 7 | }); 8 | -------------------------------------------------------------------------------- /src/components/Logo/Logo.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import React from 'react'; 3 | import { Logo } from './'; 4 | 5 | type Component = typeof Logo; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: Logo, 10 | } satisfies Meta; 11 | 12 | export const Overview: Story = { 13 | render: () => ( 14 | <> 15 | 16 | 17 | ), 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as styles from './Logo.css'; 3 | 4 | export type Props = React.ComponentProps<'svg'> & { 5 | size?: number; 6 | }; 7 | 8 | export const Logo = ({ size = 24, ...rest }: Props) => ( 9 | 10 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 47 | 48 | ); 49 | -------------------------------------------------------------------------------- /src/components/Logo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Logo'; 2 | -------------------------------------------------------------------------------- /src/components/Main/Main.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, typography } from '../../styles/variables.css'; 3 | 4 | export const title = style([ 5 | typography.title1, 6 | { 7 | margin: `124px 0 ${Space * 3}px`, 8 | }, 9 | ]); 10 | 11 | export const sectionTitle = style([ 12 | typography.title2, 13 | { 14 | margin: `${Space * 12}px 0 ${Space * 3}px`, 15 | ':first-of-type': { 16 | marginTop: Space * 8, 17 | }, 18 | }, 19 | ]); 20 | -------------------------------------------------------------------------------- /src/components/Main/Main.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useEntities, useEntityFilter } from '../../states/entity'; 3 | import { useNotify } from '../../states/notification'; 4 | import { BreakPoint, Size, Space } from '../../styles/variables.css'; 5 | import type { RegEntity, RegVariant } from '../../types/reg'; 6 | import { Card } from '../Card'; 7 | import { Container } from '../Container'; 8 | import { VGrid } from '../VGrid'; 9 | import * as styles from './Main.css'; 10 | 11 | const titles: { [K in RegVariant]: string } = { 12 | new: 'NEW ITEMS', 13 | passed: 'PASSED ITEMS', 14 | changed: 'CHANGED ITEMS', 15 | deleted: 'DELETED ITEMS', 16 | }; 17 | 18 | export type Props = {}; 19 | 20 | const gridOptions = [ 21 | { 22 | media: 'screen', 23 | gap: Space * 5, 24 | minContentLength: 270, 25 | }, 26 | { 27 | media: `screen and (min-width: ${BreakPoint.X_SMALL}px)`, 28 | gap: Space * 5, 29 | minContentLength: 300, 30 | }, 31 | { 32 | media: `screen and (min-width: ${BreakPoint.SMALL}px)`, 33 | gap: Space * 5, 34 | minContentLength: 360, 35 | }, 36 | { 37 | media: `screen and (min-width: ${BreakPoint.X_LARGE}px)`, 38 | gap: Space * 5, 39 | minContentLength: 540, 40 | }, 41 | ]; 42 | 43 | const Content = ({ 44 | variant, 45 | entities, 46 | }: { 47 | variant: RegVariant; 48 | entities: RegEntity[]; 49 | }) => { 50 | const notify = useNotify(); 51 | const title = titles[variant]; 52 | 53 | const handleCopy = useCallback(() => { 54 | notify('Copied URL to clipboard'); 55 | }, [notify]); 56 | 57 | if (entities.length < 1) { 58 | return null; 59 | } 60 | 61 | return ( 62 | <> 63 |

    64 | {title} 65 |

    66 | 72 | {({ item: entity }) => ( 73 | 79 | )} 80 | 81 | 82 | ); 83 | }; 84 | 85 | export const Main = () => { 86 | const entity = useEntities(); 87 | const [isFiltering] = useEntityFilter(); 88 | 89 | return ( 90 | 91 |

    REPORT DETAIL

    92 | 93 | {isFiltering && entity.allItems.length === 0 ? ( 94 | <> 95 |

    Not found

    96 |

    97 | No items found that match the text entered. 98 |
    99 | Try filtering with different keywords :) 100 |

    101 | 102 | ) : ( 103 | <> 104 | 105 | 106 | 107 | 108 | 109 | )} 110 |
    111 | ); 112 | }; 113 | -------------------------------------------------------------------------------- /src/components/Main/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Main'; 2 | -------------------------------------------------------------------------------- /src/components/Menu/Item.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens, typography } from '../../styles/variables.css'; 3 | 4 | export const inner = style([ 5 | typography.body2, 6 | { 7 | display: 'flex', 8 | alignItems: 'center', 9 | position: 'relative', 10 | width: '100%', 11 | padding: `${Space * 1}px ${Space * 2}px`, 12 | borderWidth: 0, 13 | background: tokens.color.white, 14 | color: tokens.color.textBase, 15 | textAlign: 'left', 16 | ':hover': { 17 | background: tokens.color.hoverBlack, 18 | }, 19 | ':focus': { 20 | zIndex: 2, 21 | }, 22 | }, 23 | ]); 24 | -------------------------------------------------------------------------------- /src/components/Menu/Item.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Props as BaseButtonProps } from '../internal/BaseButton'; 3 | import { BaseButton } from '../internal/BaseButton'; 4 | import { Ellipsis } from '../internal/Ellipsis'; 5 | import * as styles from './Item.css'; 6 | 7 | export type Props = BaseButtonProps; 8 | 9 | export const Item = ({ children, ...rest }: Props) => { 10 | return ( 11 |
  • 12 | 13 | {children} 14 | 15 |
  • 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/Menu/Menu.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import React, { useRef, useState } from 'react'; 4 | import type { Placement } from './'; 5 | import { Menu } from './'; 6 | 7 | type Component = typeof Menu; 8 | type Story = StoryObj; 9 | 10 | export default { 11 | component: Menu, 12 | } satisfies Meta; 13 | 14 | export const Overview: Story = { 15 | render: () => { 16 | const [open, setOpen] = useState(false); 17 | const [placement, setPlacement] = useState('bottom-left'); 18 | const btn = useRef(null); 19 | 20 | return ( 21 | <> 22 |
    23 | 38 | 49 |
    50 | 51 | { 57 | setOpen(false); 58 | }} 59 | > 60 | Button 1 61 | Anchor 1 62 | Button 2 63 | Anchor 2 64 | 65 | 66 | ); 67 | }, 68 | }; 69 | 70 | export const WithLongContent: Story = { 71 | render: () => ( 72 | 73 | {'Long content '.repeat(10)} 74 | {'Long content '.repeat(15)} 75 | {'Long content '.repeat(20)} 76 | 77 | ), 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/Menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Menu'; 2 | -------------------------------------------------------------------------------- /src/components/Notification/Notification.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { BreakPoint, Space, tokens } from '../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | position: 'fixed', 6 | right: Space * 2, 7 | bottom: Space * 2, 8 | left: Space * 2, 9 | zIndex: tokens.depth.notification, 10 | '@media': { 11 | [`(min-width: ${BreakPoint.SMALL}px)`]: { 12 | left: '50%', 13 | right: 'auto', 14 | minWidth: 230, 15 | transform: 'translateX(-50%)', 16 | }, 17 | }, 18 | }); 19 | 20 | export const inner = style({ 21 | transitionProperty: 'opacity, transform', 22 | transitionTimingFunction: tokens.easing.standard, 23 | selectors: { 24 | '&.notification-enter': { 25 | transform: 'translateY(5px)', 26 | opacity: 0, 27 | }, 28 | '&.notification-enter-active': { 29 | transitionDuration: tokens.duration.fadeIn, 30 | transform: 'translateY(0)', 31 | opacity: 1, 32 | }, 33 | '&.notification-exit': { 34 | transform: 'translateY(0)', 35 | opacity: 1, 36 | }, 37 | '&.notification-exit-active': { 38 | transitionDuration: tokens.duration.fadeOut, 39 | transform: 'translateY(2px)', 40 | opacity: 0, 41 | }, 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /src/components/Notification/Notification.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Notification } from './Notification'; 4 | 5 | type Component = typeof Notification; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: Notification, 10 | args: { 11 | show: true, 12 | message: 'Notification !!', 13 | }, 14 | } satisfies Meta; 15 | 16 | export const Overview: Story = { 17 | render: (args) => { 18 | const [msg, setMsg] = useState(''); 19 | 20 | useEffect(() => { 21 | setTimeout(() => { 22 | setMsg(''); 23 | }, 1000); 24 | }, [msg]); 25 | 26 | return ( 27 | <> 28 | 29 | 30 | 31 | ); 32 | }, 33 | }; 34 | 35 | export const WithShow: Story = {}; 36 | -------------------------------------------------------------------------------- /src/components/Notification/Notification.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CSSTransition from 'react-transition-group/CSSTransition'; 3 | import SwitchTransition from 'react-transition-group/SwitchTransition'; 4 | import { Snackbar } from '../Snackbar'; 5 | import { Portal } from '../internal/Portal'; 6 | import { Duration } from '../../styles/variables.css'; 7 | import * as styles from './Notification.css'; 8 | 9 | export type Props = { 10 | show: boolean; 11 | message: string; 12 | }; 13 | 14 | export const Notification = ({ show, message }: Props) => { 15 | return ( 16 | 17 |
    18 |
    19 | 20 | 28 |
    29 | {show && {message}} 30 |
    31 |
    32 |
    33 |
    34 |
    35 |
    36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/Notification/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNotificationMessage } from '../../states/notification'; 3 | import { Notification as Component } from './Notification'; 4 | 5 | export type Props = {}; 6 | 7 | export const Notification = () => { 8 | const message = useNotificationMessage(); 9 | 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/PoweredBy/PoweredBy.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens, typography } from '../../styles/variables.css'; 3 | 4 | export const button = style({ 5 | display: 'flex', 6 | alignItems: 'center', 7 | padding: `${Space * 3}px ${Space * 2}px`, 8 | minWidth: 280, 9 | background: 'transparent', 10 | ':hover': { 11 | backgroundColor: tokens.color.hoverBlack, 12 | }, 13 | }); 14 | 15 | export const icon = style({ 16 | marginRight: Space * 1, 17 | }); 18 | 19 | export const title = style([ 20 | typography.subTitle3, 21 | { 22 | margin: 0, 23 | color: tokens.color.textBase, 24 | }, 25 | ]); 26 | 27 | export const url = style([ 28 | typography.body3, 29 | { 30 | margin: 0, 31 | color: tokens.color.textSub, 32 | }, 33 | ]); 34 | -------------------------------------------------------------------------------- /src/components/PoweredBy/PoweredBy.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { PoweredBy } from './'; 3 | 4 | type Component = typeof PoweredBy; 5 | type Story = StoryObj; 6 | 7 | export default { 8 | component: PoweredBy, 9 | } satisfies Meta; 10 | 11 | export const Overview: Story = {}; 12 | -------------------------------------------------------------------------------- /src/components/PoweredBy/PoweredBy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Logo } from '../Logo'; 3 | import { BaseButton } from '../internal/BaseButton'; 4 | import * as styles from './PoweredBy.css'; 5 | 6 | const REG_VIS_URL = 'https://github.com/reg-viz'; 7 | 8 | export type Props = {}; 9 | 10 | export const PoweredBy = () => ( 11 | 22 | ); 23 | -------------------------------------------------------------------------------- /src/components/PoweredBy/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PoweredBy'; 2 | -------------------------------------------------------------------------------- /src/components/SearchBox/SearchBox.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens } from '../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | position: 'relative', 6 | }); 7 | 8 | export const icon = style({ 9 | position: 'absolute', 10 | top: '50%', 11 | left: 8, 12 | display: 'block', 13 | transform: 'translateY(-50%)', 14 | lineHeight: 0, 15 | }); 16 | 17 | export const input = style({ 18 | position: 'relative', 19 | display: 'block', 20 | width: '100%', 21 | height: 60, 22 | padding: `${Space * 1}px ${Space * 1}px ${Space * 1}px ${Space * 7}px`, 23 | borderWidth: 0, 24 | borderBottom: `1px solid ${tokens.color.border}`, 25 | background: 'transparent', 26 | fontSize: 'inherit', 27 | fontFamily: 'inherit', 28 | transition: `border ${tokens.duration.smallOut} ${tokens.easing.standard}`, 29 | ':focus': { 30 | outline: 'none', 31 | }, 32 | ':focus-visible': { 33 | borderBottomColor: tokens.color.brandPrimary, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/SearchBox/SearchBox.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { SearchBox } from './'; 4 | 5 | type Component = typeof SearchBox; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: SearchBox, 10 | args: { 11 | placeholder: 'Filter by file name', 12 | onChange: action('onChange'), 13 | }, 14 | } satisfies Meta; 15 | 16 | export const Overview: Story = {}; 17 | -------------------------------------------------------------------------------- /src/components/SearchBox/SearchBox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Color } from '../../styles/variables.css'; 3 | import { SearchIcon } from '../icons/SearchIcon'; 4 | import * as styles from './SearchBox.css'; 5 | 6 | export type Props = React.ComponentPropsWithoutRef<'input'> & { 7 | inputRef?: React.Ref; 8 | }; 9 | 10 | export const SearchBox = ({ inputRef, children, ...rest }: Props) => { 11 | return ( 12 |
    13 | 16 | 17 |
    18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/SearchBox/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SearchBox'; 2 | -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useMedia } from '../../hooks/useMedia'; 3 | import { BreakPoint } from '../../styles/variables.css'; 4 | import { usePrevious } from '../../hooks/usePrevious'; 5 | import { useSidebarMutators } from '../../states/sidebar'; 6 | import { Desktop } from './internal/Desktop'; 7 | import { Mobile } from './internal/Mobile'; 8 | import type { Props } from './types'; 9 | 10 | export type { Props }; 11 | 12 | export const Sidebar = (props: Props) => { 13 | const { open, close } = useSidebarMutators(); 14 | const isDesktop = useMedia(`(min-width: ${BreakPoint.MEDIUM}px)`); 15 | const prevIsDesktop = usePrevious(isDesktop); 16 | 17 | useEffect(() => { 18 | if (isDesktop !== prevIsDesktop) { 19 | if (isDesktop) { 20 | open(); 21 | } else { 22 | close(); 23 | } 24 | } 25 | }, [open, close, isDesktop, prevIsDesktop]); 26 | 27 | return isDesktop ? : ; 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/Sidebar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Sidebar'; 2 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Desktop/Desktop.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, style } from '@vanilla-extract/css'; 2 | import { Space, tokens } from '../../../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | position: 'sticky !important' as any, 6 | top: 0, 7 | bottom: 0, 8 | left: 0, 9 | zIndex: tokens.depth.sidebar, 10 | background: tokens.color.background, 11 | borderRight: `1px solid ${tokens.color.border}`, 12 | transitionProperty: 'min-width, width', 13 | transitionTimingFunction: tokens.easing.standard, 14 | willChange: 'min-width', 15 | selectors: { 16 | '&.sidebar-enter': { 17 | minWidth: '0 !important', 18 | width: '0 !important', 19 | }, 20 | '&.sidebar-enter-active': { 21 | minWidth: '280px !important', 22 | transitionDuration: tokens.duration.slideIn, 23 | transform: 'translate(0, 0)', 24 | }, 25 | '&.sidebar-enter-done': { 26 | minWidth: '280px !important', 27 | transform: 'translate(0, 0)', 28 | }, 29 | '&.sidebar-exit': { 30 | minWidth: '280px !important', 31 | }, 32 | '&.sidebar-exit-active': { 33 | minWidth: '0 !important', 34 | width: '0 !important', 35 | transitionDuration: tokens.duration.slideOut, 36 | }, 37 | '&.sidebar-exit-done': { 38 | minWidth: '0 !important', 39 | width: '0 !important', 40 | }, 41 | }, 42 | }); 43 | 44 | export const handle = style({ 45 | position: 'absolute', 46 | top: '50%', 47 | right: `calc(${Space * 0.5}px + 5px)`, 48 | width: 4, 49 | height: 32, 50 | background: tokens.color.border, 51 | borderRadius: 4, 52 | transform: 'translate(1px) scale(0.8)', 53 | transition: `transform ${tokens.duration.slideIn} ${tokens.easing.back}`, 54 | }); 55 | 56 | export const handleRight = style({ 57 | zIndex: 10, 58 | }); 59 | 60 | globalStyle(`${handleRight}:where(:hover, :active) ${handle}`, { 61 | transform: 'translate(0) scale(1)', 62 | }); 63 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Desktop/Desktop.tsx: -------------------------------------------------------------------------------- 1 | import { Resizable } from 're-resizable'; 2 | import React from 'react'; 3 | import CSSTransition from 'react-transition-group/CSSTransition'; 4 | import { useSidebarState } from '../../../../states/sidebar'; 5 | import { Duration } from '../../../../styles/variables.css'; 6 | import type { Props } from '../../types'; 7 | import { SidebarInner } from '../SidebarInner'; 8 | import * as styles from './Desktop.css'; 9 | 10 | const DEFAULT_WIDTH = 300; 11 | 12 | export type { Props }; 13 | 14 | export const Desktop = (props: Props) => { 15 | const { isOpen } = useSidebarState(); 16 | 17 | return ( 18 | 27 | , 31 | }} 32 | handleClasses={{ 33 | top: 'handle-top', 34 | right: styles.handleRight, 35 | bottom: 'handle-bottom', 36 | left: 'handle-left', 37 | topRight: 'handle-top-right', 38 | topLeft: 'handle-top-left', 39 | bottomRight: 'handle-bottom-right', 40 | bottomLeft: 'handle-bottom-left', 41 | }} 42 | enable={{ 43 | top: false, 44 | topRight: false, 45 | topLeft: false, 46 | right: isOpen, 47 | bottom: false, 48 | bottomRight: false, 49 | bottomLeft: false, 50 | left: false, 51 | }} 52 | defaultSize={{ width: DEFAULT_WIDTH, height: '100vh' }} 53 | maxWidth="90%" 54 | minWidth={280} 55 | minHeight="100vh" 56 | maxHeight="100vh" 57 | > 58 | 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Desktop/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Desktop'; 2 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Mobile/Mobile.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens } from '../../../../styles/variables.css'; 3 | 4 | export const inner = style({ 5 | position: 'fixed', 6 | top: 0, 7 | right: 0, 8 | bottom: 0, 9 | left: 0, 10 | zIndex: tokens.depth.sidebar, 11 | width: `calc(100% - 44px - ${Space * 4}px)`, 12 | background: tokens.color.background, 13 | transitionProperty: 'box-shadow, transform', 14 | transitionTimingFunction: tokens.easing.standard, 15 | transform: 'translateX(-100%)', 16 | selectors: { 17 | '.sidebar-enter &': { 18 | transform: 'translateX(-100%)', 19 | }, 20 | '.sidebar-enter-active &': { 21 | transitionDuration: tokens.duration.slideIn, 22 | transform: 'translateX(0)', 23 | }, 24 | '.sidebar-enter-done &': { 25 | boxShadow: tokens.shadow.lv2, 26 | transform: 'translateX(0)', 27 | }, 28 | '.sidebar-exit &': { 29 | transform: 'translateX(0)', 30 | }, 31 | '.sidebar-exit-active &': { 32 | transitionDuration: tokens.duration.slideOut, 33 | transform: 'translateX(-100%)', 34 | }, 35 | '.sidebar-exit-done &': { 36 | transform: 'translateX(-100%)', 37 | }, 38 | }, 39 | }); 40 | 41 | export const backdrop = style({ 42 | position: 'fixed', 43 | top: 0, 44 | right: 0, 45 | bottom: 0, 46 | left: 0, 47 | zIndex: `calc(${tokens.depth.sidebar} - 1)`, 48 | display: 'none', 49 | margin: 0, 50 | padding: 0, 51 | width: '100%', 52 | height: '100%', 53 | borderWidth: 0, 54 | background: 'rgba(0, 0, 0, 0.2)', 55 | transitionTimingFunction: tokens.easing.standard, 56 | transitionProperty: 'opacity', 57 | selectors: { 58 | '.sidebar-enter &': { 59 | display: 'block', 60 | opacity: 0, 61 | }, 62 | '.sidebar-enter-active &': { 63 | display: 'block', 64 | opacity: 1, 65 | transitionDuration: tokens.duration.slideIn, 66 | }, 67 | '.sidebar-enter-done &': { 68 | display: 'block', 69 | opacity: 1, 70 | }, 71 | '.sidebar-exit &': { 72 | display: 'block', 73 | opacity: 1, 74 | }, 75 | '.sidebar-exit-active &': { 76 | display: 'block', 77 | transitionDuration: tokens.duration.slideOut, 78 | opacity: 0, 79 | }, 80 | '.sidebar-exit-done &': { 81 | display: 'none', 82 | }, 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Mobile/Mobile.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | clearAllBodyScrollLocks, 3 | disableBodyScroll, 4 | enableBodyScroll, 5 | } from 'body-scroll-lock'; 6 | import type { FocusTrap } from 'focus-trap'; 7 | import { createFocusTrap } from 'focus-trap'; 8 | import React, { useCallback, useEffect, useRef } from 'react'; 9 | import CSSTransition from 'react-transition-group/CSSTransition'; 10 | import { useKey } from '../../../../hooks/useKey'; 11 | import { 12 | useSidebarMutators, 13 | useSidebarState, 14 | } from '../../../../states/sidebar'; 15 | import { Duration } from '../../../../styles/variables.css'; 16 | import type { Props } from '../../types'; 17 | import { SidebarInner } from '../SidebarInner'; 18 | import * as styles from './Mobile.css'; 19 | 20 | export type { Props }; 21 | 22 | export const Mobile = (props: Props) => { 23 | const { isOpen } = useSidebarState(); 24 | const { close } = useSidebarMutators(); 25 | 26 | const focusRef = useRef(null); 27 | const wrapperRef = useRef(null); 28 | const scrollerRef = useRef(null); 29 | 30 | const handleBackdropClick = useCallback( 31 | (e: React.MouseEvent) => { 32 | e.preventDefault(); 33 | close(); 34 | }, 35 | [close], 36 | ); 37 | 38 | useEffect(() => { 39 | const { current: wrapper } = wrapperRef; 40 | if (wrapper == null) { 41 | return; 42 | } 43 | 44 | focusRef.current = createFocusTrap(wrapper, {}); 45 | 46 | return () => { 47 | clearAllBodyScrollLocks(); 48 | 49 | if (focusRef.current != null) { 50 | focusRef.current.deactivate(); 51 | } 52 | }; 53 | }, []); 54 | 55 | useEffect(() => { 56 | const { current: scroller } = scrollerRef; 57 | const { current: focus } = focusRef; 58 | if (scroller == null || focus == null) { 59 | return; 60 | } 61 | 62 | if (isOpen) { 63 | focus.activate(); 64 | disableBodyScroll(scroller); 65 | } else { 66 | focus.deactivate(); 67 | enableBodyScroll(scroller); 68 | } 69 | }, [isOpen]); 70 | 71 | useKey(wrapperRef, ['Escape'], () => { 72 | if (isOpen) { 73 | close(); 74 | } 75 | }); 76 | 77 | return ( 78 | 87 |
    88 |
    89 | 90 |
    91 |
    98 |
    99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Mobile/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Mobile'; 2 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/SidebarInner/SidebarInner.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space } from '../../../../styles/variables.css'; 3 | 4 | export const inner = style({ 5 | position: 'absolute', 6 | top: 0, 7 | right: 0, 8 | bottom: 0, 9 | left: 0, 10 | zIndex: 0, 11 | overflowX: 'visible', 12 | overflowY: 'auto', 13 | WebkitOverflowScrolling: 'touch', 14 | selectors: { 15 | '.sidebar-exit-done &': { 16 | display: 'none', 17 | }, 18 | }, 19 | }); 20 | 21 | export const toggleWrapper = style({ 22 | position: 'absolute', 23 | top: Space * 2, 24 | left: `calc(100% + ${Space * 2}px)`, 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/SidebarInner/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SidebarInner'; 2 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Summary/Summary.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import type { RegStructualItem } from '../../../../types/reg'; 3 | import { List } from '../../../List'; 4 | 5 | const useForceOpen = (forceOpen: boolean) => { 6 | const [open, setOpen] = useState(forceOpen); 7 | const openCache = useRef(open); 8 | 9 | useEffect(() => { 10 | if (forceOpen) { 11 | openCache.current = open; 12 | setOpen(true); 13 | } else { 14 | setOpen(openCache.current); 15 | } 16 | }, [forceOpen]); // eslint-disable-line react-hooks/exhaustive-deps 17 | 18 | return [open, setOpen] as const; 19 | }; 20 | 21 | export type Props = { 22 | forceOpen: boolean; 23 | label: string; 24 | icon: React.ReactNode; 25 | items: RegStructualItem[]; 26 | size: number; 27 | }; 28 | 29 | const SummaryListItem = ({ 30 | forceOpen, 31 | label, 32 | depth, 33 | item, 34 | }: { 35 | forceOpen: boolean; 36 | label: string; 37 | depth: number; 38 | item: RegStructualItem; 39 | }) => { 40 | const [open, setOpen] = useForceOpen(forceOpen); 41 | 42 | if (item.children.length > 0) { 43 | return ( 44 | 51 | {/* eslint-disable-next-line @typescript-eslint/no-use-before-define */} 52 | 58 | 59 | ); 60 | } 61 | 62 | return ( 63 | 69 | {item.name} 70 | 71 | ); 72 | }; 73 | 74 | const SummaryList = ({ 75 | items, 76 | ...rest 77 | }: { 78 | forceOpen: boolean; 79 | label: string; 80 | depth: number; 81 | items: RegStructualItem[]; 82 | }) => ( 83 | <> 84 | {items.map((item) => ( 85 | 86 | ))} 87 | 88 | ); 89 | 90 | export const Summary = ({ forceOpen, label, icon, items, size }: Props) => { 91 | const [open, setOpen] = useForceOpen(forceOpen); 92 | 93 | if (items.length < 1) { 94 | return null; 95 | } 96 | 97 | return ( 98 | 106 | 112 | 113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Summary/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Summary'; 2 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Toggle/Toggle.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from '@vanilla-extract/css'; 2 | import { tokens } from '../../../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | position: 'relative', 6 | display: 'block', 7 | width: 44, 8 | height: 44, 9 | borderWidth: 0, 10 | borderRadius: 4, 11 | background: 'transparent', 12 | }); 13 | 14 | const lineBase = style({ 15 | position: 'absolute', 16 | top: '50%', 17 | left: '50%', 18 | display: 'block', 19 | marginLeft: '-19px', 20 | height: 4, 21 | borderRadius: 4, 22 | background: tokens.color.textBase, 23 | transition: `all ${tokens.duration.mediumOut} ${tokens.easing.standard}`, 24 | }); 25 | 26 | const lineFirstBase = style([ 27 | lineBase, 28 | { 29 | marginTop: -13, 30 | willChange: 'width', 31 | }, 32 | ]); 33 | 34 | const lineSecondBase = style([ 35 | lineBase, 36 | { 37 | marginTop: -2.5, 38 | width: 38, 39 | }, 40 | ]); 41 | 42 | const lineThirdBase = style([ 43 | lineBase, 44 | { 45 | marginTop: 10, 46 | width: 22, 47 | }, 48 | ]); 49 | 50 | export const line = styleVariants({ 51 | firstClose: [ 52 | lineFirstBase, 53 | { 54 | width: 30, 55 | }, 56 | ], 57 | firstOpen: [ 58 | lineFirstBase, 59 | { 60 | width: 38, 61 | transform: 'rotate(225deg) translate(-7px, -8px)', 62 | }, 63 | ], 64 | secondClose: [lineSecondBase, {}], 65 | secondOpen: [ 66 | lineSecondBase, 67 | { 68 | transform: 'rotate(135deg)', 69 | }, 70 | ], 71 | thirdClose: [ 72 | lineThirdBase, 73 | { 74 | opacity: 1, 75 | }, 76 | ], 77 | thirdOpen: [ 78 | lineThirdBase, 79 | { 80 | opacity: 0, 81 | transform: 'rotate(45deg) translate(-4px, -6px)', 82 | }, 83 | ], 84 | }); 85 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Toggle/Toggle.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import React, { useState } from 'react'; 4 | import { Toggle } from './'; 5 | 6 | type Component = typeof Toggle; 7 | type Story = StoryObj; 8 | 9 | export default { 10 | component: Toggle, 11 | args: { 12 | onClick: action('onClick') as any, 13 | }, 14 | } satisfies Meta; 15 | 16 | export const Overview: Story = { 17 | render: (args) => { 18 | const [open, setOpen] = useState(false); 19 | 20 | return setOpen(!open)} />; 21 | }, 22 | }; 23 | 24 | export const WithOpen: Story = { 25 | args: { 26 | open: true, 27 | }, 28 | }; 29 | 30 | export const WithClose: Story = { 31 | args: { 32 | open: false, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Toggle/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import React, { useCallback } from 'react'; 3 | import { BaseButton } from '../../../internal/BaseButton'; 4 | import * as styles from './Toggle.css'; 5 | 6 | export type Props = { 7 | open: boolean; 8 | onClick: () => void; 9 | }; 10 | 11 | export const Toggle = ({ open, onClick }: Props) => { 12 | const handleClick = useCallback( 13 | (e: React.MouseEvent) => { 14 | e.preventDefault(); 15 | onClick(); 16 | }, 17 | [onClick], 18 | ); 19 | 20 | return ( 21 | 27 | 33 | 39 | 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/Sidebar/internal/Toggle/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Toggle'; 2 | -------------------------------------------------------------------------------- /src/components/Sidebar/types.ts: -------------------------------------------------------------------------------- 1 | export type Props = { 2 | inputRef?: React.RefObject; 3 | listRef?: React.RefObject; 4 | }; 5 | -------------------------------------------------------------------------------- /src/components/Sign/Sign.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from '@vanilla-extract/css'; 2 | import { tokens } from '../../styles/variables.css'; 3 | 4 | const wrapperBase = style({ 5 | display: 'inline-flex', 6 | justifyContent: 'center', 7 | alignItems: 'center', 8 | width: 24, 9 | height: 24, 10 | borderRadius: '50%', 11 | lineHeight: 0, 12 | verticalAlign: 'bottom', 13 | }); 14 | 15 | export const wrapper = styleVariants({ 16 | passed: [ 17 | wrapperBase, 18 | { 19 | backgroundColor: tokens.color.signPassed, 20 | }, 21 | ], 22 | new: [ 23 | wrapperBase, 24 | { 25 | backgroundColor: tokens.color.signNew, 26 | }, 27 | ], 28 | changed: [ 29 | wrapperBase, 30 | { 31 | backgroundColor: tokens.color.signChanged, 32 | }, 33 | ], 34 | deleted: [ 35 | wrapperBase, 36 | { 37 | backgroundColor: tokens.color.signDeleted, 38 | }, 39 | ], 40 | }); 41 | -------------------------------------------------------------------------------- /src/components/Sign/Sign.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import React from 'react'; 3 | import { Sign } from './'; 4 | 5 | type Component = typeof Sign; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: Sign, 10 | } satisfies Meta; 11 | 12 | export const Overview: Story = { 13 | render: () => ( 14 | <> 15 | 16 | 17 | 18 | 19 | 20 | ), 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/Sign/Sign.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { RegVariant } from '../../types/reg'; 3 | import { SignChangedIcon } from '../icons/SignChangedIcon'; 4 | import { SignDeletedIcon } from '../icons/SignDeletedIcon'; 5 | import { SignNewIcon } from '../icons/SignNewIcon'; 6 | import { SignPassedIcon } from '../icons/SignPassedIcon'; 7 | import * as styles from './Sign.css'; 8 | 9 | const signIconMap = { 10 | passed: { 11 | label: 'Passed item', 12 | icon: , 13 | }, 14 | new: { 15 | label: 'New item', 16 | icon: , 17 | }, 18 | changed: { 19 | label: 'Changed item', 20 | icon: , 21 | }, 22 | deleted: { 23 | label: 'Deleted item', 24 | icon: , 25 | }, 26 | }; 27 | 28 | export type Props = { 29 | variant: RegVariant; 30 | }; 31 | 32 | export const Sign = ({ variant, ...rest }: Props) => { 33 | const { label, icon } = signIconMap[variant]; 34 | 35 | return ( 36 | 37 | {icon} 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Sign/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Sign'; 2 | -------------------------------------------------------------------------------- /src/components/Slider/Slider.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, style } from '@vanilla-extract/css'; 2 | import { tokens } from '../../styles/variables.css'; 3 | 4 | const barStyles = { 5 | position: 'absolute', 6 | top: '50%', 7 | marginTop: -2, 8 | height: 4, 9 | borderRadius: 2, 10 | } as const; 11 | 12 | export const wrapper = style({ 13 | position: 'relative', 14 | height: 32, 15 | cursor: 'pointer', 16 | }); 17 | 18 | globalStyle(`${wrapper} .rc-slider-rail`, { 19 | ...barStyles, 20 | zIndex: 1, 21 | width: '100%', 22 | background: tokens.color.border, 23 | }); 24 | 25 | globalStyle(`${wrapper} .rc-slider-track`, { 26 | ...barStyles, 27 | zIndex: 2, 28 | background: tokens.color.brandPrimary, 29 | }); 30 | 31 | globalStyle(`${wrapper} .rc-slider-handle`, { 32 | position: 'absolute', 33 | top: '50%', 34 | zIndex: 3, 35 | marginTop: -9, 36 | width: 18, 37 | height: 18, 38 | background: tokens.color.white, 39 | borderRadius: '50%', 40 | border: `4px solid ${tokens.color.brandPrimary}`, 41 | transitionProperty: 'transform, border', 42 | transitionDuration: tokens.duration.fadeIn, 43 | transitionTimingFunction: tokens.easing.standard, 44 | }); 45 | 46 | globalStyle(`${wrapper} .rc-slider-handle`, { 47 | position: 'absolute', 48 | top: '50%', 49 | zIndex: 3, 50 | marginTop: -9, 51 | width: 18, 52 | height: 18, 53 | background: tokens.color.white, 54 | borderRadius: '50%', 55 | border: `4px solid ${tokens.color.brandPrimary}`, 56 | transitionProperty: 'transform, border', 57 | transitionDuration: tokens.duration.fadeIn, 58 | transitionTimingFunction: tokens.easing.standard, 59 | }); 60 | 61 | globalStyle(`${wrapper} .rc-slider-handle:focus`, { 62 | outline: 'none', 63 | }); 64 | 65 | globalStyle(`${wrapper} .rc-slider-handle:focus-visible`, { 66 | boxShadow: tokens.state.focus, 67 | }); 68 | 69 | globalStyle( 70 | `${wrapper}:hover .rc-slider-handle, ${wrapper} .rc-slider-handle:focus-visible`, 71 | { 72 | borderWidth: 2, 73 | transform: 'scale(1.15)', 74 | }, 75 | ); 76 | -------------------------------------------------------------------------------- /src/components/Slider/Slider.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { Slider } from './'; 3 | 4 | type Component = typeof Slider; 5 | type Story = StoryObj; 6 | 7 | export default { 8 | component: Slider, 9 | args: { 10 | min: 0, 11 | max: 1, 12 | step: 0.01, 13 | }, 14 | } satisfies Meta; 15 | 16 | export const Overview: Story = {}; 17 | -------------------------------------------------------------------------------- /src/components/Slider/Slider.tsx: -------------------------------------------------------------------------------- 1 | import RcSlider from 'rc-slider'; 2 | import React from 'react'; 3 | import * as styles from './Slider.css'; 4 | 5 | export type Props = { 6 | min?: number; 7 | max?: number; 8 | step?: number; 9 | value?: number; 10 | defaultValue?: number; 11 | onChange?: (value: number) => void; 12 | reverse?: boolean; 13 | }; 14 | 15 | export const Slider = ({ onChange, ...rest }: Props) => ( 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /src/components/Slider/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Slider'; 2 | -------------------------------------------------------------------------------- /src/components/Snackbar/Snackbar.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens, typography } from '../../styles/variables.css'; 3 | 4 | export const wrapper = style([ 5 | typography.subTitle3, 6 | { 7 | padding: `${Space * 1.5}px ${Space * 2}px`, 8 | boxShadow: tokens.shadow.lv2, 9 | borderRadius: 2, 10 | background: tokens.color.brandSecondary, 11 | color: tokens.color.white, 12 | textAlign: 'center', 13 | }, 14 | ]); 15 | 16 | export const content = style({ 17 | margin: 0, 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/Snackbar/Snackbar.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { Snackbar } from './'; 3 | 4 | type Component = typeof Snackbar; 5 | type Story = StoryObj; 6 | 7 | export default { 8 | component: Snackbar, 9 | args: { 10 | children: 'Snackbar', 11 | }, 12 | } satisfies Meta; 13 | 14 | export const Overview: Story = {}; 15 | -------------------------------------------------------------------------------- /src/components/Snackbar/Snackbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as styles from './Snackbar.css'; 3 | 4 | export type Props = React.ComponentPropsWithoutRef<'div'>; 5 | 6 | export const Snackbar = ({ children, ...rest }: Props) => ( 7 |
    8 |

    {children}

    9 |
    10 | ); 11 | -------------------------------------------------------------------------------- /src/components/Snackbar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Snackbar'; 2 | -------------------------------------------------------------------------------- /src/components/Spacer/Spacer.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from '@vanilla-extract/css'; 2 | import { Space } from '../../styles/variables.css'; 3 | 4 | const wrapperBase = style({ 5 | display: 'block', 6 | }); 7 | 8 | export const wrapper = styleVariants({ 9 | margin: [ 10 | wrapperBase, 11 | { 12 | marginTop: `calc(${Space}px * var(--spacer-x))`, 13 | }, 14 | ], 15 | padding: [ 16 | wrapperBase, 17 | { 18 | paddingTop: `calc(${Space}px * var(--spacer-x))`, 19 | }, 20 | ], 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/Spacer/Spacer.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import React from 'react'; 3 | import { Spacer } from './'; 4 | 5 | const Bar = () =>
    ; 6 | 7 | type Component = typeof Spacer; 8 | type Story = StoryObj; 9 | 10 | export default { 11 | component: Spacer, 12 | } satisfies Meta; 13 | 14 | export const WithMargin: Story = { 15 | render: () => ( 16 | <> 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ), 30 | }; 31 | 32 | export const WithPadding: Story = { 33 | render: () => ( 34 | <> 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ), 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/Spacer/Spacer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as styles from './Spacer.css'; 3 | 4 | export type Props = { 5 | variant: 'margin' | 'padding'; 6 | x: number; 7 | }; 8 | 9 | export const Spacer = ({ variant, x }: Props) => ( 10 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/Spacer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Spacer'; 2 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { Color } from '../../styles/variables.css'; 3 | import { Spinner } from './'; 4 | 5 | type Component = typeof Spinner; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: Spinner, 10 | args: { 11 | 'aria-label': 'Loading...', 12 | }, 13 | } satisfies Meta; 14 | 15 | export const Overview: Story = {}; 16 | 17 | export const WithLarge: Story = { 18 | args: { 19 | size: 64, 20 | }, 21 | }; 22 | 23 | export const WithGray: Story = { 24 | args: { 25 | color: Color.TEXT_SUB, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MDSpinner from 'react-md-spinner'; 3 | import { Color } from '../../styles/variables.css'; 4 | 5 | export type Props = { 6 | size?: number; 7 | color?: Color; 8 | 'aria-label': string; 9 | }; 10 | 11 | export const Spinner = ({ 12 | size = 32, 13 | color = Color.BRAND_PRIMARY, 14 | ...rest 15 | }: Props) => ; 16 | -------------------------------------------------------------------------------- /src/components/Spinner/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Spinner'; 2 | -------------------------------------------------------------------------------- /src/components/Switch/Switch.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens, typography } from '../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | display: 'inline-flex', 6 | alignItems: 'center', 7 | }); 8 | 9 | export const checkbox = style({ 10 | position: 'relative', 11 | }); 12 | 13 | export const checkboxInput = style({ 14 | position: 'absolute', 15 | top: '-25%', 16 | left: '-25%', 17 | width: '150%', 18 | height: '150%', 19 | zIndex: 2, 20 | margin: 0, 21 | padding: 0, 22 | opacity: 0, 23 | cursor: 'pointer', 24 | }); 25 | 26 | export const checkboxVisual = style({ 27 | position: 'relative', 28 | zIndex: 1, 29 | display: 'block', 30 | width: 42, 31 | height: 22, 32 | borderRadius: 11, 33 | background: tokens.color.border, 34 | transition: `transform ${tokens.duration.smallOut} ${tokens.easing.standard}`, 35 | '::before': { 36 | position: 'absolute', 37 | top: 1, 38 | left: 1, 39 | width: 20, 40 | height: 20, 41 | borderRadius: '50%', 42 | background: tokens.color.white, 43 | boxShadow: tokens.shadow.lv1, 44 | transition: `transform ${tokens.duration.smallOut} ${tokens.easing.standard}`, 45 | content: '""', 46 | }, 47 | selectors: { 48 | 'input:checked + &': { 49 | background: tokens.color.brandPrimary, 50 | }, 51 | 'input:checked + &::before': { 52 | transform: 'translateX(20px)', 53 | }, 54 | 'input:focus-visible + &': { 55 | boxShadow: tokens.state.focus, 56 | }, 57 | }, 58 | }); 59 | 60 | export const prepend = style([ 61 | typography.subTitle3, 62 | { 63 | marginRight: Space * 1, 64 | textAlign: 'right', 65 | }, 66 | ]); 67 | 68 | export const append = style([ 69 | typography.subTitle3, 70 | { 71 | marginLeft: Space * 1, 72 | textAlign: 'left', 73 | }, 74 | ]); 75 | -------------------------------------------------------------------------------- /src/components/Switch/Switch.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import React from 'react'; 3 | import { Switch } from './'; 4 | 5 | type Component = typeof Switch; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: Switch, 10 | args: { 11 | id: 'switch', 12 | }, 13 | } satisfies Meta; 14 | 15 | export const Overview: Story = {}; 16 | 17 | export const WithChecked: Story = { 18 | args: { 19 | checked: true, 20 | }, 21 | }; 22 | 23 | export const WithPrepend: Story = { 24 | args: { 25 | prepend: Before, 26 | }, 27 | }; 28 | 29 | export const WithAppend: Story = { 30 | args: { 31 | prepend: After, 32 | }, 33 | }; 34 | 35 | export const WithPrependAndAppend: Story = { 36 | args: { 37 | prepend: Before, 38 | append: After, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Switch/Switch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Modify } from '../../utils/types'; 3 | import * as styles from './Switch.css'; 4 | 5 | export type Props = Modify< 6 | Omit, 'type'>, 7 | { 8 | id: string; 9 | prepend?: React.ReactNode; 10 | append?: React.ReactNode; 11 | } 12 | >; 13 | 14 | export const Switch = ({ id, prepend, append, ...rest }: Props) => { 15 | const prependId = `${id}-prepend`; 16 | const appendId = `${id}-append`; 17 | const describedby = []; 18 | 19 | if (prepend != null) { 20 | describedby.push(prependId); 21 | } 22 | 23 | if (append != null) { 24 | describedby.push(appendId); 25 | } 26 | 27 | return ( 28 | 29 | {prepend && ( 30 | 31 | {prepend} 32 | 33 | )} 34 | 35 | 36 | 43 | 44 | 45 | 46 | {append && ( 47 | 48 | {append} 49 | 50 | )} 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/Switch/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Switch'; 2 | -------------------------------------------------------------------------------- /src/components/VGrid/index.ts: -------------------------------------------------------------------------------- 1 | export * from './VGrid'; 2 | -------------------------------------------------------------------------------- /src/components/Viewer/Viewer.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { Space, tokens } from '../../styles/variables.css'; 3 | import { OPEN_DELAY } from './constants'; 4 | 5 | export const wrapper = style({ 6 | position: 'fixed', 7 | top: 0, 8 | right: 0, 9 | bottom: 0, 10 | left: 0, 11 | zIndex: tokens.depth.viewer, 12 | minWidth: 320, 13 | minHeight: 400, 14 | overflowX: 'hidden', 15 | overflowY: 'auto', 16 | transitionProperty: 'opacity', 17 | transitionTimingFunction: 'ease-out', 18 | selectors: { 19 | '.viewer-enter &': { 20 | opacity: 0, 21 | }, 22 | '.viewer-enter-active &': { 23 | opacity: 1, 24 | transitionDuration: tokens.duration.largeIn, 25 | }, 26 | '.viewer-exit &': { 27 | opacity: 1, 28 | }, 29 | '.viewer-exit-active &': { 30 | opacity: 0, 31 | transitionDuration: tokens.duration.largeOut, 32 | }, 33 | }, 34 | }); 35 | 36 | export const headerWrapper = style({ 37 | position: 'absolute', 38 | top: 0, 39 | right: 0, 40 | left: 0, 41 | zIndex: 10, 42 | transitionProperty: 'opacity, transform', 43 | transitionTimingFunction: tokens.easing.standard, 44 | selectors: { 45 | '.viewer-enter &': { 46 | opacity: 0, 47 | transform: 'translateY(-3px)', 48 | }, 49 | '.viewer-enter-active &': { 50 | opacity: 1, 51 | transform: 'translateY(0)', 52 | transitionDuration: tokens.duration.largeIn, 53 | transitionDelay: `${OPEN_DELAY}ms`, 54 | }, 55 | }, 56 | }); 57 | 58 | export const body = style({ 59 | position: 'absolute', 60 | top: tokens.size.headerHeight, 61 | right: 0, 62 | bottom: 0, 63 | left: 0, 64 | zIndex: 5, 65 | }); 66 | 67 | const navigation = style({ 68 | position: 'absolute', 69 | top: '50%', 70 | zIndex: 10, 71 | transform: 'translate(0, -50%)', 72 | transitionProperty: 'opacity, transform', 73 | transitionTimingFunction: tokens.easing.standard, 74 | }); 75 | 76 | export const previous = style([ 77 | navigation, 78 | { 79 | left: Space * 2, 80 | selectors: { 81 | '.viewer-enter &': { 82 | opacity: 0, 83 | transform: 'translate(-5px, -50%)', 84 | }, 85 | '.viewer-enter-active &': { 86 | opacity: 1, 87 | transform: 'translate(0, -50%)', 88 | transitionDuration: tokens.duration.largeIn, 89 | transitionDelay: `${OPEN_DELAY}ms`, 90 | }, 91 | }, 92 | }, 93 | ]); 94 | 95 | export const next = style([ 96 | navigation, 97 | { 98 | right: Space * 2, 99 | selectors: { 100 | '.viewer-enter &': { 101 | opacity: 0, 102 | transform: 'translate(5px, -50%)', 103 | }, 104 | '.viewer-enter-active &': { 105 | opacity: 1, 106 | transform: 'translate(0, -50%)', 107 | transitionDuration: tokens.duration.largeIn, 108 | transitionDelay: `${OPEN_DELAY}ms`, 109 | }, 110 | }, 111 | }, 112 | ]); 113 | 114 | export const background = style({ 115 | position: 'absolute', 116 | top: 0, 117 | right: 0, 118 | bottom: 0, 119 | left: 0, 120 | zIndex: 0, 121 | }); 122 | -------------------------------------------------------------------------------- /src/components/Viewer/Viewer.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import React, { useState } from 'react'; 4 | import { createRegEntity } from '../../mocks'; 5 | import type { RegEntity } from '../../types/reg'; 6 | import { Viewer } from './Viewer'; 7 | 8 | const defaultEntity = createRegEntity({ 9 | id: 'id', 10 | variant: 'changed', 11 | name: 'filename.png', 12 | diff: 'https://via.placeholder.com/438x178?text=diff', 13 | before: 'https://via.placeholder.com/876x356?text=before', 14 | after: 'https://via.placeholder.com/600x534?text=after', 15 | }); 16 | 17 | type Component = typeof Viewer; 18 | type Story = StoryObj; 19 | 20 | export default { 21 | component: Viewer, 22 | args: { 23 | total: 302, 24 | current: 2, 25 | entity: defaultEntity, 26 | matching: null, 27 | markersEnabled: true, 28 | onPrevious: action('onPrevious') as any, 29 | onNext: action('onNext') as any, 30 | onRequestClose: action('onRequestClose') as any, 31 | onMarkersToggle: action('onMarkersToggle') as any, 32 | }, 33 | } satisfies Meta; 34 | 35 | export const Overview: Story = { 36 | render: (args) => { 37 | const [entity, setEntity] = useState(null); 38 | 39 | return ( 40 | <> 41 | 49 | 50 | { 54 | setEntity(null); 55 | }} 56 | /> 57 | 58 | ); 59 | }, 60 | }; 61 | 62 | export const WithChanged: Story = {}; 63 | 64 | export const WithNew: Story = { 65 | args: { 66 | entity: { 67 | ...defaultEntity, 68 | variant: 'new', 69 | }, 70 | }, 71 | }; 72 | 73 | export const WithDeleted: Story = { 74 | args: { 75 | entity: { 76 | ...defaultEntity, 77 | variant: 'deleted', 78 | }, 79 | }, 80 | }; 81 | 82 | export const WithPassed: Story = { 83 | args: { 84 | entity: { 85 | ...defaultEntity, 86 | variant: 'passed', 87 | }, 88 | }, 89 | }; 90 | -------------------------------------------------------------------------------- /src/components/Viewer/constants.ts: -------------------------------------------------------------------------------- 1 | export const OPEN_DELAY = 64; 2 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/Blend.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | 3 | export const wrapper = style({ 4 | display: 'flex', 5 | justifyContent: 'center', 6 | alignItems: 'center', 7 | }); 8 | 9 | export const inner = style({ 10 | position: 'relative', 11 | }); 12 | 13 | const view = style({ 14 | position: 'absolute', 15 | top: 0, 16 | left: '50%', 17 | width: '100%', 18 | transform: 'translate(-50%, 0)', 19 | }); 20 | 21 | export const before = style([ 22 | view, 23 | { 24 | zIndex: 0, 25 | }, 26 | ]); 27 | 28 | export const after = style([ 29 | view, 30 | { 31 | zIndex: 1, 32 | }, 33 | ]); 34 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/Blend.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Matching } from '../../../../types/reg'; 3 | import { Image } from '../../../Image'; 4 | import * as styles from './Blend.css'; 5 | import { Markers } from './Markers'; 6 | import { useComparisonImage } from './useComparisonImage'; 7 | 8 | export type Props = { 9 | before: string; 10 | after: string; 11 | value: number; 12 | matching: Matching | null; 13 | }; 14 | 15 | export const Blend = ({ before, after, value, matching }: Props) => { 16 | const { canvas, image } = useComparisonImage(before, after); 17 | 18 | return ( 19 |
    23 |
    24 |
    31 | 36 | 37 |
    38 | 39 |
    47 | 52 | 53 |
    54 |
    55 |
    56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/ComparisonView.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { BreakPoint, Space, tokens } from '../../../../styles/variables.css'; 3 | import { OPEN_DELAY } from '../../constants'; 4 | 5 | export const wrapper = style({ 6 | position: 'absolute', 7 | top: 0, 8 | right: 0, 9 | bottom: 0, 10 | left: 0, 11 | zIndex: 5, 12 | }); 13 | 14 | export const comparisonImage = style({ 15 | position: 'absolute', 16 | top: 0, 17 | right: 0, 18 | bottom: 0, 19 | left: 0, 20 | zIndex: 1, 21 | display: 'flex', 22 | justifyContent: 'center', 23 | alignItems: 'center', 24 | overflowX: 'hidden', 25 | overflowY: 'auto', 26 | WebkitOverflowScrolling: 'touch', 27 | }); 28 | 29 | export const comparisonImageInnerV = style({ 30 | margin: `auto ${Space * 1}px`, 31 | width: '100%', 32 | '@media': { 33 | [`(min-width: ${BreakPoint.MEDIUM}px)`]: { 34 | marginRight: Space * 5, 35 | marginLeft: Space * 5, 36 | }, 37 | }, 38 | }); 39 | 40 | export const comparisonImageInnerH = style({ 41 | position: 'relative', 42 | margin: `${Space * 3}px auto ${Space * 17}px`, 43 | }); 44 | 45 | export const comparisonMode = style({ 46 | position: 'absolute', 47 | bottom: Space * 1, 48 | left: '50%', 49 | zIndex: 10, 50 | maxWidth: '100%', 51 | width: 480, 52 | padding: `0 ${Space * 1}px`, 53 | transform: 'translate(-50%, 0)', 54 | transitionProperty: 'opacity, transform', 55 | transitionTimingFunction: tokens.easing.standard, 56 | '@media': { 57 | [`(min-width: ${BreakPoint.MEDIUM}px)`]: { 58 | bottom: Space * 5, 59 | }, 60 | }, 61 | selectors: { 62 | '.viewer-enter &': { 63 | opacity: 0, 64 | transform: 'translate(-50%, 3px)', 65 | }, 66 | '.viewer-enter-active &': { 67 | transitionDuration: tokens.duration.largeIn, 68 | transitionDelay: `${OPEN_DELAY}ms`, 69 | opacity: 1, 70 | transform: 'translate(-50%, 0)', 71 | }, 72 | }, 73 | }); 74 | 75 | export const controlWrapper = style({ 76 | position: 'absolute', 77 | right: 0, 78 | bottom: '100%', 79 | left: 0, 80 | padding: `0 ${Space * 1}px ${Space * 2}px`, 81 | }); 82 | 83 | export const control = style({ 84 | display: 'flex', 85 | justifyContent: 'center', 86 | alignItems: 'center', 87 | height: 40, 88 | padding: `${Space * 1}px ${Space * 4}px`, 89 | borderRadius: 20, 90 | background: tokens.color.white, 91 | boxShadow: tokens.shadow.lv2, 92 | transitionProperty: 'opacity, transform', 93 | transitionTimingFunction: tokens.easing.standard, 94 | selectors: { 95 | '.control-enter &': { 96 | opacity: 0, 97 | transform: 'translateY(3px)', 98 | }, 99 | '.control-enter-active &': { 100 | transitionDuration: tokens.duration.fadeIn, 101 | opacity: 1, 102 | transform: 'translateY(0)', 103 | }, 104 | '.control-exit &': { 105 | opacity: 1, 106 | transform: 'translateY(0)', 107 | }, 108 | '.control-exit-active &': { 109 | transitionDuration: tokens.duration.fadeOut, 110 | opacity: 0, 111 | transform: 'translateY(1px)', 112 | }, 113 | }, 114 | }); 115 | 116 | export const controlSlider = style({ 117 | flex: '1 0 auto', 118 | padding: `0 ${Space * 2}px`, 119 | }); 120 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/ComparisonView.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { createRegEntity } from '../../../../mocks'; 3 | import { ComparisonView } from './ComparisonView'; 4 | 5 | const defaultEntity = createRegEntity({ 6 | id: 'id', 7 | variant: 'changed', 8 | name: 'filename.png', 9 | diff: 'https://via.placeholder.com/700x400?text=diff', 10 | before: 'https://via.placeholder.com/700x400/FF0000/FFFFFF?text=before', 11 | after: 'https://via.placeholder.com/800x700?text=after', 12 | }); 13 | 14 | const largeAfterEntity = createRegEntity({ 15 | id: 'id', 16 | variant: 'changed', 17 | name: 'filename.png', 18 | diff: 'https://via.placeholder.com/700x400?text=diff', 19 | before: 'https://via.placeholder.com/800x700/FF0000/FFFFFF?text=before', 20 | after: 'https://via.placeholder.com/700x400?text=after', 21 | }); 22 | 23 | type Component = typeof ComparisonView; 24 | type Story = StoryObj; 25 | 26 | export default { 27 | component: ComparisonView, 28 | args: { 29 | entity: defaultEntity, 30 | matching: null, 31 | defaultMode: 'slide', 32 | }, 33 | } satisfies Meta; 34 | 35 | export const WithSlide: Story = {}; 36 | 37 | export const WithDiff: Story = { 38 | args: { 39 | defaultMode: 'diff', 40 | }, 41 | }; 42 | 43 | export const WithTwoUp: Story = { 44 | args: { 45 | defaultMode: '2up', 46 | }, 47 | }; 48 | 49 | export const WithBlend: Story = { 50 | args: { 51 | defaultMode: 'blend', 52 | }, 53 | }; 54 | 55 | export const WithToggle: Story = { 56 | args: { 57 | defaultMode: 'toggle', 58 | }, 59 | }; 60 | 61 | export const WithLargeAfterSlide: Story = { 62 | args: { 63 | entity: largeAfterEntity, 64 | }, 65 | }; 66 | 67 | export const WithLargeAfterTwoUp: Story = { 68 | args: { 69 | defaultMode: '2up', 70 | entity: largeAfterEntity, 71 | }, 72 | }; 73 | 74 | export const WithLargeAfterBlend: Story = { 75 | args: { 76 | defaultMode: 'blend', 77 | entity: largeAfterEntity, 78 | }, 79 | }; 80 | 81 | export const WithLargeAfterToggle: Story = { 82 | args: { 83 | defaultMode: 'toggle', 84 | entity: largeAfterEntity, 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/Diff.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Image } from '../../../Image'; 3 | 4 | export type Props = { 5 | src: string; 6 | }; 7 | 8 | export const Diff = ({ src }: Props) => ; 9 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/Markers.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | 3 | export const wrapper = style({ 4 | position: 'absolute', 5 | top: 0, 6 | right: 0, 7 | bottom: 0, 8 | left: 0, 9 | zIndex: 10, 10 | }); 11 | 12 | export const inner = style({ 13 | position: 'relative', 14 | width: '100%', 15 | height: '100%', 16 | margin: '0 auto', 17 | }); 18 | 19 | const rect = style({ 20 | position: 'absolute', 21 | borderWidth: 1, 22 | borderStyle: 'solid', 23 | borderRadius: 2, 24 | }); 25 | 26 | export const bounding = style([ 27 | rect, 28 | { 29 | borderWidth: 1, 30 | borderColor: '#4183c4', 31 | }, 32 | ]); 33 | 34 | export const diff = style([ 35 | rect, 36 | { 37 | borderColor: '#ff108a', 38 | }, 39 | ]); 40 | 41 | export const straying = style([ 42 | rect, 43 | { 44 | borderColor: '#2aacea', 45 | }, 46 | ]); 47 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/Slide.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { tokens } from '../../../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | display: 'flex', 6 | justifyContent: 'center', 7 | alignItems: 'center', 8 | }); 9 | 10 | export const inner = style({ 11 | position: 'relative', 12 | }); 13 | 14 | export const range = style({ 15 | position: 'absolute', 16 | top: 0, 17 | left: 0, 18 | zIndex: 5, 19 | margin: 0, 20 | width: '100%', 21 | height: '100%', 22 | opacity: 0, 23 | appearance: 'none', 24 | touchAction: 'auto', 25 | }); 26 | 27 | export const frame = style({ 28 | position: 'absolute', 29 | top: 0, 30 | bottom: 0, 31 | left: 0, 32 | zIndex: 2, 33 | overflow: 'hidden', 34 | }); 35 | 36 | const view = style({ 37 | position: 'absolute', 38 | top: 0, 39 | left: '50%', 40 | transform: 'translate(-50%, 0)', 41 | }); 42 | 43 | export const after = style([ 44 | view, 45 | { 46 | zIndex: 0, 47 | }, 48 | ]); 49 | 50 | export const before = style([ 51 | view, 52 | { 53 | zIndex: 1, 54 | transform: 'translate(0, 0)', 55 | }, 56 | ]); 57 | 58 | export const handle = style({ 59 | position: 'absolute', 60 | top: 0, 61 | bottom: 0, 62 | zIndex: 5, 63 | cursor: 'ew-resize', 64 | }); 65 | 66 | export const handleBar = style({ 67 | position: 'absolute', 68 | top: 0, 69 | bottom: 0, 70 | left: '50%', 71 | marginLeft: -1, 72 | width: 2, 73 | background: '#fff', 74 | '::after': { 75 | position: 'absolute', 76 | top: '100%', 77 | left: '50%', 78 | display: 'block', 79 | marginLeft: -6, 80 | width: 12, 81 | height: 12, 82 | borderRadius: '50%', 83 | border: `3px solid ${tokens.color.brandPrimary}`, 84 | background: 'transparent', 85 | content: '""', 86 | }, 87 | '::before': { 88 | position: 'absolute', 89 | top: 0, 90 | bottom: '100%', 91 | left: '50%', 92 | display: 'block', 93 | marginLeft: -6, 94 | width: 12, 95 | height: 12, 96 | borderRadius: '50%', 97 | border: `3px solid ${tokens.color.brandPrimary}`, 98 | background: 'transparent', 99 | content: '""', 100 | }, 101 | }); 102 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/Toggle.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | 3 | export const wrapper = style({ 4 | display: 'flex', 5 | justifyContent: 'center', 6 | alignItems: 'center', 7 | }); 8 | 9 | export const inner = style({ 10 | position: 'relative', 11 | }); 12 | 13 | const view = style({ 14 | position: 'absolute', 15 | top: 0, 16 | left: '50%', 17 | width: '100%', 18 | transform: 'translate(-50%, 0)', 19 | }); 20 | 21 | export const before = style([ 22 | view, 23 | { 24 | zIndex: 0, 25 | }, 26 | ]); 27 | 28 | export const after = style([ 29 | view, 30 | { 31 | zIndex: 1, 32 | }, 33 | ]); 34 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Matching } from '../../../../types/reg'; 3 | import { Image } from '../../../Image'; 4 | import { Markers } from './Markers'; 5 | import * as styles from './Toggle.css'; 6 | import { useComparisonImage } from './useComparisonImage'; 7 | 8 | export type Props = { 9 | before: string; 10 | after: string; 11 | checked: boolean; 12 | matching: Matching | null; 13 | }; 14 | 15 | export const Toggle = ({ before, after, checked, matching }: Props) => { 16 | const { canvas, image } = useComparisonImage(before, after); 17 | 18 | return ( 19 |
    23 |
    24 |
    32 | 37 | 38 |
    39 | 40 |
    48 | 53 | 54 |
    55 |
    56 |
    57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/TwoUp.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { BreakPoint, Space } from '../../../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | display: 'grid', 6 | gridTemplateColumns: 'repeat(2, 1fr)', 7 | gridGap: Space * 1, 8 | justifyContent: 'center', 9 | alignItems: 'start', 10 | '@media': { 11 | [`(min-width: ${BreakPoint.MEDIUM}px)`]: { 12 | gridGap: Space * 3, 13 | }, 14 | }, 15 | }); 16 | 17 | export const view = style({ 18 | position: 'relative', 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/TwoUp.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Matching } from '../../../../types/reg'; 3 | import { Image } from '../../../Image'; 4 | import { Markers } from './Markers'; 5 | import * as styles from './TwoUp.css'; 6 | 7 | export type Props = { 8 | before: string; 9 | after: string; 10 | matching: Matching | null; 11 | }; 12 | 13 | export const TwoUp = ({ before, after, matching }: Props) => { 14 | return ( 15 |
    16 |
    17 | 18 | 19 |
    20 | 21 |
    22 | 23 | 24 |
    25 |
    26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ComparisonView'; 2 | -------------------------------------------------------------------------------- /src/components/Viewer/internal/ComparisonView/useComparisonImage.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useCallback, useEffect } from 'react'; 2 | import debounce from 'debounce'; 3 | import { Space, BreakPoint } from '../../../../styles/variables.css'; 4 | import { useMedia } from '../../../../hooks/useMedia'; 5 | 6 | const RESIZE_DEBOUNCE_MS = 32; 7 | 8 | const safe = (n: number) => (Number.isNaN(n) ? 0 : n); 9 | 10 | export const useComparisonImage = (before: string, after: string) => { 11 | const isDesktop = useMedia(`(min-width: ${BreakPoint.MEDIUM}px)`); 12 | 13 | // canvas 14 | const [width, setWidth] = useState(0); 15 | 16 | useEffect(() => { 17 | const updateWidth = debounce(() => { 18 | setWidth(window.innerWidth - (isDesktop ? Space * 5 : Space * 1) * 2); 19 | }, RESIZE_DEBOUNCE_MS); 20 | 21 | updateWidth(); 22 | 23 | window.addEventListener('resize', updateWidth, false); 24 | 25 | return () => { 26 | window.removeEventListener('resize', updateWidth, false); 27 | }; 28 | }, [isDesktop]); 29 | 30 | // images 31 | const [bLoaded, setBLoaded] = useState(false); 32 | const [aLoaded, setALoaded] = useState(false); 33 | const [bSize, setBSize] = useState({ width: 0, height: 0 }); 34 | const [aSize, setASize] = useState({ width: 0, height: 0 }); 35 | 36 | const bRef = useRef(null); 37 | const aRef = useRef(null); 38 | 39 | useEffect(() => { 40 | if (bRef.current != null && bRef.current.complete) { 41 | setBLoaded(true); 42 | } 43 | }, [before]); 44 | 45 | useEffect(() => { 46 | if (aRef.current != null && aRef.current.complete) { 47 | setALoaded(true); 48 | } 49 | }, [after]); 50 | 51 | const handleBLoaded = useCallback(() => { 52 | if (bRef.current != null) { 53 | setBSize({ 54 | width: bRef.current.naturalWidth, 55 | height: bRef.current.naturalHeight, 56 | }); 57 | } 58 | 59 | setBLoaded(true); 60 | }, []); 61 | 62 | const handleALoaded = useCallback(() => { 63 | if (aRef.current != null) { 64 | setASize({ 65 | width: aRef.current.naturalWidth, 66 | height: aRef.current.naturalHeight, 67 | }); 68 | } 69 | 70 | setALoaded(true); 71 | }, []); 72 | 73 | // calculate 74 | const w = safe(Math.min(width, Math.max(bSize.width, aSize.width))); 75 | const bw = Math.min(w, bSize.width); 76 | const bh = safe((bw / bSize.width) * bSize.height); 77 | const aw = Math.min(w, aSize.width); 78 | const ah = safe((aw / aSize.width) * aSize.height); 79 | const h = Math.max(bh, ah); 80 | 81 | return { 82 | canvas: { 83 | width: Math.min(width, w), 84 | height: h, 85 | }, 86 | image: { 87 | width: w, 88 | height: h, 89 | loaded: bLoaded && aLoaded, 90 | before: { 91 | ref: bRef, 92 | width: bw, 93 | height: bh, 94 | loaded: bLoaded, 95 | handleLoad: handleBLoaded, 96 | }, 97 | after: { 98 | ref: aRef, 99 | width: aw, 100 | height: ah, 101 | loaded: aLoaded, 102 | handleLoad: handleALoaded, 103 | }, 104 | }, 105 | }; 106 | }; 107 | -------------------------------------------------------------------------------- /src/components/VisuallyHidden/VisuallyHidden.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | 3 | /** 4 | * @see https://github.com/cvializ/amphtml/blob/70d2c6a4d8b3e51bea21918e907ef2a5e9e33e50/css/amp.css#L244-L274 5 | */ 6 | export const wrapper = style({ 7 | position: 'fixed !important' as any, 8 | top: '0 !important', 9 | left: '0 !important', 10 | display: 'block !important', 11 | visibility: 'visible !important' as any, 12 | overflow: 'hidden !important', 13 | margin: '0 !important', 14 | padding: '0 !important', 15 | width: '4px !important', 16 | height: '4px !important', 17 | border: 'none !important', 18 | opacity: '0 !important', 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/VisuallyHidden/VisuallyHidden.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { VisuallyHidden } from './'; 3 | 4 | type Component = typeof VisuallyHidden; 5 | type Story = StoryObj; 6 | 7 | export default { 8 | component: VisuallyHidden, 9 | args: { 10 | children: 'Hidden', 11 | }, 12 | } satisfies Meta; 13 | 14 | export const Overview: Story = {}; 15 | -------------------------------------------------------------------------------- /src/components/VisuallyHidden/VisuallyHidden.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as styles from './VisuallyHidden.css'; 3 | 4 | export type Props = React.PropsWithChildren<{}>; 5 | 6 | export const VisuallyHidden = ({ children, ...rest }: Props) => ( 7 |
    8 | {children} 9 |
    10 | ); 11 | -------------------------------------------------------------------------------- /src/components/VisuallyHidden/index.ts: -------------------------------------------------------------------------------- 1 | export * from './VisuallyHidden'; 2 | -------------------------------------------------------------------------------- /src/components/icons/ArrowDownIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const ArrowDownIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/icons/ArrowLeftIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const ArrowLeftIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/icons/ArrowRightIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const ArrowRightIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/icons/ArrowUpIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const ArrowUpIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/icons/CloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const CloseIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/icons/HelpIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const HelpIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/icons/MoreIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const MoreIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/components/icons/SearchIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const SearchIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/icons/SignChangedIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const SignChangedIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/icons/SignDeletedIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const SignDeletedIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/icons/SignNewIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const SignNewIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/icons/SignPassedIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = React.ComponentProps<'svg'>; 4 | 5 | export const SignPassedIcon = ({ fill, ...rest }: Props) => ( 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/internal/BaseButton/BaseButton.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { tokens } from '../../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | margin: 0, 6 | padding: 0, 7 | textDecoration: 'none', 8 | fontFamily: 'inherit', 9 | lineHeight: 'inherit', 10 | letterSpacing: 'inherit', 11 | transitionProperty: 'color, background, border, opacity', 12 | transitionDuration: tokens.duration.smallOut, 13 | transitionTimingFunction: tokens.easing.standard, 14 | cursor: 'pointer', 15 | ':disabled': { 16 | cursor: 'default', 17 | pointerEvents: 'none', 18 | }, 19 | ':focus': { 20 | outline: 'none', 21 | }, 22 | ':focus-visible': { 23 | boxShadow: tokens.state.focus, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/internal/BaseButton/BaseButton.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import React, { forwardRef } from 'react'; 3 | import type { Modify } from '../../../utils/types'; 4 | import { Link } from '../../Link'; 5 | import * as styles from './BaseButton.css'; 6 | 7 | export type Props = Modify< 8 | React.ComponentPropsWithoutRef<'button'>, 9 | { 10 | download?: any; 11 | href?: string; 12 | hrefLang?: string; 13 | media?: string; 14 | ping?: string; 15 | rel?: string; 16 | target?: string; 17 | type?: string; 18 | referrerPolicy?: string; 19 | } 20 | >; 21 | 22 | export const BaseButton = forwardRef< 23 | HTMLButtonElement | HTMLAnchorElement, 24 | Props 25 | >(({ className, type = 'button', href, children, ...rest }, ref) => { 26 | const linkable = href != null; 27 | const Component = linkable ? Link : 'button'; 28 | const props = linkable ? ({ href, ...rest } as any) : { type, ...rest }; 29 | 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/internal/BaseButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BaseButton'; 2 | -------------------------------------------------------------------------------- /src/components/internal/Collapse/Collapse.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { tokens } from '../../../styles/variables.css'; 3 | 4 | export const wrapper = style({ 5 | transitionProperty: 'height', 6 | transitionTimingFunction: tokens.easing.standard, 7 | selectors: { 8 | '&[aria-hidden="true"]:not(.collapse-exit)': { 9 | display: 'none', 10 | }, 11 | '&.collapse-enter': { 12 | overflow: 'hidden', 13 | height: 0, 14 | transitionDuration: 'var(--collapse-duration-enter)', 15 | }, 16 | '&.collapse-exit': { 17 | overflow: 'auto', 18 | height: 'auto', 19 | }, 20 | '&.collapse-exit-active': { 21 | overflow: 'hidden', 22 | height: 0, 23 | transitionDuration: 'var(--collapse-duration-exit)', 24 | }, 25 | }, 26 | }); 27 | 28 | export const inner = style({ 29 | display: 'flex', 30 | }); 31 | 32 | export const innerBox = style({ 33 | flexBasis: '100%', 34 | width: '100%', 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/internal/Collapse/Collapse.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import React, { useState } from 'react'; 3 | import { Color, Space } from '../../../styles/variables.css'; 4 | import { Collapse } from './'; 5 | 6 | type Component = typeof Collapse; 7 | type Story = StoryObj; 8 | 9 | export default { 10 | component: Collapse, 11 | args: { 12 | children: ( 13 |
    20 |

    21 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean vel 22 | tristique risus. Aenean efficitur condimentum auctor. Mauris 23 | consectetur magna neque, sollicitudin viverra lorem semper eget. Lorem 24 | ipsum dolor sit amet, consectetur adipiscing elit. Nulla rhoncus 25 | convallis ante, ac interdum urna ultricies at. Nulla facilisi. Sed id 26 | turpis mi. Nulla semper imperdiet suscipit. Mauris quis malesuada 27 | risus, a efficitur justo. 28 |

    29 |

    30 | Mauris accumsan nunc vel purus convallis luctus. Donec bibendum nulla 31 | lacus, vitae accumsan justo accumsan a. Duis ut nisi posuere, 32 | scelerisque sem nec, sagittis arcu. Sed vulputate imperdiet maximus. 33 | Praesent felis libero, consectetur non odio ac, ornare elementum 34 | lorem. Integer malesuada odio at efficitur volutpat. Sed sed volutpat 35 | ipsum. Sed eget lectus vitae risus sodales gravida vel vitae ante. 36 | Praesent semper nulla non elit mattis consectetur. Nullam pulvinar, 37 | neque vehicula malesuada ornare, enim orci posuere urna, a rutrum urna 38 | lorem eget lorem. Cras posuere faucibus turpis in fringilla. Etiam 39 | iaculis dolor ex. Morbi sollicitudin, purus vel vehicula dignissim, 40 | lectus urna euismod orci, sed porta tortor erat non ligula. Aenean ex 41 | risus, tempus facilisis sollicitudin eu, porta vitae justo. Nam 42 | tincidunt felis arcu, consectetur efficitur sapien euismod quis. 43 | Integer consequat erat id nibh maximus malesuada. 44 |

    45 |
    46 | ), 47 | }, 48 | } satisfies Meta; 49 | 50 | export const Overview: Story = { 51 | render: (args) => { 52 | const [open, setOpen] = useState(false); 53 | 54 | return ( 55 | <> 56 | 64 | 65 |
    66 | 67 | 68 | 69 | ); 70 | }, 71 | }; 72 | 73 | export const WithOpen: Story = { 74 | args: { 75 | open: true, 76 | duration: { enter: 0, exit: 0 }, 77 | }, 78 | }; 79 | 80 | export const WithClose: Story = { 81 | args: { 82 | open: false, 83 | duration: { enter: 0, exit: 0 }, 84 | }, 85 | }; 86 | -------------------------------------------------------------------------------- /src/components/internal/Collapse/Collapse.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef } from 'react'; 2 | import { CSSTransition, TransitionGroup } from 'react-transition-group'; 3 | import * as styles from './Collapse.css'; 4 | 5 | export type Props = Omit< 6 | React.ComponentPropsWithoutRef<'div'>, 7 | 'aria-hidden' 8 | > & { 9 | open: boolean; 10 | duration: { enter: number; exit: number }; 11 | }; 12 | 13 | export const Collapse = ({ open, duration, children, ...rest }: Props) => { 14 | const bodyRef = useRef(null); 15 | 16 | const applyPreAnimationStyles = useCallback((node: HTMLElement) => { 17 | const { current: body } = bodyRef; 18 | 19 | node.style.height = `${body != null ? body.clientHeight : 0}px`; 20 | }, []); 21 | 22 | const clearInlineStyles = useCallback((node: HTMLElement) => { 23 | node.style.height = ''; 24 | }, []); 25 | 26 | const handleEnter = useCallback( 27 | (node: HTMLElement) => { 28 | applyPreAnimationStyles(node); 29 | }, 30 | [applyPreAnimationStyles], 31 | ); 32 | 33 | const handleEntered = useCallback( 34 | (node: HTMLElement) => { 35 | clearInlineStyles(node); 36 | }, 37 | [clearInlineStyles], 38 | ); 39 | 40 | const handleExit = useCallback( 41 | (node: HTMLElement) => { 42 | applyPreAnimationStyles(node); 43 | }, 44 | [applyPreAnimationStyles], 45 | ); 46 | 47 | const handleExiting = useCallback( 48 | (node: HTMLElement) => { 49 | clearInlineStyles(node); 50 | }, 51 | [clearInlineStyles], 52 | ); 53 | 54 | return ( 55 | 56 | {open && ( 57 | 67 |
    77 |
    78 |
    {children}
    79 |
    80 |
    81 |
    82 | )} 83 |
    84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/components/internal/Collapse/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Collapse'; 2 | -------------------------------------------------------------------------------- /src/components/internal/Ellipsis/Ellipsis.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from '@vanilla-extract/css'; 2 | 3 | export const variants = styleVariants({ 4 | single: { 5 | display: 'block', 6 | whiteSpace: 'nowrap', 7 | }, 8 | multiple: { 9 | display: '-webkit-box', 10 | WebkitLineClamp: 'var(--ellipsis-line)', 11 | WebkitBoxOrient: 'vertical', 12 | }, 13 | }); 14 | 15 | export const wrapper = style({ 16 | overflow: 'hidden', 17 | textOverflow: 'ellipsis', 18 | wordWrap: 'break-word', 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/internal/Ellipsis/Ellipsis.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import React from 'react'; 3 | import type { Modify } from '../../../utils/types'; 4 | import * as styles from './Ellipsis.css'; 5 | 6 | export type Props = Modify< 7 | React.ComponentPropsWithoutRef<'span'>, 8 | { 9 | line?: number; 10 | } 11 | >; 12 | 13 | export const Ellipsis = ({ line = 1, children, ...rest }: Props) => { 14 | return ( 15 | 1, 25 | })} 26 | > 27 | {children} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/internal/Ellipsis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Ellipsis'; 2 | -------------------------------------------------------------------------------- /src/components/internal/Portal/Portal.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import React from 'react'; 3 | import { Portal } from './'; 4 | 5 | type Component = typeof Portal; 6 | type Story = StoryObj; 7 | 8 | export default { 9 | component: Portal, 10 | } satisfies Meta; 11 | 12 | export const Overview: Story = { 13 | render: () => ( 14 | <> 15 |
    Outside Portal
    16 | Inside Portal 17 | 18 | ), 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/internal/Portal/Portal.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import { useEffect, useState } from 'react'; 3 | import { createPortal } from 'react-dom'; 4 | import { MainAppId } from '../../../constants'; 5 | 6 | const portal: { 7 | el: HTMLDivElement | null; 8 | count: number; 9 | } = { 10 | el: null, 11 | count: 0, 12 | }; 13 | 14 | export type Props = { 15 | children: React.ReactNode; 16 | onRendered?: () => void; 17 | }; 18 | 19 | export const Portal = ({ children, onRendered }: Props) => { 20 | const [mounted, setMounted] = useState(false); 21 | 22 | useEffect(() => { 23 | const app = 24 | document.getElementById(MainAppId) ?? 25 | document.getElementById('storybook-root'); 26 | if (app == null) { 27 | return; 28 | } 29 | 30 | if (portal.el == null) { 31 | portal.el = document.createElement('div'); 32 | portal.el.classList.add('portal'); 33 | app.appendChild(portal.el); 34 | } 35 | 36 | portal.count++; 37 | 38 | setMounted(true); 39 | 40 | return () => { 41 | portal.count--; 42 | 43 | if (portal.count <= 0 && portal.el != null) { 44 | app.removeChild(portal.el); 45 | portal.count = 0; 46 | portal.el = null; 47 | } 48 | }; 49 | }, []); 50 | 51 | useEffect(() => { 52 | if (mounted && onRendered != null) { 53 | onRendered(); 54 | } 55 | }, [mounted, onRendered]); 56 | 57 | if (!mounted || portal.el == null) { 58 | return null; 59 | } 60 | 61 | return createPortal(children, portal.el); 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/internal/Portal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Portal'; 2 | -------------------------------------------------------------------------------- /src/components/internal/Transparent/Transparent.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | 3 | export const wrapper = style({ 4 | position: 'absolute', 5 | top: 0, 6 | right: 0, 7 | bottom: 0, 8 | left: 0, 9 | zIndex: 1, 10 | display: 'block', 11 | width: '100%', 12 | height: '100%', 13 | background: `url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAySURBVHgB7dAxDgAwCAJA2nTk/y9lr35ABuPorYSQcH5CQVIV4aJpi8Zzl5PE+OIWjQDL+Qn2c7fZmAAAAABJRU5ErkJggg==')`, 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/internal/Transparent/Transparent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as styles from './Transparent.css'; 3 | 4 | export type Props = React.ComponentPropsWithoutRef<'span'>; 5 | 6 | export const Transparent = ({ ...rest }: Props) => ( 7 | 8 | ); 9 | -------------------------------------------------------------------------------- /src/components/internal/Transparent/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Transparent'; 2 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const MainAppId = 'app'; 2 | -------------------------------------------------------------------------------- /src/context/AnchorScrollContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type AnchorScrollContextValue = { 4 | readonly hash: string; 5 | readonly consumed: boolean; 6 | consume(): void; 7 | }; 8 | 9 | export const AnchorScrollContext = 10 | React.createContext({ 11 | hash: '#', 12 | consumed: true, 13 | consume: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function 14 | }); 15 | 16 | class HashValue implements AnchorScrollContextValue { 17 | private _ = false; 18 | hash = location.hash; 19 | consume() { 20 | this._ = true; 21 | } 22 | get consumed() { 23 | return this._; 24 | } 25 | } 26 | 27 | export const AnchorScrollProvider = ({ children }: React.PropsWithChildren) => { 28 | const value: AnchorScrollContextValue = new HashValue(); 29 | 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/context/HistoryContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext } from 'react'; 2 | import type { History } from 'history'; 3 | import history from 'history/browser'; 4 | 5 | export type HistoryContextValue = History; 6 | 7 | export const HistoryContext = createContext(history); 8 | 9 | export const HistoryContextProvider = ({ 10 | children, 11 | }: React.PropsWithChildren) => ( 12 | {children} 13 | ); 14 | -------------------------------------------------------------------------------- /src/context/WorkerContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import type { WorkerClient } from '../worker-client'; 3 | 4 | export type WorkerContextValue = WorkerClient | null; 5 | 6 | export const WorkerContext = createContext(null); 7 | -------------------------------------------------------------------------------- /src/detector-wrapper/index.ts: -------------------------------------------------------------------------------- 1 | import { instantiateCachedURL } from './util'; 2 | 3 | export type ModuleInitialize = (instance: ModuleClass) => void; 4 | 5 | export type ModuleOptions = { 6 | init: ModuleInitialize; 7 | version: number; 8 | wasmUrl: string; 9 | }; 10 | 11 | export class ModuleClass { 12 | private _init: ModuleInitialize; 13 | private _version: number; 14 | private _wasmUrl: string; 15 | 16 | public constructor({ init, version, wasmUrl }: ModuleOptions) { 17 | this._init = init; 18 | this._version = version; 19 | this._wasmUrl = wasmUrl; 20 | } 21 | 22 | public locateFile(baseName: string) { 23 | return self.location.pathname.replace(/\[^\/]*$/, '/') + baseName; 24 | } 25 | 26 | public instantiateWasm( 27 | imports: any, 28 | callback: (instance: WebAssembly.Instance) => void, 29 | ) { 30 | instantiateCachedURL(this._version, this._wasmUrl, imports).then( 31 | (instance) => callback(instance), 32 | ); 33 | 34 | return {}; 35 | } 36 | 37 | public onInit(callback: ModuleInitialize) { 38 | this._init = callback; 39 | } 40 | 41 | public onRuntimeInitialized() { 42 | if (this._init) { 43 | return this._init(this); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: inherit; 5 | } 6 | 7 | :root { 8 | box-sizing: border-box; 9 | -webkit-text-size-adjust: 100%; 10 | -ms-text-size-adjust: 100%; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | :focus-visible { 16 | outline: 3px solid rgba(51, 166, 232, 0.4); 17 | outline-offset: 1px; 18 | } 19 | 20 | body { 21 | min-width: 320px; 22 | margin: 0; 23 | padding: 0; 24 | background: #f7f8f8; 25 | color: #00303c; 26 | font-family: 'Work Sans', sans-serif; 27 | line-height: 1.5; 28 | } 29 | 30 | body, 31 | #app { 32 | min-height: 100vh; 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks/useHistory.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { HistoryContext } from '../context/HistoryContext'; 3 | 4 | export const useHistory = () => useContext(HistoryContext); 5 | -------------------------------------------------------------------------------- /src/hooks/useKey.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | import { tinykeys } from 'tinykeys'; 3 | 4 | const ignore = new Set(['input', 'select', 'textarea']); 5 | 6 | export type UseKeyCallback = (e: KeyboardEvent) => void; 7 | 8 | export const useKey = ( 9 | target: 10 | | React.RefObject 11 | | React.MutableRefObject 12 | | null, 13 | keys: string[], 14 | callback: UseKeyCallback, 15 | ) => { 16 | const callbackFn = useCallback(callback, []); // eslint-disable-line react-hooks/exhaustive-deps 17 | const callbackRef = useRef(callbackFn); 18 | callbackRef.current = callback; 19 | 20 | useEffect(() => { 21 | if (target != null && target.current == null) { 22 | return; 23 | } 24 | 25 | return tinykeys( 26 | target?.current ?? window, 27 | Object.fromEntries( 28 | keys.map((key) => [ 29 | key, 30 | (e) => { 31 | const el = e.target; 32 | if ( 33 | el instanceof HTMLElement && 34 | ignore.has(el.tagName.toLowerCase()) 35 | ) { 36 | return; 37 | } 38 | callbackRef.current(e); 39 | }, 40 | ]), 41 | ), 42 | ); 43 | }, [target, keys]); 44 | }; 45 | -------------------------------------------------------------------------------- /src/hooks/useMedia.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export const useMedia = (query: string) => { 4 | const [state, setState] = useState(() => window.matchMedia(query).matches); 5 | 6 | useEffect(() => { 7 | let mounted = true; 8 | 9 | const mql = window.matchMedia(query); 10 | 11 | const handleChange = () => { 12 | if (mounted) { 13 | setState(mql.matches); 14 | } 15 | }; 16 | 17 | mql.addListener(handleChange); 18 | 19 | setState(mql.matches); 20 | 21 | return () => { 22 | mounted = false; 23 | mql.removeListener(handleChange); 24 | }; 25 | }, [query]); 26 | 27 | return state; 28 | }; 29 | -------------------------------------------------------------------------------- /src/hooks/useMergeRefs.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | type ReactRef = React.Ref | React.MutableRefObject; 4 | 5 | const assignRef = (ref: ReactRef | undefined, value: T) => { 6 | if (ref == null) { 7 | return; 8 | } 9 | 10 | if (typeof ref === 'function') { 11 | ref(value); 12 | return; 13 | } 14 | 15 | try { 16 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 17 | // @ts-ignore 18 | ref.current = value; 19 | } catch (error) { 20 | throw new Error(`Cannot assign value '${value}' to ref '${ref}'`); 21 | } 22 | }; 23 | 24 | export const useMergeRefs = (...refs: (ReactRef | undefined)[]) => { 25 | return useMemo(() => { 26 | if (refs.every((ref) => ref == null)) { 27 | return null; 28 | } 29 | return (node: T) => { 30 | refs.forEach((ref) => { 31 | if (ref) { 32 | assignRef(ref, node); 33 | } 34 | }); 35 | }; 36 | }, refs); // eslint-disable-line react-hooks/exhaustive-deps 37 | }; 38 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export const usePrevious = (value: T): T | null => { 4 | const ref = useRef(null); 5 | 6 | useEffect(() => { 7 | ref.current = value; 8 | }); 9 | 10 | return ref.current; 11 | }; 12 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createStore } from 'jotai'; 2 | import React, { StrictMode } from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | import { App } from './App'; 5 | import { MainAppId } from './constants'; 6 | import './global.css'; 7 | import type { RegData } from './types/reg'; 8 | import { WorkerClient } from './worker-client'; 9 | import { initializeEntityState } from './states/entity'; 10 | import { initializeWorkerState } from './states/worker'; 11 | 12 | const regData = (window as any)['__reg__'] as RegData; 13 | 14 | // x-img-diff 15 | const workerClient = new WorkerClient(); 16 | const ximgdiffConfig = regData.ximgdiffConfig || { enabled: false }; 17 | 18 | // INFO: set false on file: protocol 19 | // ref: https://github.com/reg-viz/reg-cli/issues/506 20 | if (window.location.protocol.startsWith('file')) { 21 | ximgdiffConfig.enabled = false; 22 | } 23 | 24 | workerClient.start(ximgdiffConfig); 25 | 26 | // Store 27 | const store = createStore(); 28 | initializeWorkerState(store, workerClient); 29 | initializeEntityState(store, regData, workerClient); 30 | 31 | // Report App 32 | const app = document.getElementById(MainAppId); 33 | const root = createRoot(app!); 34 | root.render( 35 | 36 | 37 | , 38 | ); 39 | -------------------------------------------------------------------------------- /src/mocks.ts: -------------------------------------------------------------------------------- 1 | import type { RegEntity } from './types/reg'; 2 | 3 | export const createRegEntity = (properties: Partial): RegEntity => ({ 4 | id: '', 5 | variant: 'passed', 6 | name: '', 7 | diff: '', 8 | before: '', 9 | after: '', 10 | ...properties, 11 | }); 12 | -------------------------------------------------------------------------------- /src/states/entity.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom, useAtomValue } from 'jotai'; 2 | import { useCallback } from 'react'; 3 | import { WorkerEventType } from '../types/event'; 4 | import type { RegData, RegEntity } from '../types/reg'; 5 | import type { Store } from '../types/store'; 6 | import { toEntities } from '../utils/transformer'; 7 | import type { WorkerClient } from '../worker-client'; 8 | import { useWorkerClient } from './worker'; 9 | 10 | type EntityState = { 11 | new: RegEntity[]; 12 | passed: RegEntity[]; 13 | failed: RegEntity[]; 14 | deleted: RegEntity[]; 15 | }; 16 | 17 | const defaultEntityAtom = atom({ 18 | new: [], 19 | passed: [], 20 | failed: [], 21 | deleted: [], 22 | }); 23 | 24 | const entityAtom = atom({ 25 | new: [], 26 | passed: [], 27 | failed: [], 28 | deleted: [], 29 | }); 30 | 31 | const allEntitiesAtom = atom((get) => { 32 | const entity = get(entityAtom); 33 | return entity.failed.concat(entity.new, entity.deleted, entity.passed); 34 | }); 35 | 36 | const filteredAtom = atom(false); 37 | 38 | export const initializeEntityState = ( 39 | store: Store, 40 | data: RegData, 41 | worker: WorkerClient, 42 | ) => { 43 | const dirs = { 44 | diff: data.diffDir, 45 | expected: data.expectedDir, 46 | actual: data.actualDir, 47 | }; 48 | 49 | const diffExtension = data.diffImageExtension ?? 'png'; 50 | 51 | store.set(defaultEntityAtom, { 52 | new: toEntities('new', dirs, data.newItems, diffExtension), 53 | passed: toEntities('passed', dirs, data.passedItems, diffExtension), 54 | failed: toEntities('changed', dirs, data.failedItems, diffExtension), 55 | deleted: toEntities('deleted', dirs, data.deletedItems, diffExtension), 56 | }); 57 | 58 | const defaultEntity = store.get(defaultEntityAtom); 59 | 60 | store.set(entityAtom, { 61 | ...defaultEntity, 62 | ...store.get(defaultEntityAtom), 63 | }); 64 | 65 | worker.requestFilterInit({ 66 | newItems: defaultEntity.new, 67 | passedItems: defaultEntity.passed, 68 | failedItems: defaultEntity.failed, 69 | deletedItems: defaultEntity.deleted, 70 | }); 71 | 72 | worker.subscribe(WorkerEventType.RESULT_FILTER, (payload) => { 73 | store.set(entityAtom, { 74 | new: payload.newItems, 75 | passed: payload.passedItems, 76 | failed: payload.failedItems, 77 | deleted: payload.deletedItems, 78 | }); 79 | }); 80 | }; 81 | 82 | export const useEntities = () => { 83 | const entity = useAtomValue(entityAtom); 84 | const allItems = useAtomValue(allEntitiesAtom); 85 | 86 | return { 87 | newItems: entity.new, 88 | passedItems: entity.passed, 89 | failedItems: entity.failed, 90 | deletedItems: entity.deleted, 91 | allItems, 92 | }; 93 | }; 94 | 95 | export const useEntityFilter = () => { 96 | const worker = useWorkerClient(); 97 | const [isFiltered, setIsFiltered] = useAtom(filteredAtom); 98 | 99 | const filter = useCallback( 100 | (input: string) => { 101 | if (worker == null) { 102 | return; 103 | } 104 | 105 | const value = input.trim(); 106 | 107 | setIsFiltered(value !== ''); 108 | 109 | worker.requestFilter({ 110 | input: value, 111 | }); 112 | }, 113 | [worker, setIsFiltered], 114 | ); 115 | 116 | return [isFiltered, filter] as const; 117 | }; 118 | -------------------------------------------------------------------------------- /src/states/notification.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from 'jotai'; 2 | import { useCallback } from 'react'; 3 | 4 | const TIMEOUT = 5000; 5 | 6 | const notificationAtom = atom< 7 | | { 8 | id: null; 9 | message: null; 10 | } 11 | | { 12 | id: number; 13 | message: string; 14 | } 15 | >({ 16 | id: null, 17 | message: null, 18 | }); 19 | 20 | const derivedNotificationAtom = atom( 21 | (get) => get(notificationAtom).message, 22 | (get, set, message) => { 23 | const previous = get(notificationAtom); 24 | if (previous.id != null) { 25 | window.clearInterval(previous.id); 26 | } 27 | set(notificationAtom, { 28 | id: window.setTimeout(() => { 29 | set(notificationAtom, { id: null, message: null }); 30 | }, TIMEOUT), 31 | message, 32 | }); 33 | }, 34 | ); 35 | 36 | export const useNotify = () => { 37 | const [, setNotification] = useAtom(derivedNotificationAtom); 38 | 39 | return useCallback( 40 | (msg: string) => { 41 | setNotification(msg); 42 | }, 43 | [setNotification], 44 | ); 45 | }; 46 | 47 | export const useNotificationMessage = () => { 48 | const [message] = useAtom(derivedNotificationAtom); 49 | return message; 50 | }; 51 | -------------------------------------------------------------------------------- /src/states/sidebar.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom, useAtomValue } from 'jotai'; 2 | import { useCallback, useMemo } from 'react'; 3 | import type { RegLink } from '../types/reg'; 4 | import { toStructualItems } from '../utils/transformer'; 5 | import { BreakPoint } from '../styles/variables.css'; 6 | import { useEntities } from './entity'; 7 | 8 | const openAtom = atom( 9 | window.matchMedia(`(min-width: ${BreakPoint.MEDIUM}px)`).matches, 10 | ); 11 | 12 | const linksAtom = atom([]); 13 | 14 | export const useSidebarState = () => { 15 | const isOpen = useAtomValue(openAtom); 16 | const links = useAtomValue(linksAtom); 17 | 18 | return { 19 | isOpen, 20 | links, 21 | }; 22 | }; 23 | 24 | export const useSidebarMutators = () => { 25 | const [, setIsOpen] = useAtom(openAtom); 26 | 27 | const open = useCallback(() => { 28 | setIsOpen(true); 29 | }, [setIsOpen]); 30 | 31 | const close = useCallback(() => { 32 | setIsOpen(false); 33 | }, [setIsOpen]); 34 | 35 | const toggle = useCallback(() => { 36 | setIsOpen((prev) => !prev); 37 | }, [setIsOpen]); 38 | 39 | return { 40 | open, 41 | close, 42 | toggle, 43 | }; 44 | }; 45 | 46 | export const useSidebarEntities = () => { 47 | const entity = useEntities(); 48 | 49 | const newItems = useMemo( 50 | () => toStructualItems(entity.newItems), 51 | [entity.newItems], 52 | ); 53 | 54 | const passedItems = useMemo( 55 | () => toStructualItems(entity.passedItems), 56 | [entity.passedItems], 57 | ); 58 | 59 | const failedItems = useMemo( 60 | () => toStructualItems(entity.failedItems), 61 | [entity.failedItems], 62 | ); 63 | 64 | const deletedItems = useMemo( 65 | () => toStructualItems(entity.deletedItems), 66 | [entity.deletedItems], 67 | ); 68 | 69 | return { 70 | newItems, 71 | passedItems, 72 | failedItems, 73 | deletedItems, 74 | }; 75 | }; 76 | -------------------------------------------------------------------------------- /src/states/worker.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtomValue } from 'jotai'; 2 | import type { Store } from '../types/store'; 3 | import type { WorkerClient } from '../worker-client'; 4 | 5 | const workerAtom = atom(null); 6 | 7 | export const initializeWorkerState = (store: Store, worker: WorkerClient) => { 8 | store.set(workerAtom, worker); 9 | }; 10 | 11 | export const useWorkerClient = () => { 12 | const worker = useAtomValue(workerAtom); 13 | if (worker == null) { 14 | throw new Error('WorkerClient is not initialized'); 15 | } 16 | return worker; 17 | }; 18 | -------------------------------------------------------------------------------- /src/supports.ts: -------------------------------------------------------------------------------- 1 | export const supportsLoading = 'loading' in HTMLImageElement.prototype; 2 | -------------------------------------------------------------------------------- /src/types/event.ts: -------------------------------------------------------------------------------- 1 | import type { RegEntity, Matching } from './reg'; 2 | 3 | export enum WorkerEventType { 4 | // calculate 5 | INIT_CALC = 'init', 6 | REQUEST_CALC = 'req_calc', 7 | RESULT_CALC = 'res_calc', 8 | 9 | // filter 10 | INIT_FILTER = 'init_filter', 11 | REQUEST_FILTER = 'req_filter', 12 | RESULT_FILTER = 'res_filter', 13 | } 14 | 15 | export type WorkerEventDataPayloadMap = { 16 | [WorkerEventType.INIT_CALC]: void; 17 | 18 | [WorkerEventType.REQUEST_CALC]: { 19 | seq: number; 20 | raw: string; 21 | actualSrc: string; 22 | expectedSrc: string; 23 | img1: ImageData; 24 | img2: ImageData; 25 | }; 26 | [WorkerEventType.RESULT_CALC]: { 27 | seq: number; 28 | raw: string; 29 | actualSrc: string; 30 | expectedSrc: string; 31 | img1: ImageData; 32 | img2: ImageData; 33 | result: Matching; 34 | }; 35 | 36 | [WorkerEventType.INIT_FILTER]: { 37 | newItems: RegEntity[]; 38 | passedItems: RegEntity[]; 39 | failedItems: RegEntity[]; 40 | deletedItems: RegEntity[]; 41 | }; 42 | [WorkerEventType.REQUEST_FILTER]: { 43 | input: string; 44 | }; 45 | [WorkerEventType.RESULT_FILTER]: { 46 | newItems: RegEntity[]; 47 | passedItems: RegEntity[]; 48 | failedItems: RegEntity[]; 49 | deletedItems: RegEntity[]; 50 | }; 51 | }; 52 | 53 | export type WorkerEventDataPayload = 54 | WorkerEventDataPayloadMap[T]; 55 | 56 | export type WorkerEventData = 57 | WorkerEventDataPayload extends void 58 | ? { 59 | type: T; 60 | } 61 | : { 62 | type: T; 63 | payload: WorkerEventDataPayload; 64 | }; 65 | 66 | export type WorkerEvent = { 67 | [P in keyof WorkerEventDataPayloadMap]: { 68 | type: string; 69 | data: WorkerEventData

    ; 70 | }; 71 | }[keyof WorkerEventDataPayloadMap]; 72 | -------------------------------------------------------------------------------- /src/types/reg.ts: -------------------------------------------------------------------------------- 1 | export type RegVariant = 'passed' | 'new' | 'changed' | 'deleted'; 2 | 3 | export type XIMGDiffConfig = { 4 | enabled: boolean; 5 | workerUrl?: string; 6 | }; 7 | 8 | export type RegItem = { 9 | raw: string; 10 | encoded: string; 11 | }; 12 | 13 | export type RegLink = { 14 | href: string; 15 | label: string; 16 | }; 17 | 18 | export type RegData = { 19 | type: 'success' | 'danger'; 20 | actualDir: string; 21 | expectedDir: string; 22 | diffDir: string; 23 | hasNew: boolean; 24 | hadPassed: boolean; 25 | hasFailed: boolean; 26 | hasDeleted: boolean; 27 | newItems: RegItem[]; 28 | passedItems: RegItem[]; 29 | failedItems: RegItem[]; 30 | deletedItems: RegItem[]; 31 | ximgdiffConfig?: XIMGDiffConfig; 32 | links?: RegLink[]; 33 | diffImageExtension?: 'webp' | 'png'; 34 | }; 35 | 36 | export type RegEntity = { 37 | id: string; 38 | variant: RegVariant; 39 | name: string; 40 | diff: string; 41 | before: string; 42 | after: string; 43 | }; 44 | 45 | export type RegStructualItem = { 46 | id: string; 47 | path: string; 48 | name: string; 49 | children: RegStructualItem[]; 50 | }; 51 | 52 | export type Rect = { 53 | width: number; 54 | height: number; 55 | x: number; 56 | y: number; 57 | }; 58 | 59 | export type Size = { 60 | width: number; 61 | height: number; 62 | }; 63 | 64 | export type DetectMatch = { 65 | bounding: Rect; 66 | center: Rect; 67 | diffMarkers: Rect[]; 68 | }; 69 | 70 | export type Matching = { 71 | images: Size[]; 72 | matches: DetectMatch[][]; 73 | strayingRects: Rect[][]; 74 | }; 75 | -------------------------------------------------------------------------------- /src/types/store.ts: -------------------------------------------------------------------------------- 1 | import type { createStore } from 'jotai'; 2 | 3 | export type Store = ReturnType; 4 | -------------------------------------------------------------------------------- /src/utils/focus.ts: -------------------------------------------------------------------------------- 1 | import { findFocusable } from './selector'; 2 | 3 | export const tryFocus = (element: HTMLElement | null): boolean => { 4 | if (element == null) { 5 | return false; 6 | } 7 | 8 | element.focus(); 9 | 10 | if (document.activeElement !== element) { 11 | element.setAttribute('tabindex', '0'); 12 | element.focus(); 13 | } 14 | 15 | return document.activeElement === element; 16 | }; 17 | 18 | const trySiblingsFocus = ( 19 | element: HTMLElement | null, 20 | callback: (focusable: HTMLElement[], index: number) => boolean, 21 | ): boolean => { 22 | if (element == null) { 23 | return false; 24 | } 25 | 26 | const focusable = findFocusable(element); 27 | if (focusable.length < 1) { 28 | return false; 29 | } 30 | 31 | const index = focusable.findIndex((el) => el === document.activeElement); 32 | if (index < 0) { 33 | return tryFocus(focusable[index]); 34 | } 35 | 36 | return callback(focusable, index); 37 | }; 38 | 39 | export const tryNextFocus = (element: HTMLElement | null): boolean => 40 | trySiblingsFocus(element, (focusable, index) => 41 | focusable.splice(index + 1).some(tryFocus), 42 | ); 43 | 44 | export const tryPreviousFocus = (element: HTMLElement | null): boolean => 45 | trySiblingsFocus(element, (focusable, index) => 46 | focusable.splice(0, index).reverse().some(tryFocus), 47 | ); 48 | -------------------------------------------------------------------------------- /src/utils/selector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Focusable Elements - Browser Compatibility Table https://allyjs.io/data-tables/focusable.html 3 | */ 4 | const FOCUSABLE_SELECTOR = [ 5 | ...['button', 'keygen', 'select', 'textarea'].map( 6 | (s) => `${s}:not(:disabled)`, 7 | ), 8 | 'input:not(:disabled):not([type="hidden"]):not([type="file"])', 9 | 'details > summary', 10 | 'a[href]:not([rel="ignore"])', 11 | 'area[href]', 12 | '[tabindex]:not([disabled]):not([tabindex=""])', 13 | '[contenteditable]', 14 | 'iframe', 15 | 'audio', 16 | 'video', 17 | ].join(','); 18 | 19 | export const findFocusable = (element: HTMLElement): HTMLElement[] => { 20 | const { childNodes } = element; 21 | let nodes: HTMLElement[] = []; 22 | 23 | for (let i = 0; i < childNodes.length; i += 1) { 24 | const node = childNodes[i]; 25 | 26 | if (!(node instanceof HTMLElement)) { 27 | continue; 28 | } 29 | 30 | if (node.matches(FOCUSABLE_SELECTOR)) { 31 | nodes.push(node); 32 | } else { 33 | nodes = [...nodes, ...findFocusable(node)]; 34 | } 35 | } 36 | 37 | return nodes; 38 | }; 39 | 40 | export const findFirstFocusable = ( 41 | element: HTMLElement, 42 | ): HTMLElement | null => { 43 | const { childNodes } = element; 44 | 45 | for (let i = 0; i < childNodes.length; i += 1) { 46 | const node = childNodes[i]; 47 | 48 | if (!(node instanceof HTMLElement)) { 49 | continue; 50 | } 51 | 52 | if (node.matches(FOCUSABLE_SELECTOR)) { 53 | return node; 54 | } 55 | 56 | const found = findFirstFocusable(node); 57 | if (found != null) { 58 | return found; 59 | } 60 | } 61 | 62 | return null; 63 | }; 64 | 65 | export const findLastFocusable = (element: HTMLElement): HTMLElement | null => { 66 | const { childNodes } = element; 67 | 68 | for (let i = childNodes.length - 1; i >= 0; i -= 1) { 69 | const node = childNodes[i]; 70 | 71 | if (!(node instanceof HTMLElement)) { 72 | continue; 73 | } 74 | 75 | if (node.matches(FOCUSABLE_SELECTOR)) { 76 | return node; 77 | } 78 | 79 | const found = findLastFocusable(node); 80 | if (found != null) { 81 | return found; 82 | } 83 | } 84 | 85 | return null; 86 | }; 87 | -------------------------------------------------------------------------------- /src/utils/transformer.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | RegItem, 3 | RegStructualItem, 4 | RegVariant, 5 | RegEntity, 6 | } from '../types/reg'; 7 | 8 | type Dirs = { 9 | diff: string; 10 | actual: string; 11 | expected: string; 12 | }; 13 | 14 | export const toEntities = ( 15 | variant: RegVariant, 16 | dirs: Dirs, 17 | items: RegItem[], 18 | diffExtension: 'png' | 'webp' = 'png', 19 | ): RegEntity[] => { 20 | const join = (key: keyof Dirs, to: string) => 21 | dirs[key].replace(/\/$/, '') + '/' + to.replace(/^\//, ''); 22 | 23 | return items.map((item) => { 24 | const id = `${variant}-${item.encoded}`.replace(/[=?]/g, '-'); 25 | 26 | return { 27 | id, 28 | variant, 29 | name: item.raw, 30 | diff: join('diff', item.encoded).replace(/\.[^.]+$/, `.${diffExtension}`), 31 | before: join('expected', item.encoded), 32 | after: join('actual', item.encoded), 33 | }; 34 | }); 35 | }; 36 | 37 | export const toStructualItems = (entities: RegEntity[]): RegStructualItem[] => { 38 | const results: RegStructualItem[] = []; 39 | 40 | entities.forEach((entity) => { 41 | const id = entity.id; 42 | const path = entity.name; 43 | const segments = path.split('/'); 44 | let obj = results; 45 | 46 | segments.forEach((segment) => { 47 | const v = obj.find((r) => r.name === segment); 48 | 49 | if (v != null) { 50 | obj = v.children; 51 | } else { 52 | const o = { 53 | id, 54 | path, 55 | name: segment, 56 | children: [], 57 | }; 58 | 59 | obj.push(o); 60 | obj = o.children; 61 | } 62 | }); 63 | }); 64 | 65 | return results; 66 | }; 67 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type PickBy = Pick< 2 | T, 3 | { [K in keyof T]: T[K] extends V ? K : never }[keyof T] 4 | >; 5 | 6 | export type Modify = Omit & U; 7 | 8 | export type Enum = T[keyof T]; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "ESNext", 5 | "lib": ["es2018", "dom"], 6 | "allowJs": false, 7 | "checkJs": false, 8 | "jsx": "react", 9 | "sourceMap": true, 10 | "noEmit": true, 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "moduleResolution": "node", 18 | "allowSyntheticDefaultImports": true, 19 | "esModuleInterop": true, 20 | "resolveJsonModule": true, 21 | "experimentalDecorators": true, 22 | "emitDecoratorMetadata": false 23 | }, 24 | "include": ["src/**/*", "types/**/*.d.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /types/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reg-viz/reg-cli-report-ui/89d8a240f4f949f1be6dc0dbf1b895ef11d00eb2/types/.gitkeep -------------------------------------------------------------------------------- /types/resize-observer.d.ts: -------------------------------------------------------------------------------- 1 | type ResizeObserverEntry = { 2 | target: Element; 3 | contentRect: DOMRectReadOnly; 4 | borderBoxSize: { 5 | readonly inlineSize: number; 6 | readonly blockSize: number; 7 | }; 8 | contentBoxSize: { 9 | readonly inlineSize: number; 10 | readonly blockSize: number; 11 | }; 12 | }; 13 | 14 | type ResizeObserverCallback = (entries: ResizeObserverEntry[]) => void; 15 | 16 | // For now TypeScript dom.d.ts does not export ResizeObserver definition, so we should self-define. 17 | // But this definition should be removed when ts esports right one. 18 | declare class ResizeObserver { 19 | constructor(cb: ResizeObserverCallback); 20 | observe(targetElement: HTMLElement): void; 21 | disconnect(): void; 22 | } 23 | -------------------------------------------------------------------------------- /vite.config.source.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; 5 | 6 | export default defineConfig(({ mode }) => { 7 | const value = (dev, fallback) => (mode === 'development' ? dev : fallback); 8 | 9 | return { 10 | define: { 11 | 'process.env.NODE_ENV': JSON.stringify( 12 | value('development', 'production'), 13 | ), 14 | }, 15 | build: { 16 | emptyOutDir: false, 17 | copyPublicDir: false, 18 | lib: { 19 | entry: path.resolve(__dirname, 'src/index.tsx'), 20 | name: 'report', 21 | formats: ['umd'], 22 | fileName: () => 'report.js', 23 | }, 24 | }, 25 | plugins: [ 26 | react(), 27 | vanillaExtractPlugin({ 28 | identifiers: mode === 'development' ? 'debug' : 'short', 29 | }), 30 | ], 31 | }; 32 | }); 33 | -------------------------------------------------------------------------------- /vite.config.worker.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import { viteStaticCopy } from 'vite-plugin-static-copy'; 4 | 5 | export default defineConfig(({ mode }) => { 6 | const value = (dev, fallback) => (mode === 'development' ? dev : fallback); 7 | 8 | return { 9 | clearScreen: false, 10 | define: { 11 | 'process.env.NODE_ENV': JSON.stringify( 12 | value('development', 'production'), 13 | ), 14 | }, 15 | build: { 16 | emptyOutDir: false, 17 | copyPublicDir: false, 18 | minify: false, 19 | outDir: value('public', 'dist'), 20 | lib: { 21 | entry: path.resolve(__dirname, 'src/worker-main.ts'), 22 | name: 'worker', 23 | formats: ['iife'], 24 | fileName: () => value('worker-dev.js', 'worker.js'), 25 | }, 26 | }, 27 | plugins: value( 28 | [ 29 | viteStaticCopy({ 30 | targets: [ 31 | { 32 | src: path.join( 33 | __dirname, 34 | 'node_modules', 35 | 'x-img-diff-js', 36 | 'build', 37 | 'cv-wasm_browser.*', 38 | ), 39 | dest: '.', 40 | }, 41 | ], 42 | }), 43 | ], 44 | [], 45 | ), 46 | }; 47 | }); 48 | --------------------------------------------------------------------------------