mirror of
https://github.com/cypress-io/cypress.git
synced 2026-04-28 19:00:03 -05:00
Merge branch '10.0-release' of github.com:cypress-io/cypress into md-10.0-merge
This commit is contained in:
@@ -13,7 +13,6 @@
|
||||
/npm/angular @cypress-io/component-testing
|
||||
/npm/create-cypress-tests @cypress-io/component-testing
|
||||
/npm/cypress-schematic @cypress-io/component-testing
|
||||
/npm/design-system @cypress-io/component-testing
|
||||
/npm/eslint-plugin-dev @cypress-io/end-to-end @cypress-io/component-testing
|
||||
/npm/mount-utils @cypress-io/component-testing
|
||||
/npm/react @cypress-io/component-testing
|
||||
|
||||
@@ -75,11 +75,6 @@ system-tests/lib/fixtureDirs.ts
|
||||
/npm/react/cypress/videos
|
||||
/npm/react/.babel-cache
|
||||
|
||||
# from npm/design-system
|
||||
/npm/design-system/bin/*
|
||||
/npm/design-system/cypress/videos
|
||||
/npm/design-system/.babel-cache
|
||||
|
||||
# from npm/webpack-dev-server
|
||||
/npm/webpack-dev-server/cypress/videos
|
||||
|
||||
|
||||
+1
-29
@@ -1124,7 +1124,6 @@ jobs:
|
||||
job-names: >
|
||||
cli-visual-tests,
|
||||
reporter-integration-tests,
|
||||
npm-design-system,
|
||||
run-app-component-tests-chrome,
|
||||
run-app-integration-tests-chrome,
|
||||
run-frontend-shared-component-tests-chrome,
|
||||
@@ -1187,7 +1186,7 @@ jobs:
|
||||
# run @cypress/design-system before other packages are built
|
||||
- run: yarn lerna run build-prod --scope \"@cypress/design-system\"
|
||||
# make sure packages with TypeScript can be transpiled to JS
|
||||
- run: yarn lerna run build-prod --stream --ignore \"@cypress/design-system\"
|
||||
- run: yarn lerna run build-prod --stream
|
||||
# run unit tests from each individual package
|
||||
- run: yarn test
|
||||
# run type checking for each individual package
|
||||
@@ -1651,28 +1650,6 @@ jobs:
|
||||
path: npm/vue/test_results
|
||||
- store-npm-logs
|
||||
|
||||
npm-design-system:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- restore_cached_workspace
|
||||
- run:
|
||||
name: Build
|
||||
command: yarn workspace @cypress/design-system build
|
||||
- run:
|
||||
name: Run tests
|
||||
# will use PERCY_TOKEN environment variable if available
|
||||
command: |
|
||||
CYPRESS_KONFIG_ENV=production \
|
||||
PERCY_PARALLEL_NONCE=$CIRCLE_SHA1 \
|
||||
PERCY_ENABLE=${PERCY_TOKEN:-0} \
|
||||
PERCY_PARALLEL_TOTAL=-1 \
|
||||
yarn percy exec --parallel -- -- \
|
||||
yarn test --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json
|
||||
working_directory: npm/design-system
|
||||
- store_test_results:
|
||||
path: npm/design-system/test_results
|
||||
- store-npm-logs
|
||||
|
||||
npm-angular:
|
||||
<<: *defaults
|
||||
steps:
|
||||
@@ -2366,10 +2343,6 @@ linux-workflow: &linux-workflow
|
||||
- npm-webpack-batteries-included-preprocessor:
|
||||
requires:
|
||||
- build
|
||||
- npm-design-system:
|
||||
context: test-runner:percy
|
||||
requires:
|
||||
- build
|
||||
- npm-vue:
|
||||
requires:
|
||||
- build
|
||||
@@ -2434,7 +2407,6 @@ linux-workflow: &linux-workflow
|
||||
- unit-tests-release
|
||||
- cli-visual-tests
|
||||
- reporter-integration-tests
|
||||
- npm-design-system
|
||||
- run-app-component-tests-chrome
|
||||
- run-app-integration-tests-chrome
|
||||
- run-frontend-shared-component-tests-chrome
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"glob": "^7.1.6",
|
||||
"inquirer": "7.3.3",
|
||||
"ora": "^5.1.0",
|
||||
"recast": "0.20.4"
|
||||
"recast": "0.20.4",
|
||||
"semver": "7.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/babel__core": "^7.1.2",
|
||||
@@ -35,6 +36,7 @@
|
||||
"@types/mock-fs": "4.10.0",
|
||||
"@types/node": "14.14.31",
|
||||
"@types/ora": "^3.2.0",
|
||||
"@types/semver": "7.3.9",
|
||||
"copy": "0.3.2",
|
||||
"mocha": "7.1.1",
|
||||
"mock-fs": "5.1.1",
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"plugins": [
|
||||
"cypress",
|
||||
"@cypress/dev"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@cypress/dev/general",
|
||||
"plugin:@cypress/dev/tests",
|
||||
"plugin:@cypress/dev/react",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"../../packages/reporter/src/.eslintrc.json"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": {
|
||||
"cypress/globals": true
|
||||
},
|
||||
"rules": {
|
||||
"no-duplicate-imports": "off",
|
||||
"no-else-return": [
|
||||
"error",
|
||||
{
|
||||
"allowElseIf": true
|
||||
}
|
||||
],
|
||||
"react/display-name": "off",
|
||||
"react/function-component-definition": [
|
||||
"error",
|
||||
{
|
||||
"namedComponents": "arrow-function",
|
||||
"unnamedComponents": "arrow-function"
|
||||
}
|
||||
],
|
||||
"react/jsx-boolean-value": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"react/jsx-closing-bracket-location": [
|
||||
"error",
|
||||
"line-aligned"
|
||||
],
|
||||
"react/jsx-closing-tag-location": "error",
|
||||
"react/jsx-curly-brace-presence": [
|
||||
"error",
|
||||
{
|
||||
"props": "never",
|
||||
"children": "never"
|
||||
}
|
||||
],
|
||||
"react/jsx-curly-newline": "error",
|
||||
"react/jsx-filename-extension": [
|
||||
"warn",
|
||||
{
|
||||
"extensions": [
|
||||
".js",
|
||||
".jsx",
|
||||
".tsx"
|
||||
]
|
||||
}
|
||||
],
|
||||
"react/jsx-first-prop-new-line": "error",
|
||||
"react/jsx-max-props-per-line": [
|
||||
"error",
|
||||
{
|
||||
"maximum": 1,
|
||||
"when": "multiline"
|
||||
}
|
||||
],
|
||||
"react/jsx-no-bind": [
|
||||
"error",
|
||||
{
|
||||
"ignoreDOMComponents": true
|
||||
}
|
||||
],
|
||||
"react/jsx-no-useless-fragment": "error",
|
||||
"react/jsx-one-expression-per-line": [
|
||||
"error",
|
||||
{
|
||||
"allow": "literal"
|
||||
}
|
||||
],
|
||||
"react/jsx-sort-props": [
|
||||
"error",
|
||||
{
|
||||
"callbacksLast": true,
|
||||
"ignoreCase": true,
|
||||
"noSortAlphabetically": true,
|
||||
"reservedFirst": true
|
||||
}
|
||||
],
|
||||
"react/jsx-tag-spacing": [
|
||||
"error",
|
||||
{
|
||||
"closingSlash": "never",
|
||||
"beforeSelfClosing": "always"
|
||||
}
|
||||
],
|
||||
"react/jsx-wrap-multilines": [
|
||||
"error",
|
||||
{
|
||||
"declaration": "parens-new-line",
|
||||
"assignment": "parens-new-line",
|
||||
"return": "parens-new-line",
|
||||
"arrow": "parens-new-line",
|
||||
"condition": "parens-new-line",
|
||||
"logical": "parens-new-line",
|
||||
"prop": "parens-new-line"
|
||||
}
|
||||
],
|
||||
"react/no-array-index-key": "error",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/prop-types": "off",
|
||||
"quote-props": [
|
||||
"error",
|
||||
"as-needed"
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"lib/*"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/*.json"
|
||||
],
|
||||
"rules": {
|
||||
"quotes": "off",
|
||||
"comma-dangle": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.spec.tsx",
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"react/jsx-no-bind": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"watch-ignore": [
|
||||
"./test/_test-output",
|
||||
"node_modules"
|
||||
],
|
||||
"require": "../../node_modules/@packages/web-config/node-register",
|
||||
"exit": true
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
cypress
|
||||
@@ -1,63 +0,0 @@
|
||||
const cssFolders = require('../css.folders')
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin')
|
||||
|
||||
module.exports = {
|
||||
stories: [
|
||||
'../src/**/*.stories.mdx',
|
||||
'../src/**/*.stories.@(js|jsx|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
],
|
||||
webpackFinal: async (config) => {
|
||||
const sassLoader = {
|
||||
loader: 'sass-loader',
|
||||
options: {
|
||||
sassOptions: {
|
||||
includePaths: cssFolders,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config.module.rules.push(...[
|
||||
{
|
||||
test: /\.s[ca]ss$/,
|
||||
exclude: /\.module\.s[ca]ss$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: {
|
||||
compileType: 'icss',
|
||||
},
|
||||
},
|
||||
},
|
||||
sassLoader,
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.module\.s[ca]ss$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: {
|
||||
compileType: 'module',
|
||||
localIdentName: '[name]__[local]--[hash:base64:5]',
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
sassLoader,
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
config.resolve.plugins.push(new TsconfigPathsPlugin())
|
||||
|
||||
return config
|
||||
},
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import '../src/global.scss'
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
# [@cypress/design-system-v1.4.0](https://github.com/cypress-io/cypress/compare/@cypress/design-system-v1.3.0...@cypress/design-system-v1.4.0) (2021-05-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency cypress-real-events to version 1.4.0 🌟 ([#16363](https://github.com/cypress-io/cypress/issues/16363)) ([38ab170](https://github.com/cypress-io/cypress/commit/38ab170fead2369bda0fa14a0cc89f505141e682))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **design-system:** SpecList - FileTree - VirtualizedTree ([#16179](https://github.com/cypress-io/cypress/issues/16179)) ([a73adf2](https://github.com/cypress-io/cypress/commit/a73adf2b4959f72789fa7d0ff9d6b9f301296258))
|
||||
|
||||
# [@cypress/design-system-v1.3.0](https://github.com/cypress-io/cypress/compare/@cypress/design-system-v1.2.0...@cypress/design-system-v1.3.0) (2021-04-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ds:** make design-system tests display icons ([#16114](https://github.com/cypress-io/cypress/issues/16114)) ([480b4b0](https://github.com/cypress-io/cypress/commit/480b4b008c998fb68a45d1f98296a6b8aa500feb))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Design System Setup ([#15776](https://github.com/cypress-io/cypress/issues/15776)) ([8eaf4b4](https://github.com/cypress-io/cypress/commit/8eaf4b478b1a359c9284d055634fb2032afda536))
|
||||
|
||||
# [@cypress/design-system-v1.2.0](https://github.com/cypress-io/cypress/compare/@cypress/design-system-v1.1.0...@cypress/design-system-v1.2.0) (2021-04-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove failing imports ([#15661](https://github.com/cypress-io/cypress/issues/15661)) ([357a296](https://github.com/cypress-io/cypress/commit/357a296fde0355fda6388345b1d01e4ffa11d69c))
|
||||
* **runner-ct:** add highlighting to spec list fuzzy find ([#15604](https://github.com/cypress-io/cypress/issues/15604)) ([56234e5](https://github.com/cypress-io/cypress/commit/56234e52d6d1cbd292acdfd5f5d547f0c4706b51))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* simplify vite server ([#15416](https://github.com/cypress-io/cypress/issues/15416)) ([adc2fc8](https://github.com/cypress-io/cypress/commit/adc2fc893fbf32f1f6121d18ddb8a8096e5ebf39))
|
||||
* **deps:** update dependency electron to version 12.x 🌟 ([#15292](https://github.com/cypress-io/cypress/issues/15292)) ([b52ac98](https://github.com/cypress-io/cypress/commit/b52ac98a6944bc831221ccb730f89c6cc92a4573))
|
||||
|
||||
# [@cypress/design-system-v1.1.0](https://github.com/cypress-io/cypress/compare/@cypress/design-system-v1.0.0...@cypress/design-system-v1.1.0) (2021-03-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* addressing style regressions in the CT runner ([#15542](https://github.com/cypress-io/cypress/issues/15542)) ([ecccf54](https://github.com/cypress-io/cypress/commit/ecccf5444041eeeb1aa6bc911c4e06b520182ce3))
|
||||
* specs overflow horizontally and z-index issues ([#15547](https://github.com/cypress-io/cypress/issues/15547)) ([e60a029](https://github.com/cypress-io/cypress/commit/e60a02912f9d92ae8bce9d03c2167f9a393482f6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **design-system:** FileExplorer ([#15513](https://github.com/cypress-io/cypress/issues/15513)) ([f2b880c](https://github.com/cypress-io/cypress/commit/f2b880c09de5c4490027689af86b6844706c8a6b))
|
||||
|
||||
# @cypress/design-system-v1.0.0 (2021-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **runner-ct:** open link in external browser ([#15420](https://github.com/cypress-io/cypress/issues/15420)) ([d291157](https://github.com/cypress-io/cypress/commit/d291157f07ffebe961527fdd85c7ec51056801e7))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* initial infrastructure for design-system ([#15211](https://github.com/cypress-io/cypress/issues/15211)) ([f52d6e4](https://github.com/cypress-io/cypress/commit/f52d6e4ee10c82b766fefadaf8015a3f8bbc8899))
|
||||
* Runner-CT UI Improvements ([#15327](https://github.com/cypress-io/cypress/issues/15327)) ([e69b996](https://github.com/cypress-io/cypress/commit/e69b9968912471b9ece6298afd47fc6f14728813))
|
||||
@@ -1,81 +0,0 @@
|
||||
# @cypress/design-system
|
||||
|
||||
A design system for the surfaces of testing softwares. 🐛💅
|
||||
|
||||
## Values
|
||||
|
||||
### It is discreet
|
||||
|
||||
Let the surfaces fade away to allow work to happen.
|
||||
|
||||
### It is native
|
||||
|
||||
Let the surfaces be familiar to where work happens.
|
||||
|
||||
### It is accessible
|
||||
|
||||
Let the surfaces honor accepted standards so everyone can use them.
|
||||
|
||||
### It is specialized
|
||||
|
||||
Let the surfaces be appropriate for the job to be done. Favor consistency over novelty, but not at the cost of functionality.
|
||||
|
||||
## Usage
|
||||
The components work with or without the global stylesheet import. The stylesheet import is used to setup global scss tokens, colors, utility classes, and typography.
|
||||
|
||||
Component Usage:
|
||||
|
||||
```jsx
|
||||
import { Button } from '@cypress/design-system'
|
||||
```
|
||||
|
||||
To setup global CSS tokens and mixins, you can import the library's `index.scss`. You can either do this with `@use` or `@import` (See CSS Trick's intro to Sass modules [here](https://css-tricks.com/introducing-sass-modules/#import-files-with-use))
|
||||
|
||||
SCSS usage:
|
||||
|
||||
```scss
|
||||
// scoped within the *.scss file
|
||||
@use '@cypress/design-system' as *;
|
||||
|
||||
// import variables and mixins throughout the whole project
|
||||
// or @import('@cypress/design-system');
|
||||
|
||||
.my-component {
|
||||
text-color: $accent-color-01;
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
We are currently using:
|
||||
* CSS Modules for styling
|
||||
* TSX for components
|
||||
* SCSS with module support
|
||||
* Rollup to bundle
|
||||
* Cypress CT as a development environment
|
||||
* Webpack is required for Cypress CT but will soon be replaced by a rollup dev server
|
||||
|
||||
#### Developing locally
|
||||
|
||||
`yarn cy:open`
|
||||
|
||||
#### Building the library
|
||||
|
||||
`yarn build`
|
||||
|
||||
#### Deploying
|
||||
|
||||
TODO: Add netlify site support and static app wrapper
|
||||
|
||||
#### Testing
|
||||
|
||||
`yarn cy:run`
|
||||
|
||||
## TODO
|
||||
1. Deploy a static page demo-ing the design system
|
||||
2. Import the first component inside of RunnerCT
|
||||
3. Hook up tests to circle
|
||||
4. Publish the package on npm (switch `package.json`'s `publishConfig` to 'public' instead of 'restricted' and then merge into master)
|
||||
|
||||
## Changelog
|
||||
|
||||
[Changelog](./CHANGELOG.md)
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: ['@babel/plugin-proposal-optional-chaining'],
|
||||
presets: [
|
||||
'@babel/preset-env',
|
||||
'@babel/preset-react',
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
const path = require('path')
|
||||
|
||||
// Resolve css dir and both local and monorepo node_modules
|
||||
module.exports = ['src/css', 'node_modules', '../../node_modules'].map((p) => path.resolve(p))
|
||||
@@ -1,22 +0,0 @@
|
||||
const { defineConfig } = require('cypress')
|
||||
|
||||
module.exports = defineConfig({
|
||||
viewportWidth: 1024,
|
||||
viewportHeight: 800,
|
||||
video: false,
|
||||
projectId: 'z9dxah',
|
||||
env: {
|
||||
reactDevtools: true,
|
||||
},
|
||||
fixturesFolder: false,
|
||||
component: {
|
||||
excludeSpecPattern: [
|
||||
'**/__snapshots__/*',
|
||||
'**/__image_snapshots__/*',
|
||||
],
|
||||
devServer: {
|
||||
framework: 'react',
|
||||
bundler: 'vite',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Components App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div data-cy-root></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'regenerator-runtime/runtime'
|
||||
import 'cypress-real-events/support'
|
||||
import '@percy/cypress'
|
||||
import './storybook'
|
||||
import 'normalize.css/normalize.css'
|
||||
|
||||
// Need to register these once per app. Depending which components are consumed
|
||||
// from @cypress/design-system, different icons are required.
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fab } from '@fortawesome/free-brands-svg-icons'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(fas)
|
||||
library.add(fab)
|
||||
@@ -1,4 +0,0 @@
|
||||
import { setGlobalConfig } from '@storybook/testing-react'
|
||||
import * as sbPreview from '../../.storybook/preview'
|
||||
|
||||
setGlobalConfig(sbPreview)
|
||||
@@ -1,11 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>AUT Frame</title>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,121 +0,0 @@
|
||||
{
|
||||
"name": "@cypress/design-system",
|
||||
"version": "0.0.0-development",
|
||||
"description": "Styles, standards, and components used throughout Cypress",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "rimraf dist && rollup -c rollup.config.js",
|
||||
"build-prod": "yarn build",
|
||||
"build-storybook": "build-storybook",
|
||||
"build-style-types": "tsm \"src/css/derived/*.scss\" --nameFormat none --exportType default",
|
||||
"cy:open": "node ../../scripts/cypress.js open --component --project ${PWD}",
|
||||
"cy:open:debug": "node --inspect-brk ../../scripts/start.js --component-testing --project ${PWD}",
|
||||
"cy:run": "node ../../scripts/cypress.js run --component --project ${PWD}",
|
||||
"cy:run:debug": "node --inspect-brk ../../scripts/start.js --component-testing --run-project ${PWD}",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"test": "yarn cy:run",
|
||||
"test-unit": "mocha -r ../../node_modules/@packages/web-config/node-register src/**/*.specmocha.*",
|
||||
"transpile": "tsc",
|
||||
"watch": "yarn build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.0.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.17",
|
||||
"@iconify/icons-vscode-icons": "^1.1.4",
|
||||
"@iconify/react": "2.0.0-rc.8",
|
||||
"@iconify/types": "^1.0.6",
|
||||
"classnames": "^2.3.1",
|
||||
"debug": "^4.3.2",
|
||||
"react-aria": "^3.5.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.5",
|
||||
"react-vtree": "3.0.0-beta.1",
|
||||
"react-window": "^1.8.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.4.5",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
||||
"@babel/preset-env": "7.4.5",
|
||||
"@babel/preset-react": "7.0.0",
|
||||
"@babel/preset-typescript": "7.10.4",
|
||||
"@packages/web-config": "0.0.0-development",
|
||||
"@react-types/button": "^3.3.1",
|
||||
"@react-types/shared": "^3.5.0",
|
||||
"@rollup/plugin-commonjs": "^17.1.0",
|
||||
"@rollup/plugin-image": "2.0.6",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^11.1.1",
|
||||
"@rollup/plugin-typescript": "^8.2.1",
|
||||
"@storybook/addon-actions": "^6.1.21",
|
||||
"@storybook/addon-essentials": "^6.1.21",
|
||||
"@storybook/addon-links": "^6.1.21",
|
||||
"@storybook/preset-typescript": "^3.0.0",
|
||||
"@storybook/react": "^6.1.21",
|
||||
"@storybook/testing-react": "^0.0.12",
|
||||
"@types/node": "14.14.31",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.0",
|
||||
"@types/react-window": "^1.8.2",
|
||||
"@types/semver": "7.3.4",
|
||||
"babel-loader": "8.0.6",
|
||||
"css-loader": "^5.1.3",
|
||||
"cypress": "0.0.0-development",
|
||||
"cypress-real-events": "1.4.0",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"mocha-junit-reporter": "^2.0.0",
|
||||
"mocha-multi-reporters": "^1.5.1",
|
||||
"postcss": "^8.2.8",
|
||||
"react": "16.8.6",
|
||||
"react-dom": "16.8.6",
|
||||
"rollup": "^2.38.5",
|
||||
"rollup-plugin-copy": "^3.4.0",
|
||||
"rollup-plugin-peer-deps-external": "2.2.4",
|
||||
"rollup-plugin-postcss": "^4.0.0",
|
||||
"sass": "1.44.0",
|
||||
"sass-loader": "10.1.1",
|
||||
"style-loader": "^2.0.0",
|
||||
"svg-url-loader": "3.0.3",
|
||||
"ts-node": "^10.2.1",
|
||||
"tsc-alias": "^1.2.9",
|
||||
"tsconfig-paths-webpack-plugin": "^3.5.1",
|
||||
"typed-scss-modules": "^4.1.1",
|
||||
"typescript": "^4.2.3",
|
||||
"vite": "2.9.0-beta.3",
|
||||
"webpack": "^4.44.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^=16.x || ^=17.x",
|
||||
"react-dom": "^=16.x || ^=17.x"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cypress-io/cypress.git"
|
||||
},
|
||||
"homepage": "https://github.com/cypress-io/cypress/blob/master/npm/design-system/#readme",
|
||||
"keywords": [
|
||||
"design-system",
|
||||
"cypress",
|
||||
"cypress-io"
|
||||
],
|
||||
"unpkg": "dist/cypress-design-system.browser.js",
|
||||
"module": "dist/cypress-design-system.esm-bundler.js",
|
||||
"style": "dist/index.scss",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"standard": {
|
||||
"globals": [
|
||||
"Cypress",
|
||||
"cy",
|
||||
"expect"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import ts from '@rollup/plugin-typescript'
|
||||
import resolve from '@rollup/plugin-node-resolve'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
import json from '@rollup/plugin-json'
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external'
|
||||
import postcss from 'rollup-plugin-postcss'
|
||||
import image from '@rollup/plugin-image'
|
||||
import copy from 'rollup-plugin-copy'
|
||||
|
||||
import { replaceTscAliasPaths } from 'tsc-alias'
|
||||
|
||||
import pkg from './package.json'
|
||||
|
||||
const cssFolders = require('./css.folders')
|
||||
|
||||
const banner = `
|
||||
/**
|
||||
* ${pkg.name} v${pkg.version}
|
||||
* (c) ${new Date().getFullYear()} Cypress.io
|
||||
* Released under the MIT License
|
||||
*/
|
||||
`
|
||||
|
||||
function createEntry () {
|
||||
const config = {
|
||||
input: 'src/index.ts',
|
||||
external: [
|
||||
'react',
|
||||
'react-dom',
|
||||
],
|
||||
plugins: [
|
||||
peerDepsExternal(),
|
||||
// Mirrors that in tsconfig
|
||||
ts({
|
||||
declaration: true,
|
||||
sourceMap: true,
|
||||
inlineSources: true,
|
||||
exclude: ['**/*.spec.tsx', '**/*.stories.tsx'],
|
||||
}),
|
||||
resolve(),
|
||||
json(),
|
||||
commonjs(),
|
||||
postcss({
|
||||
modules: {
|
||||
globalModulePaths: ['src/css/derived/export.scss'],
|
||||
},
|
||||
use: [
|
||||
['sass', {
|
||||
includePaths: cssFolders,
|
||||
}],
|
||||
],
|
||||
}),
|
||||
image(),
|
||||
copy({
|
||||
targets: [
|
||||
// Purposefully ignore global.scss to prevent direct imports
|
||||
{
|
||||
src: './src/index.scss',
|
||||
dest: './dist',
|
||||
},
|
||||
// Purposefully ignore `derived` directory SASS
|
||||
{
|
||||
src: './src/css/*.scss',
|
||||
dest: './dist/css',
|
||||
},
|
||||
{
|
||||
src: './src/css/derived/*.d.ts',
|
||||
dest: './dist/css/derived',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ name: 'test', writeBundle: () => {
|
||||
replaceTscAliasPaths()
|
||||
} },
|
||||
],
|
||||
output: {
|
||||
banner,
|
||||
name: 'CypressDesignSystem',
|
||||
dir: './dist',
|
||||
// file: pkg.module,
|
||||
format: 'es',
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export default [
|
||||
createEntry(),
|
||||
]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,25 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
// @ts-ignore
|
||||
import LogoPng from '../../assets/cypress-logo.png'
|
||||
import styles from './CypressLogo.module.scss'
|
||||
|
||||
const sizes = {
|
||||
small: '8rem',
|
||||
medium: '12rem',
|
||||
large: '16rem',
|
||||
}
|
||||
|
||||
interface LogoProps {
|
||||
size: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
export const CypressLogo: React.FC<LogoProps> = (props) => {
|
||||
return (
|
||||
<img
|
||||
className={styles.logo}
|
||||
style={{ width: sizes[props.size] }}
|
||||
src={LogoPng}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { mount } from 'cypress/react'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fab } from '@fortawesome/free-brands-svg-icons'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
import { NavItem } from './types'
|
||||
import { LeftNav } from './LeftNav'
|
||||
|
||||
library.add(fas)
|
||||
library.add(fab)
|
||||
|
||||
const homeItem: NavItem = {
|
||||
id: 'foo',
|
||||
title: 'Foo button',
|
||||
icon: 'home',
|
||||
interaction: {
|
||||
type: 'anchor',
|
||||
href: 'https://cypress.io',
|
||||
},
|
||||
}
|
||||
|
||||
const makeOnClickItem: (options?: Partial<NavItem>) => NavItem = (options?: Partial<NavItem>) => {
|
||||
return {
|
||||
id: 'bar',
|
||||
title: 'Bar button',
|
||||
icon: 'key',
|
||||
interaction: {
|
||||
type: 'js',
|
||||
onClick: () => {
|
||||
},
|
||||
},
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
const items = [homeItem, makeOnClickItem()]
|
||||
|
||||
describe('LeftNav', () => {
|
||||
it('renders', () => {
|
||||
mount(<LeftNav items={[]} />)
|
||||
cy.get('nav').should('exist')
|
||||
})
|
||||
|
||||
it('renders a stack of items', () => {
|
||||
mountAndSnapshot(<LeftNav items={items} />)
|
||||
|
||||
cy.get('nav').should('exist')
|
||||
})
|
||||
|
||||
it('properly follows anchor links', () => {
|
||||
mount(<LeftNav items={[
|
||||
{
|
||||
id: 'foo',
|
||||
title: 'Foo button',
|
||||
icon: 'home',
|
||||
interaction: {
|
||||
type: 'anchor',
|
||||
href: '#foo',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>)
|
||||
|
||||
cy.get('a').first().eq(0).click().url().should('include', '#foo')
|
||||
})
|
||||
|
||||
it('should properly display in page', () => {
|
||||
const Comp = () => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr' }}>
|
||||
<LeftNav items={items} />
|
||||
<div style={{
|
||||
height: 1000,
|
||||
width: 1000,
|
||||
}}
|
||||
>
|
||||
This is the main page content
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
mount(<Comp />)
|
||||
})
|
||||
|
||||
it('properly follows JS onclicks', () => {
|
||||
const clickSpy = cy.spy()
|
||||
|
||||
const Wrapper = () => {
|
||||
const [activeIndex, setActiveIndex] = React.useState<number>()
|
||||
|
||||
const items = [
|
||||
homeItem,
|
||||
makeOnClickItem({
|
||||
location: 'bottom',
|
||||
id: 'bottom-item',
|
||||
icon: 'ad',
|
||||
interaction: {
|
||||
type: 'js',
|
||||
onClick ({ index }) {
|
||||
if (index === activeIndex) {
|
||||
return setActiveIndex(undefined)
|
||||
}
|
||||
|
||||
setActiveIndex(2)
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeOnClickItem({
|
||||
itemClassesActive: 'second-item-button-active',
|
||||
itemClasses: 'second-item-button',
|
||||
interaction: {
|
||||
type: 'js',
|
||||
onClick ({ index }) {
|
||||
if (index === activeIndex) {
|
||||
return setActiveIndex(undefined)
|
||||
}
|
||||
|
||||
setActiveIndex(1)
|
||||
clickSpy()
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr' }}>
|
||||
<LeftNav
|
||||
activeIndex={activeIndex}
|
||||
items={items}
|
||||
/>
|
||||
<div style={{ height: 300, width: 1000 }}>This is the main page content</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
mount(<Wrapper />)
|
||||
|
||||
cy.get('a').eq(1).click().should(() => {
|
||||
expect(clickSpy).to.be.called
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,134 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import cs from 'classnames'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(fas)
|
||||
|
||||
import styles from './LeftNav.module.scss'
|
||||
import { LeftNavProps, NavButtonProps, NavLocation, NavItem } from './types'
|
||||
|
||||
interface NavItemDefinedLocation extends NavItem {
|
||||
location: NavLocation
|
||||
_index: number
|
||||
}
|
||||
|
||||
interface GroupedItems {
|
||||
top: NavItemDefinedLocation[]
|
||||
bottom: NavItemDefinedLocation[]
|
||||
}
|
||||
|
||||
export const LeftNav: React.FC<LeftNavProps> = ({ items, activeIndex, leftNavClasses, navButtonClasses }) => {
|
||||
const mappedItems = items.reduce<GroupedItems>((acc, curr, index) => {
|
||||
if (curr.location === 'bottom') {
|
||||
return {
|
||||
top: acc.top,
|
||||
bottom: acc.bottom.concat({
|
||||
...curr,
|
||||
_index: curr._index || index,
|
||||
location: 'bottom',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
top: acc.top.concat({
|
||||
...curr,
|
||||
_index: curr._index || index,
|
||||
location: 'top',
|
||||
}),
|
||||
bottom: acc.bottom,
|
||||
}
|
||||
}, { top: [], bottom: [] })
|
||||
|
||||
const navItem = (item: NavItemDefinedLocation) => (
|
||||
<NavButtonCell
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={item._index === activeIndex}
|
||||
navButtonClasses={navButtonClasses}
|
||||
index={item._index}
|
||||
/>
|
||||
)
|
||||
|
||||
const topNav = (
|
||||
<nav
|
||||
key='nav-section-top'
|
||||
className={styles.top}
|
||||
>
|
||||
{mappedItems.top.map((item) => navItem(item))}
|
||||
</nav>
|
||||
)
|
||||
|
||||
const bottomNav = (
|
||||
<nav
|
||||
key='nav-section-bottom'
|
||||
className={styles.bottom}
|
||||
>
|
||||
{mappedItems.bottom.map((item) => navItem(item))}
|
||||
</nav>
|
||||
)
|
||||
|
||||
const nav = (
|
||||
<nav className={cs(styles.leftNav, leftNavClasses || '')}>
|
||||
{topNav}
|
||||
{bottomNav}
|
||||
</nav>
|
||||
)
|
||||
|
||||
return nav
|
||||
}
|
||||
|
||||
export const NavButtonCell: React.FC<NavButtonProps> = ({ item: { title, icon, interaction, itemClasses, itemClassesActive = '', itemClassesInactive = '', location }, isActive, navButtonClasses, index }) => {
|
||||
const commonClasses = cs(styles.item, itemClasses, navButtonClasses, {
|
||||
[styles.active]: isActive,
|
||||
[styles.inactive]: !isActive,
|
||||
[itemClassesActive]: isActive,
|
||||
[itemClassesInactive]: !isActive,
|
||||
})
|
||||
|
||||
const faIcon = <FontAwesomeIcon className={styles.icon} icon={icon} size="1x" />
|
||||
|
||||
if (interaction.type === 'anchor') {
|
||||
const anchorProps = {
|
||||
href: interaction.href,
|
||||
className: cs(styles.itemAnchor, commonClasses),
|
||||
onClick: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
if (!interaction.onClick) {
|
||||
return
|
||||
}
|
||||
|
||||
interaction.onClick({ event, index })
|
||||
},
|
||||
title,
|
||||
}
|
||||
|
||||
if (interaction.targetBlank) {
|
||||
return (
|
||||
<a
|
||||
{...anchorProps}
|
||||
target='_blank'
|
||||
>
|
||||
{faIcon}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a {...anchorProps}>
|
||||
{faIcon}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className={commonClasses}
|
||||
title={title}
|
||||
onClick={(event) => interaction.onClick({ event, index })}
|
||||
>
|
||||
{faIcon}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './LeftNav'
|
||||
|
||||
export * from './types'
|
||||
@@ -1,49 +0,0 @@
|
||||
import { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
export type NavLocation = 'top' | 'bottom'
|
||||
|
||||
export interface NavClick {
|
||||
index: number
|
||||
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
id: string
|
||||
_index?: number
|
||||
|
||||
/**
|
||||
* Displayed on hover
|
||||
*/
|
||||
title: string
|
||||
|
||||
icon: IconProp
|
||||
|
||||
itemClasses?: string
|
||||
itemClassesActive?: string
|
||||
itemClassesInactive?: string
|
||||
location?: NavLocation
|
||||
|
||||
interaction: {
|
||||
type: 'anchor'
|
||||
href: string
|
||||
targetBlank?: boolean
|
||||
onClick?: (payload: NavClick) => void
|
||||
} | {
|
||||
type: 'js'
|
||||
onClick: (payload: NavClick) => void
|
||||
}
|
||||
}
|
||||
|
||||
export interface LeftNavProps {
|
||||
items: NavItem[]
|
||||
activeIndex?: number
|
||||
leftNavClasses?: string
|
||||
navButtonClasses?: string
|
||||
}
|
||||
|
||||
export interface NavButtonProps {
|
||||
index: number
|
||||
item: NavItem
|
||||
isActive: boolean
|
||||
navButtonClasses?: string
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react'
|
||||
import { CypressLogo } from './CypressLogo/CypressLogo'
|
||||
import { mount } from 'cypress/react'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fab } from '@fortawesome/free-brands-svg-icons'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(fas)
|
||||
library.add(fab)
|
||||
|
||||
describe('Playground', () => {
|
||||
it('cypress logo', () => {
|
||||
mount(
|
||||
<>
|
||||
<CypressLogo size="small" />
|
||||
<br />
|
||||
<CypressLogo size="medium" />
|
||||
<br />
|
||||
<CypressLogo size="large" />
|
||||
</>,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { mount } from 'cypress/react'
|
||||
import { composeStories } from '@storybook/testing-react'
|
||||
import * as stories from './CollapsibleGroup.stories'
|
||||
|
||||
const { CollapsibleGroup } = composeStories(stories)
|
||||
|
||||
describe('<CollapsibleGroup />', () => {
|
||||
it('playground', () => {
|
||||
mount(<CollapsibleGroup />)
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { createStory, createStorybookConfig } from '../../stories/util'
|
||||
|
||||
import { CollapsibleGroup as CollapsibleGroupComponent } from './CollapsibleGroup'
|
||||
import { PaddedBox } from '../../core/surface/paddedBox/PaddedBox'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Components/CollapsibleGroup',
|
||||
})
|
||||
|
||||
export const CollapsibleGroup = createStory(() => {
|
||||
return (
|
||||
<div>
|
||||
<CollapsibleGroupComponent title="Expand me">
|
||||
<PaddedBox>
|
||||
Collapsible padded box with content
|
||||
</PaddedBox>
|
||||
</CollapsibleGroupComponent>
|
||||
<CollapsibleGroupComponent title="Defaults to open" defaultExpanded={true}>
|
||||
<PaddedBox>
|
||||
Collapsible padded box with content
|
||||
</PaddedBox>
|
||||
</CollapsibleGroupComponent>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Icons = createStory(() => {
|
||||
return (
|
||||
<div>
|
||||
<CollapsibleGroupComponent title="Expand me" icons={{ expanded: 'chevron-down', collapsed: 'chevron-right' }}>
|
||||
<PaddedBox>
|
||||
Collapsible padded box with content
|
||||
</PaddedBox>
|
||||
</CollapsibleGroupComponent>
|
||||
<CollapsibleGroupComponent title="Defaults to open" defaultExpanded={true}>
|
||||
<PaddedBox>
|
||||
Collapsible padded box with content
|
||||
</PaddedBox>
|
||||
</CollapsibleGroupComponent>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import cs from 'classnames'
|
||||
import React, { useLayoutEffect, useState } from 'react'
|
||||
|
||||
import { CollapsibleGroupHeader, CollapsibleGroupHeaderProps } from './CollapsibleGroupHeader'
|
||||
import { TextSizableComponent } from 'core/shared'
|
||||
|
||||
import styles from './CollapsibleGroup.module.scss'
|
||||
|
||||
interface CollapsibleGroupProps extends Omit<CollapsibleGroupHeaderProps, 'expanded' | 'onClick'>, TextSizableComponent {
|
||||
style?: React.CSSProperties
|
||||
|
||||
title: string | JSX.Element
|
||||
|
||||
expanded?: boolean
|
||||
defaultExpanded?: boolean
|
||||
onToggle?: (isExpanded: boolean) => void
|
||||
|
||||
disable?: boolean
|
||||
}
|
||||
|
||||
export const CollapsibleGroup: React.FC<CollapsibleGroupProps> = ({
|
||||
className,
|
||||
style,
|
||||
expanded: externalExpanded,
|
||||
defaultExpanded = externalExpanded,
|
||||
onToggle,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(!!defaultExpanded)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (externalExpanded !== undefined) {
|
||||
setIsExpanded(externalExpanded)
|
||||
}
|
||||
}, [externalExpanded])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs([
|
||||
{ [styles.expanded]: isExpanded },
|
||||
styles.group,
|
||||
className,
|
||||
])}
|
||||
style={style}
|
||||
>
|
||||
<CollapsibleGroupHeader
|
||||
{...props}
|
||||
expanded={isExpanded}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => {
|
||||
onToggle?.(!isExpanded)
|
||||
|
||||
setIsExpanded((expanded) => !expanded)
|
||||
}}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{isExpanded && children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React, { CSSProperties } from 'react'
|
||||
import cs from 'classnames'
|
||||
import { IconName } from '@fortawesome/fontawesome-common-types'
|
||||
|
||||
import { TextSizableComponent } from 'core/shared'
|
||||
import { StyledText } from 'core/text/styledText'
|
||||
import { Icon, IconProps } from 'core/icon/Icon'
|
||||
|
||||
import styles from './CollapsibleGroup.module.scss'
|
||||
|
||||
export interface IconInfo {
|
||||
expanded: IconName
|
||||
collapsed: IconName
|
||||
iconProps?: Omit<IconProps, 'icon'>
|
||||
}
|
||||
|
||||
export interface CollapsibleGroupHeaderProps extends TextSizableComponent {
|
||||
style?: CSSProperties
|
||||
|
||||
title: string | JSX.Element
|
||||
tooltipTitle?: string
|
||||
|
||||
/**
|
||||
* The icons to render for expanded and collapsed states. If not specified, no icons will be rendered
|
||||
*/
|
||||
icons?: IconInfo
|
||||
|
||||
expanded: boolean
|
||||
disabled?: boolean
|
||||
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export const CollapsibleGroupHeader: React.FC<CollapsibleGroupHeaderProps> = ({
|
||||
className,
|
||||
style,
|
||||
title,
|
||||
tooltipTitle,
|
||||
expanded,
|
||||
disabled,
|
||||
size,
|
||||
lineHeight,
|
||||
icons,
|
||||
onClick,
|
||||
}) => (
|
||||
<StyledText
|
||||
className={cs([
|
||||
styles.header,
|
||||
{ [styles.expanded]: expanded, [styles.disabled]: disabled },
|
||||
className,
|
||||
])}
|
||||
style={style}
|
||||
title={tooltipTitle ? tooltipTitle : typeof title === 'string' ? title : undefined}
|
||||
size={size}
|
||||
lineHeight={lineHeight}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icons && <Icon {...icons.iconProps} icon={expanded ? icons.expanded : icons.collapsed} />}
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
</StyledText>
|
||||
)
|
||||
@@ -1,132 +0,0 @@
|
||||
import React from 'react'
|
||||
import { mount } from 'cypress/react'
|
||||
import { FileTree } from './FileTree'
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
|
||||
const files = [
|
||||
{
|
||||
path: 'foo/bar/foo.spec.js',
|
||||
},
|
||||
{
|
||||
path: 'qux/dog.spec.tsx',
|
||||
},
|
||||
{
|
||||
path: 'merp/cat.spec.ts',
|
||||
},
|
||||
]
|
||||
|
||||
const assertSelectedBorder = ($els: JQuery<HTMLElement>) => {
|
||||
const win = $els[0].ownerDocument.defaultView
|
||||
|
||||
if (win) {
|
||||
const after = win.getComputedStyle($els[0], 'after')
|
||||
|
||||
// Verify that we see at least some border, indicating it is highlighted
|
||||
const leftStyle = after.getPropertyValue('border-left-style')
|
||||
|
||||
expect(leftStyle).to.eq('solid')
|
||||
|
||||
const leftWidth = after.getPropertyValue('border-left-width')
|
||||
|
||||
expect(leftWidth).to.eq('2px')
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cy.viewport(500, 500)
|
||||
})
|
||||
|
||||
describe('FileTree', () => {
|
||||
it('should send onFilePress callback on space and enter', () => {
|
||||
const filePressStub = cy.stub()
|
||||
|
||||
mountAndSnapshot(
|
||||
<div style={{ height: 500, width: 500 }}>
|
||||
<FileTree files={files} rootDirectory="/" emptyPlaceholder="No specs found" onFilePress={filePressStub} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// Click on the "foo" directory
|
||||
cy.get('div').contains('foo').click()
|
||||
|
||||
// navigate to "qux"
|
||||
cy.focused().type('{downarrow}')
|
||||
cy.get('div').contains('dog.spec.tsx').should('exist')
|
||||
|
||||
// collapse "qux", hiding "dog.spec.tsx"
|
||||
cy.focused().type('{enter}')
|
||||
cy.get('div').contains('dog.spec.tsx').should('not.exist')
|
||||
|
||||
// uncollapse "qux", revealing "dog.spec.tsx"
|
||||
cy.focused().type('{enter}')
|
||||
cy.get('div').contains('dog.spec.tsx').should('exist')
|
||||
|
||||
// navigate to "dog.spec.tsx"
|
||||
cy.focused().type('{downarrow}')
|
||||
cy.focused().type(' ').then(() => {
|
||||
expect(filePressStub).to.have.been.callCount(1)
|
||||
})
|
||||
|
||||
cy.focused().type('{uparrow}')
|
||||
cy.focused().type('{uparrow}')
|
||||
|
||||
cy.focused().should('contain', 'foo/bar')
|
||||
})
|
||||
|
||||
describe('focus', () => {
|
||||
it('should automatically focus the first row when focused', () => {
|
||||
mount(
|
||||
<div style={{ height: 500, width: 500 }}>
|
||||
<FileTree files={files} rootDirectory="/" emptyPlaceholder="No specs found" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
cy.get('[data-cy=virtualized-tree]').focus()
|
||||
|
||||
cy.contains('.treeChild', '/').then(assertSelectedBorder)
|
||||
})
|
||||
|
||||
it('should preserve focus state', () => {
|
||||
mount(
|
||||
<div>
|
||||
<div style={{ height: 500, width: 500 }}>
|
||||
<FileTree files={files} rootDirectory="/" emptyPlaceholder="No specs found" />
|
||||
</div>
|
||||
<button>Test</button>
|
||||
</div>,
|
||||
)
|
||||
|
||||
cy.get('[data-cy=virtualized-tree]').focus().type('{downarrow}').type('{downarrow}')
|
||||
|
||||
cy.get('button').focus()
|
||||
|
||||
cy.get('[data-cy=virtualized-tree]').focus()
|
||||
|
||||
cy.contains('.treeChild', 'foo.spec.js').then(assertSelectedBorder)
|
||||
})
|
||||
|
||||
it('should scroll to item on keyboard input', () => {
|
||||
const files = []
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
files.push({ path: `File ${i}` })
|
||||
}
|
||||
|
||||
mount(
|
||||
<div style={{ height: 500, width: 500 }}>
|
||||
<FileTree files={files} rootDirectory="/" emptyPlaceholder="No specs found" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
cy.get('[data-cy=virtualized-tree]').focus().type('{downarrow}').type('{downarrow}')
|
||||
|
||||
cy.get('[data-cy=virtualized-tree] > div').scrollTo('bottom')
|
||||
|
||||
cy.contains('.treeChild', 'File 99').should('be.visible')
|
||||
|
||||
cy.get('[data-cy=virtualized-tree]').focus().type('{downarrow}').type('{downarrow}')
|
||||
|
||||
cy.contains('.treeChild', 'File 3').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
import React from 'react'
|
||||
import { Button } from '../../core/button/Button'
|
||||
|
||||
import { createStory, createStorybookConfig } from '../../stories/util'
|
||||
|
||||
import { FileTree as FileTreeComponent } from './FileTree'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Components/FileTree',
|
||||
})
|
||||
|
||||
const paths = [
|
||||
'/cypress/integration/spec1.tsx',
|
||||
'/cypress/integration/spec2.tsx',
|
||||
'/cypress/integration/feature1/spec3.tsx',
|
||||
'/cypress/integration/feature1/spec4.tsx',
|
||||
'/cypress/integration/feature1/sub/spec6.tsx',
|
||||
'/rootspec.tsx',
|
||||
'/cypress/whyisthishere.spec.js',
|
||||
'/src/core/foo.spec.js',
|
||||
].map((path) => ({ path }))
|
||||
|
||||
export const FileTree = createStory(() => {
|
||||
return (
|
||||
<div>
|
||||
<Button color="white" aria-label='Before focus'>Before focus</Button>
|
||||
<div style={{ width: 800, height: 400 }}>
|
||||
<FileTreeComponent<{path: string}>
|
||||
files={paths}
|
||||
rootDirectory="/"
|
||||
emptyPlaceholder="Placeholder"
|
||||
/>
|
||||
</div>
|
||||
<Button color="white" aria-label='After focus'>After focus</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,134 +0,0 @@
|
||||
import React, { CSSProperties, useMemo } from 'react'
|
||||
|
||||
import { VirtualizedTree } from 'components/virtualizedTree/VirtualizedTree'
|
||||
import { CollapsibleGroupHeader, IconInfo } from 'components/collapsibleGroup/CollapsibleGroupHeader'
|
||||
import { LeafProps, OnNodeKeyDown, OnNodePress, ParentProps } from 'components/virtualizedTree/types'
|
||||
import { Placeholder } from 'core/text/placeholder'
|
||||
import { buildTree } from './buildTree'
|
||||
import { FileBase, FilePressEvent, FileTreeProps, TreeFile, TreeFolder } from './types'
|
||||
import { FileTreeFile, NameWithHighlighting } from './FileTreeFile'
|
||||
import { FileTreeSelectedContext } from './state'
|
||||
|
||||
import styles from './FileTree.module.scss'
|
||||
|
||||
interface MutableFilePressEvent extends Omit<FilePressEvent, 'defaultPrevented'> {
|
||||
defaultPrevented: boolean
|
||||
}
|
||||
|
||||
const treeStyle: CSSProperties = { overflowX: 'hidden' }
|
||||
|
||||
export const FileTree = <T extends FileBase>({
|
||||
innerRef,
|
||||
files,
|
||||
rootDirectory,
|
||||
emptyPlaceholder,
|
||||
selectedId,
|
||||
leftOffset,
|
||||
onRenderFolder,
|
||||
onRenderFile,
|
||||
onFolderPress,
|
||||
onFilePress,
|
||||
onFolderKeyDown,
|
||||
onFileKeyDown,
|
||||
}: FileTreeProps<T>) => {
|
||||
const tree = useMemo(() => buildTree(files, rootDirectory), [files, rootDirectory])
|
||||
|
||||
const ParentComponent = useMemo(() => onRenderFolder ?? createDefaultFolderComponent(leftOffset), [leftOffset, onRenderFolder])
|
||||
const LeafComponent = useMemo(() => onRenderFile ?? createDefaultFileComponent(leftOffset), [leftOffset, onRenderFile])
|
||||
|
||||
const onNodePress = useMemo<OnNodePress<TreeFile<T>, TreeFolder<T>> | undefined>(() => (node, event) => {
|
||||
let customEvent: MutableFilePressEvent = {
|
||||
...event,
|
||||
defaultPrevented: false,
|
||||
preventDefault: () => {},
|
||||
}
|
||||
|
||||
customEvent.preventDefault = () => {
|
||||
customEvent!.defaultPrevented = true
|
||||
}
|
||||
|
||||
if (node.type === 'parent') {
|
||||
onFolderPress?.(node.data, customEvent)
|
||||
|
||||
if (!customEvent?.defaultPrevented) {
|
||||
node.setOpen(!node.isOpen)
|
||||
}
|
||||
} else {
|
||||
onFilePress?.(node.data, customEvent)
|
||||
}
|
||||
}, [onFolderPress, onFilePress])
|
||||
|
||||
const onNodeKeyDown = useMemo<OnNodeKeyDown<TreeFile<T>, TreeFolder<T>> | undefined>(() => onFolderKeyDown || onFileKeyDown ? (node, event) => {
|
||||
if (node.type === 'parent') {
|
||||
onFolderKeyDown?.(node.data, event)
|
||||
} else {
|
||||
onFileKeyDown?.(node.data, event)
|
||||
}
|
||||
} : undefined, [onFolderKeyDown, onFileKeyDown])
|
||||
|
||||
return (
|
||||
tree ? (
|
||||
<FileTreeSelectedContext.Provider value={selectedId}>
|
||||
<VirtualizedTree<TreeFile<T>, TreeFolder<T>>
|
||||
innerRef={innerRef}
|
||||
className={styles.tree}
|
||||
// No x scrollbar. Unfortunately, react-vtree sets overflow using `style`, so we also have to
|
||||
style={treeStyle}
|
||||
tree={tree}
|
||||
// TODO: This is hardcoded to spacing ml, but the API doesn't accept REM, only pixels
|
||||
defaultItemSize={20}
|
||||
showRoot={true}
|
||||
onNodePress={onNodePress}
|
||||
onNodeKeyDown={onNodeKeyDown}
|
||||
onRenderParent={ParentComponent}
|
||||
onRenderLeaf={LeafComponent}
|
||||
/>
|
||||
</FileTreeSelectedContext.Provider>
|
||||
) : (
|
||||
<div className={styles.placeholder}>
|
||||
<Placeholder>
|
||||
{emptyPlaceholder}
|
||||
</Placeholder>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const icons: IconInfo = { expanded: 'chevron-down', collapsed: 'chevron-right', iconProps: { sizeWithoutCenter: true } }
|
||||
|
||||
const createDefaultFolderComponent = <T extends FileBase>(leftOffset = 0) => ({ parent: { id, name, indexes }, depth, isOpen }: ParentProps<TreeFolder<T>>) => (
|
||||
<CollapsibleGroupHeader
|
||||
className={styles.node}
|
||||
style={depth > 0 || leftOffset !== 0 ? {
|
||||
paddingLeft: `${depth + leftOffset}rem`,
|
||||
backgroundSize: `${depth}rem 100%`,
|
||||
// spacing(s) = 0.5rem is the default spacing as per styles.node
|
||||
backgroundPositionX: leftOffset !== 0 ? `${0.5 + leftOffset}rem` : undefined,
|
||||
} : undefined}
|
||||
title={(
|
||||
<NameWithHighlighting
|
||||
name={name}
|
||||
path={id}
|
||||
indexes={indexes ?? []}
|
||||
/>
|
||||
)}
|
||||
tooltipTitle={id}
|
||||
expanded={isOpen}
|
||||
icons={icons}
|
||||
lineHeight="tight"
|
||||
/>
|
||||
)
|
||||
|
||||
const createDefaultFileComponent = <T extends FileBase>(leftOffset = 0) => (props: LeafProps<TreeFile<T>>) => (
|
||||
<FileTreeFile
|
||||
{...props}
|
||||
style={props.depth > 0 || leftOffset !== 0 ? {
|
||||
paddingLeft: `${props.depth + leftOffset}rem`,
|
||||
backgroundSize: `${props.depth}rem 100%`,
|
||||
// spacing(s) = 0.5rem is the default spacing as per styles.node
|
||||
backgroundPositionX: leftOffset !== 0 ? `${0.5 + leftOffset}rem` : undefined,
|
||||
} : undefined}
|
||||
item={props.leaf}
|
||||
indexes={props.leaf.file.indexes ?? []}
|
||||
/>
|
||||
)
|
||||
@@ -1,104 +0,0 @@
|
||||
import React, { CSSProperties } from 'react'
|
||||
import cs from 'classnames'
|
||||
|
||||
import type { IconifyIcon } from '@iconify/types'
|
||||
import { InlineIcon } from '@iconify/react'
|
||||
import javascriptIcon from '@iconify/icons-vscode-icons/file-type-js-official'
|
||||
import typescriptIcon from '@iconify/icons-vscode-icons/file-type-typescript-official'
|
||||
import reactJs from '@iconify/icons-vscode-icons/file-type-reactjs'
|
||||
import reactTs from '@iconify/icons-vscode-icons/file-type-reactts'
|
||||
|
||||
import { StyledText } from 'core/text/styledText'
|
||||
import { FileBase, TreeFile } from './types'
|
||||
|
||||
import styles from './FileTree.module.scss'
|
||||
import { useSelectedId } from './state'
|
||||
|
||||
export interface NodeComponentProps<T> {
|
||||
item: T
|
||||
depth: number
|
||||
indexes: number[]
|
||||
}
|
||||
|
||||
export interface FileComponentProps<T extends FileBase> extends NodeComponentProps<TreeFile<T>> {
|
||||
style?: CSSProperties
|
||||
depth: number
|
||||
remeasure: () => void
|
||||
}
|
||||
|
||||
export const icons: Record<string, { icon: IconifyIcon }> = {
|
||||
js: { icon: javascriptIcon },
|
||||
ts: { icon: typescriptIcon },
|
||||
tsx: { icon: reactTs },
|
||||
jsx: { icon: reactJs },
|
||||
} as const
|
||||
|
||||
export const FileTreeFile = <T extends FileBase>({ item, indexes, style }: FileComponentProps<T>) => {
|
||||
const selectedId = useSelectedId()
|
||||
const isSelected = item.id === selectedId
|
||||
|
||||
const ext = getExt(item.name)
|
||||
const inlineIconProps = ext ? icons[ext] : {
|
||||
// If we don't have an icon for the extension, don't render an icon
|
||||
icon: '',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.node, styles.file, { [styles.active]: isSelected })}
|
||||
style={style}
|
||||
title={item.file.path}
|
||||
data-cy={isSelected ? 'selected-spec' : ''}
|
||||
>
|
||||
<InlineIcon {...inlineIconProps} />
|
||||
<NameWithHighlighting
|
||||
name={item.name}
|
||||
path={item.file.path}
|
||||
indexes={indexes}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NameWithHighlighting: React.FC<{
|
||||
name: string
|
||||
path: string
|
||||
indexes: number[]
|
||||
}> = ({ name, path, indexes }) => {
|
||||
const lengthOffset = path.length - name.length
|
||||
|
||||
const indexSet = indexes.reduce((acc, current) => {
|
||||
const newIndex = current - lengthOffset
|
||||
|
||||
if (newIndex >= 0) {
|
||||
acc.add(newIndex)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, new Set<number>())
|
||||
|
||||
// TODO: It would be nice if we didn't make `n` React nodes, and instead properly inserted only the spans when necessary
|
||||
return (
|
||||
<StyledText className={styles.highlight} size="ms" lineHeight="tight">
|
||||
{[...name].map((char, index) => indexSet.has(index) ? (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<span key={index}>
|
||||
{char}
|
||||
</span>
|
||||
) : (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<React.Fragment key={index}>
|
||||
{char}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</StyledText>
|
||||
)
|
||||
}
|
||||
|
||||
const extensionRegex = /(?:\.([^.]+))?$/
|
||||
|
||||
const getExt = (path: string) => {
|
||||
const extensionMatches = path.match(extensionRegex)
|
||||
|
||||
return extensionMatches ? extensionMatches[1] : ''
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { expect } from 'chai'
|
||||
import { buildTree } from './buildTree'
|
||||
import { FileBase, TreeFile, TreeFolder } from './types'
|
||||
|
||||
const getLeaves = (tree: TreeFolder<FileBase>): Array<TreeFile<FileBase>> => {
|
||||
const folders: Array<TreeFolder<FileBase>> = []
|
||||
const files: Array<TreeFile<FileBase>> = []
|
||||
|
||||
for (const item of tree.children) {
|
||||
if ('children' in item) {
|
||||
folders.push(item)
|
||||
} else {
|
||||
files.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return [...files, ...folders.flatMap(getLeaves)]
|
||||
}
|
||||
|
||||
describe('tree contents', () => {
|
||||
it('should contain all files', () => {
|
||||
const files = [
|
||||
'forOfStatement.js',
|
||||
'foo/y/bar.js',
|
||||
'foo/bar',
|
||||
'a/b/c',
|
||||
].map((path) => ({ path }))
|
||||
|
||||
const tree = buildTree(files, '/')
|
||||
|
||||
expect(tree).to.not.be.undefined
|
||||
|
||||
const leaves = getLeaves(tree!)
|
||||
|
||||
files.forEach(({ path }) => {
|
||||
const pathSegments = path.split('/')
|
||||
const fileName = pathSegments[pathSegments.length - 1]
|
||||
|
||||
expect(leaves).to.deep.include({ id: path, name: fileName, file: { path } })
|
||||
})
|
||||
})
|
||||
|
||||
it('should contain folders with files', () => {
|
||||
const files = [
|
||||
'folder1/file1.js',
|
||||
'anotherFolder/file2.ts',
|
||||
'folder3/file3.tsx',
|
||||
'oneMore/file4.js',
|
||||
].map((path) => ({ path }))
|
||||
|
||||
const tree = buildTree(files, '/')!
|
||||
|
||||
expect(tree).to.not.be.undefined
|
||||
|
||||
expect(tree.children.length).to.eq(4)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const { path } = files[i]
|
||||
const child = tree.children[i] as TreeFolder<FileBase>
|
||||
|
||||
const [folder, file] = path.split('/')
|
||||
|
||||
expect(child).to.have.property('id', folder)
|
||||
expect(child.children.length).to.eq(1)
|
||||
expect(child.children[0].id).to.eq(path)
|
||||
expect(child.children[0].name).to.eq(file)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('tree collapsing', () => {
|
||||
it('should collapse directories into a single node', () => {
|
||||
const files = [
|
||||
{ path: 'folder1/folder2/folder3/moreFolder/file1.js' },
|
||||
]
|
||||
|
||||
const tree = buildTree(files, '/')!
|
||||
|
||||
expect(tree).to.not.be.undefined
|
||||
|
||||
expect(tree.children.length).to.eq(1)
|
||||
|
||||
expect(tree.id).to.eq('folder1/folder2/folder3/moreFolder')
|
||||
expect(tree.name).to.eq('folder1/folder2/folder3/moreFolder')
|
||||
expect(tree.children[0].id).to.eq(files[0].path)
|
||||
})
|
||||
|
||||
it('should collapse directories with multiple children into a single shared node', () => {
|
||||
const files = [
|
||||
{ path: 'folder1/folder2/folder3/moreFolder/file1.js' },
|
||||
{ path: 'folder1/folder2/diff/filesChanged/file2.js' },
|
||||
]
|
||||
|
||||
const tree = buildTree(files, '/')!
|
||||
|
||||
expect(tree).to.not.be.undefined
|
||||
|
||||
expect(tree.children.length).to.eq(2)
|
||||
|
||||
const [file1Folder, file2Folder] = tree.children as [TreeFolder<FileBase>, TreeFolder<FileBase>]
|
||||
|
||||
expect(tree.id).to.eq('folder1/folder2')
|
||||
expect(tree.name).to.eq('folder1/folder2')
|
||||
|
||||
expect(file1Folder.id).to.eq('folder1/folder2/folder3/moreFolder')
|
||||
expect(file1Folder.name).to.eq('folder3/moreFolder')
|
||||
|
||||
expect(file2Folder.id).to.eq('folder1/folder2/diff/filesChanged')
|
||||
expect(file2Folder.name).to.eq('diff/filesChanged')
|
||||
|
||||
expect(file1Folder.children.length).to.eq(1)
|
||||
expect(file1Folder.children[0].id).to.eq('folder1/folder2/folder3/moreFolder/file1.js')
|
||||
expect(file1Folder.children[0].name).to.eq('file1.js')
|
||||
|
||||
expect(file2Folder.children.length).to.eq(1)
|
||||
expect(file2Folder.children[0].id).to.eq('folder1/folder2/diff/filesChanged/file2.js')
|
||||
expect(file2Folder.children[0].name).to.eq('file2.js')
|
||||
})
|
||||
})
|
||||
@@ -1,129 +0,0 @@
|
||||
import { FileBase, TreeFolder } from './types'
|
||||
|
||||
interface BuildingFile<T extends FileBase> {
|
||||
name: string
|
||||
path: string
|
||||
file: T
|
||||
}
|
||||
|
||||
interface BuildingFolder<T extends FileBase> {
|
||||
name: string
|
||||
path: string
|
||||
/**
|
||||
* If undefined, this folder is root
|
||||
*/
|
||||
parent: BuildingFolder<T> | undefined
|
||||
files: Array<BuildingFile<T>>
|
||||
folders: Record<string, BuildingFolder<T>>
|
||||
|
||||
indexes?: number[]
|
||||
}
|
||||
|
||||
const treeToFolders = <T extends FileBase>({ path, name, files, folders, indexes }: BuildingFolder<T>): TreeFolder<T> => {
|
||||
return {
|
||||
id: path,
|
||||
name,
|
||||
children: [...Object.values(folders).map(treeToFolders), ...files.map(({ path, name, file }) => {
|
||||
return {
|
||||
id: path,
|
||||
name,
|
||||
file,
|
||||
}
|
||||
})],
|
||||
indexes,
|
||||
}
|
||||
}
|
||||
|
||||
const compressTree = <T extends FileBase>(folder: BuildingFolder<T>) => {
|
||||
while (Object.keys(folder.folders).length === 1 && folder.files.length < 1) {
|
||||
// Not root, has only one folder child and no file children
|
||||
const child = folder.folders[Object.keys(folder.folders)[0]]
|
||||
|
||||
folder.folders = child.folders
|
||||
folder.files = child.files
|
||||
folder.path = child.path
|
||||
folder.name = `${folder.name}/${child.name}`
|
||||
folder.indexes = child.indexes
|
||||
child.parent = folder.parent
|
||||
}
|
||||
|
||||
for (const childKey of Object.keys(folder.folders)) {
|
||||
compressTree(folder.folders[childKey])
|
||||
}
|
||||
}
|
||||
|
||||
export const buildTree = <T extends FileBase>(files: T[], rootDirectory: string) => {
|
||||
if (files.length < 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const rootPathParts = rootDirectory.split('/')
|
||||
|
||||
const lastRootPart = rootPathParts[rootPathParts.length - 1]
|
||||
|
||||
const rootName = lastRootPart
|
||||
? lastRootPart
|
||||
: rootPathParts.length > 1
|
||||
? rootPathParts[rootPathParts.length - 2]
|
||||
// If no root path, use empty string as root
|
||||
: ''
|
||||
|
||||
const rootFolder: BuildingFolder<T> = {
|
||||
path: rootDirectory,
|
||||
name: rootName,
|
||||
files: [],
|
||||
folders: {},
|
||||
parent: undefined,
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
let parentDirectory = rootFolder
|
||||
|
||||
// All paths should be POSIX compliant
|
||||
const pathParts = file.path.split('/')
|
||||
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const part = pathParts[i]
|
||||
|
||||
if (i === pathParts.length - 1) {
|
||||
// Last item, filename
|
||||
parentDirectory.files.push({
|
||||
path: file.path,
|
||||
name: part,
|
||||
file,
|
||||
})
|
||||
} else {
|
||||
if (part in parentDirectory.folders) {
|
||||
// Directory already exists, switch to new parent
|
||||
parentDirectory = parentDirectory.folders[part]
|
||||
} else {
|
||||
// Directory hasn't been seen before
|
||||
const newDirectory: BuildingFolder<T> = {
|
||||
path: pathParts.slice(0, i + 1).join('/'),
|
||||
name: part,
|
||||
files: [],
|
||||
folders: {},
|
||||
parent: parentDirectory,
|
||||
// First file. If we have highlight indexes, add to directory
|
||||
// TODO: This is incorrect. This only has one entry for the directory, when individual files in that directory could have different indexes
|
||||
indexes: file.indexes,
|
||||
}
|
||||
|
||||
parentDirectory.folders[part] = newDirectory
|
||||
parentDirectory = newDirectory
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compressTree(rootFolder)
|
||||
|
||||
if (rootFolder.name !== '/' && rootFolder.name[0] === '/') {
|
||||
// As long as root folder isn't the filesystem root, trim the beginning slash
|
||||
rootFolder.name = rootFolder.name.slice(1)
|
||||
} else if (rootFolder.name === '') {
|
||||
rootFolder.name = '/'
|
||||
}
|
||||
|
||||
return treeToFolders(rootFolder)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './FileTree'
|
||||
|
||||
export * from './FileTreeFile'
|
||||
|
||||
export * from './types'
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export const FileTreeSelectedContext = createContext<string | undefined>(undefined)
|
||||
|
||||
export const useSelectedId = () => useContext(FileTreeSelectedContext)
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { PressEvent } from '@react-types/shared'
|
||||
import type { MutableRefObject, ReactNode } from 'react'
|
||||
|
||||
import { LeafProps, ParentProps, SpecificTreeNode, VirtualizedTreeRef } from 'components/virtualizedTree/types'
|
||||
|
||||
export interface FileTreeProps<T extends FileBase> {
|
||||
/**
|
||||
* Use instead of `ref`. React/TS still doesn't have a good solution for `forwardRef` generics
|
||||
*/
|
||||
innerRef?: MutableRefObject<VirtualizedTreeRef>
|
||||
|
||||
files: T[]
|
||||
rootDirectory: string
|
||||
|
||||
/**
|
||||
* If specified, the node with this ID will be highlighted
|
||||
*/
|
||||
selectedId?: string
|
||||
|
||||
emptyPlaceholder: ReactNode
|
||||
|
||||
/**
|
||||
* If specified, offset tree nodes by this amount in REM
|
||||
*/
|
||||
leftOffset?: number
|
||||
|
||||
onRenderFolder?: (folder: ParentProps<TreeFolder<T>>) => JSX.Element
|
||||
onRenderFile?: (folder: LeafProps<TreeFile<T>>) => JSX.Element
|
||||
|
||||
onFolderPress?: (folder: SpecificTreeNode<TreeFolder<T>>, event: FilePressEvent) => void
|
||||
onFilePress?: (file: SpecificTreeNode<TreeFile<T>>, event: FilePressEvent) => void
|
||||
|
||||
onFolderKeyDown?: (folder: SpecificTreeNode<TreeFolder<T>>, event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onFileKeyDown?: (file: SpecificTreeNode<TreeFile<T>>, event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
export interface FilePressEvent extends PressEvent {
|
||||
readonly defaultPrevented: boolean
|
||||
preventDefault: () => void
|
||||
}
|
||||
|
||||
export interface FileBase {
|
||||
path: string
|
||||
indexes?: number[]
|
||||
}
|
||||
|
||||
export interface TreeFile<T extends FileBase> {
|
||||
id: string
|
||||
name: string
|
||||
file: T
|
||||
}
|
||||
|
||||
export interface TreeFolder<T extends FileBase> {
|
||||
id: string
|
||||
name: string
|
||||
indexes?: number[]
|
||||
children: Array<TreeFolder<T> | TreeFile<T>>
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { mount } from 'cypress/react'
|
||||
|
||||
import { SearchInput } from './SearchInput'
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
|
||||
const { useCallback, useState } = React
|
||||
|
||||
describe('SearchInput', () => {
|
||||
const StatefulWrapper: React.FC<{onInput?: (input: string) => void}> = ({ onInput }) => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const memoedOnInput = useCallback((input: string) => {
|
||||
setValue(input)
|
||||
onInput?.(input)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return <SearchInput placeholder="foo" value={value} aria-label="Search" onInput={memoedOnInput} />
|
||||
}
|
||||
|
||||
it('should render', () => {
|
||||
function onInput () {}
|
||||
mountAndSnapshot(<SearchInput placeholder="foo" value="" aria-label="Search" onInput={onInput} />)
|
||||
cy.get('input').should('exist')
|
||||
})
|
||||
|
||||
it('should pass input to onInput', () => {
|
||||
const onInput = cy.stub()
|
||||
|
||||
mount(<StatefulWrapper onInput={onInput} />)
|
||||
|
||||
const string = 'Testing input!'
|
||||
|
||||
cy.get('input').type(string).then(() => {
|
||||
expect(onInput).to.be.callCount(string.length)
|
||||
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
expect(onInput.getCall(i)).to.be.calledWithExactly(string.slice(0, i + 1))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Clear button', () => {
|
||||
it('should only show when text is present', () => {
|
||||
mount(<StatefulWrapper />)
|
||||
|
||||
cy.get('[aria-label="Clear search"]').should('not.exist')
|
||||
|
||||
cy.get('input').type('some input')
|
||||
|
||||
cy.get('[aria-label="Clear search"]').should('exist')
|
||||
|
||||
cy.percySnapshot()
|
||||
})
|
||||
|
||||
it('should clear input on click', () => {
|
||||
const onInput = cy.stub()
|
||||
|
||||
mount(<SearchInput placeholder="foo" value="a value" aria-label="Search" onInput={onInput} />)
|
||||
|
||||
cy.get('input').should('have.value', 'a value')
|
||||
|
||||
cy.get('[aria-label="Clear search"]').click().then(() => expect(onInput).to.be.calledOnceWith(''))
|
||||
})
|
||||
|
||||
it('should focus input on click', () => {
|
||||
function onInput () {}
|
||||
mount(<SearchInput placeholder="foo" value="a value" aria-label="Search" onInput={onInput} />)
|
||||
|
||||
cy.get('[aria-label="Clear search"]').click()
|
||||
|
||||
cy.get('input').should('be.focused')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { createStory, createStorybookConfig } from 'stories/util'
|
||||
|
||||
import { SearchInput as SearchInputComponent } from './SearchInput'
|
||||
const { useState } = React
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Components/SearchInput',
|
||||
})
|
||||
|
||||
export const SearchInput = createStory(() => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchInputComponent value={value} placeholder='Search specs' aria-label="Search" onInput={setValue} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
import React, { KeyboardEvent, useMemo, FormEvent, MutableRefObject, useCallback } from 'react'
|
||||
import { IconInput, IconSettings } from 'core/input/IconInput'
|
||||
import { CoreComponent } from 'core/shared'
|
||||
import { TextSize } from 'css'
|
||||
import { useCombinedRefs } from '../../hooks/useCombinedRefs'
|
||||
|
||||
export interface SearchInputProps extends CoreComponent {
|
||||
inputRef?: MutableRefObject<HTMLInputElement> | null
|
||||
|
||||
value?: string
|
||||
placeholder: string
|
||||
|
||||
/**
|
||||
* Defaults to 'm'
|
||||
*/
|
||||
size?: TextSize
|
||||
|
||||
onInput: (input: string) => void
|
||||
onEnter?: (input: string) => void
|
||||
onVerticalArrowKey?: (key: 'up' | 'down') => void
|
||||
|
||||
['aria-label']: string
|
||||
}
|
||||
|
||||
const prefixItem: IconSettings = {
|
||||
icon: 'search',
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC<SearchInputProps> = ({ inputRef = null, onInput: externalOnInput, onEnter, onVerticalArrowKey, ...props }) => {
|
||||
const ref = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
useCombinedRefs(ref, inputRef)
|
||||
|
||||
const onInput = useCallback((e: FormEvent<HTMLInputElement>) => externalOnInput(e.currentTarget.value), [externalOnInput])
|
||||
const onClear = useCallback(() => {
|
||||
if (ref.current) {
|
||||
ref.current.value = ''
|
||||
}
|
||||
|
||||
externalOnInput('')
|
||||
|
||||
ref.current?.focus()
|
||||
}, [externalOnInput])
|
||||
|
||||
const onKeyDown = useMemo(() => onEnter || onVerticalArrowKey ? (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
onEnter?.(e.currentTarget.value)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
onVerticalArrowKey?.('up')
|
||||
break
|
||||
case 'ArrowDown':
|
||||
onVerticalArrowKey?.('down')
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// If we get here, we matched a key
|
||||
e.preventDefault()
|
||||
} : undefined, [onEnter, onVerticalArrowKey])
|
||||
|
||||
const value = props.value ?? ref.current?.value
|
||||
|
||||
return (
|
||||
<IconInput
|
||||
{...props}
|
||||
inputRef={ref}
|
||||
label={{ type: 'aria', contents: props['aria-label'] }}
|
||||
prefixIcon={prefixItem}
|
||||
suffixIcon={value ? { icon: 'times', onPress: onClear, 'aria-label': 'Clear search' } : undefined}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { composeStories } from '@storybook/testing-react'
|
||||
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
|
||||
import * as stories from './VirtualizedTree.stories'
|
||||
const { VirtualizedTree } = composeStories(stories)
|
||||
|
||||
// TODO: Autogenerate from stories
|
||||
describe('<VirtualizedTree />', () => {
|
||||
it('VirtualizedTree', () => {
|
||||
mountAndSnapshot(<VirtualizedTree />)
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
import React from 'react'
|
||||
import { Button } from '../../core/button/Button'
|
||||
|
||||
import { createStory, createStorybookConfig } from '../../stories/util'
|
||||
|
||||
import { VirtualizedTree as VirtualizedTreeComponent } from './VirtualizedTree'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Components/VirtualizedTree',
|
||||
})
|
||||
|
||||
type TreeLeaf = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type TreeParent = {
|
||||
id: string
|
||||
name: string
|
||||
children: Array<TreeLeaf | TreeParent>
|
||||
}
|
||||
|
||||
const tree: TreeParent = {
|
||||
id: 'root',
|
||||
name: 'Root',
|
||||
children: [
|
||||
{
|
||||
id: 'child1',
|
||||
name: 'Child 1',
|
||||
children: [
|
||||
{
|
||||
id: 'child11',
|
||||
name: 'Child 11',
|
||||
},
|
||||
{
|
||||
id: 'child12',
|
||||
name: 'Child 12',
|
||||
children: [
|
||||
{
|
||||
id: 'child121',
|
||||
name: 'Child 121',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'child2',
|
||||
name: 'Child 2',
|
||||
},
|
||||
{
|
||||
id: 'child3',
|
||||
name: 'Child 3',
|
||||
},
|
||||
{
|
||||
id: 'child4',
|
||||
name: 'Child 4',
|
||||
children: [
|
||||
{
|
||||
id: 'child41',
|
||||
name: 'Child 41',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const VirtualizedTree = createStory(() => {
|
||||
return (
|
||||
<div>
|
||||
<Button color="white" aria-label='Before focus'>Before focus</Button>
|
||||
<div style={{ width: 800, height: 400 }}>
|
||||
<VirtualizedTreeComponent<TreeLeaf, TreeParent>
|
||||
tree={tree}
|
||||
defaultItemSize={20}
|
||||
onNodePress={(node) => {
|
||||
if (node.type === 'parent') {
|
||||
node.setOpen(!node.isOpen)
|
||||
}
|
||||
}}
|
||||
onRenderParent={({ parent, depth, isOpen, setOpen }) => (
|
||||
<div style={{ marginLeft: 20 * depth, backgroundColor: 'red', cursor: 'pointer' }}>
|
||||
{parent.name}
|
||||
</div>
|
||||
)}
|
||||
onRenderLeaf={({ leaf, depth }) => (
|
||||
<div style={{ marginLeft: 20 * depth }}>
|
||||
{leaf.name}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button color="white" aria-label='After focus'>After focus</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,326 +0,0 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import AutoSizer from 'react-virtualized-auto-sizer'
|
||||
import { VariableSizeNodePublicState, VariableSizeTree } from 'react-vtree'
|
||||
import type { NodeComponentProps } from 'react-vtree/dist/lib/Tree'
|
||||
|
||||
import { useCombinedRefs } from 'hooks/useCombinedRefs'
|
||||
import { FocusStateContext, FocusStateHasFocusContext, useFocusDispatch } from './focusState'
|
||||
import {
|
||||
createPressEventNode,
|
||||
isParent,
|
||||
LeafTreeBase,
|
||||
OnNodePress,
|
||||
ParentTreeBase,
|
||||
TreeNode,
|
||||
TreeNodeData,
|
||||
VirtualizedTreeProps,
|
||||
} from './types'
|
||||
import { TreeChild } from './VirtualizedTreeChild'
|
||||
|
||||
import styles from './VirtualizedTree.module.scss'
|
||||
|
||||
const VirtualizedTreeContents = <
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
>({
|
||||
innerRef,
|
||||
treeRef,
|
||||
tree,
|
||||
defaultItemSize,
|
||||
overscanCount = 20,
|
||||
indentSize,
|
||||
showRoot,
|
||||
shouldMeasure,
|
||||
onNodePress: externalOnNodePress,
|
||||
onNodeKeyDown,
|
||||
onRenderLeaf,
|
||||
onRenderParent,
|
||||
...props
|
||||
}: VirtualizedTreeProps<TLeaf, TParent>) => {
|
||||
type TNodeData = TreeNodeData<TLeaf, TParent>
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
const internalRef = useRef<VariableSizeTree<TNodeData> | null>(null)
|
||||
|
||||
useCombinedRefs(internalRef, treeRef ?? null)
|
||||
|
||||
const [focusIdRef, dispatch] = useFocusDispatch()
|
||||
const [hasFocus, setHasFocus] = useState(false)
|
||||
|
||||
useImperativeHandle(innerRef, () => ({
|
||||
focus: () => wrapperRef.current?.focus(),
|
||||
}))
|
||||
|
||||
const treeWalker = useMemo(() => {
|
||||
const buildNodeData = (node: TLeaf | TParent, nestingLevel: number, isFirst: boolean): TreeNode<TLeaf, TParent> => ({
|
||||
data: {
|
||||
id: node.id,
|
||||
node,
|
||||
nestingLevel,
|
||||
isOpenByDefault: true,
|
||||
defaultHeight: defaultItemSize,
|
||||
isFirst,
|
||||
},
|
||||
})
|
||||
|
||||
function* walker (): Generator<TreeNode<TLeaf, TParent> | undefined, undefined, TreeNode<TLeaf, TParent>> {
|
||||
if (showRoot) {
|
||||
yield buildNodeData(tree, 0, true)
|
||||
} else {
|
||||
// Push all children of root as many psuedo roots
|
||||
for (let i = 0; i < tree.children.length; i++) {
|
||||
yield buildNodeData(tree.children[i] as TLeaf | TParent, 0, i === 0)
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const parent = yield
|
||||
|
||||
if (!isParent(parent.data.node)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const child of parent.data.node.children) {
|
||||
yield buildNodeData(child as TLeaf | TParent, parent.data.nestingLevel + 1, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return walker
|
||||
}, [tree, showRoot, defaultItemSize])
|
||||
|
||||
const currentSelectionIndex = useCallback(() => {
|
||||
const order = internalRef.current?.state.order
|
||||
|
||||
if (order === undefined) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (order !== undefined) {
|
||||
return focusIdRef.current ? order.indexOf(focusIdRef.current) ?? -1 : -1
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onNodePress = useCallback<OnNodePress<TLeaf, TParent>>((node, event) => {
|
||||
dispatch(node.data.id)
|
||||
|
||||
externalOnNodePress?.(node, event)
|
||||
wrapperRef.current?.focus()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [externalOnNodePress])
|
||||
|
||||
const onKeyDown = useMemo(() => {
|
||||
const currentNode = () => {
|
||||
const order = internalRef.current?.state.order
|
||||
|
||||
const currentIndex = currentSelectionIndex()
|
||||
|
||||
if (order === undefined || currentIndex === undefined || currentIndex === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return internalRef.current?.state.records.get(order[currentIndex])?.public
|
||||
}
|
||||
|
||||
return (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const order = internalRef.current?.state.order
|
||||
|
||||
if (!order) {
|
||||
return
|
||||
}
|
||||
|
||||
const node = currentNode()
|
||||
|
||||
if (node) {
|
||||
const { data, isOpen, setOpen } = node
|
||||
|
||||
onNodeKeyDown?.(createPressEventNode(data as TreeNodeData<TLeaf, TParent>, isOpen, setOpen), event)
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let id = node?.data.id
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
const currentIndex = currentSelectionIndex()
|
||||
|
||||
if (currentIndex === undefined) {
|
||||
break
|
||||
}
|
||||
|
||||
const newSelectionIndex = currentIndex + 1
|
||||
|
||||
if (newSelectionIndex >= order.length) {
|
||||
break
|
||||
}
|
||||
|
||||
id = order[newSelectionIndex]
|
||||
|
||||
dispatch(id)
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
const currentIndex = currentSelectionIndex()
|
||||
|
||||
if (currentIndex === undefined) {
|
||||
break
|
||||
}
|
||||
|
||||
let newSelectionIndex = currentIndex - 1
|
||||
|
||||
if (newSelectionIndex < 0) {
|
||||
newSelectionIndex = 0
|
||||
}
|
||||
|
||||
id = order[newSelectionIndex]
|
||||
|
||||
dispatch(id)
|
||||
break
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (!node) {
|
||||
break
|
||||
}
|
||||
|
||||
const { data, isOpen, setOpen } = node
|
||||
|
||||
if (isParent(data.node) && !isOpen) {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
if (!node) {
|
||||
break
|
||||
}
|
||||
|
||||
const { data, isOpen, setOpen } = node
|
||||
|
||||
if (isParent(data.node) && isOpen) {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'Enter':
|
||||
case 'Spacebar':
|
||||
case ' ': {
|
||||
if (!node) {
|
||||
break
|
||||
}
|
||||
|
||||
const { data, isOpen, setOpen } = node
|
||||
|
||||
onNodePress?.(createPressEventNode(data as TreeNodeData<TLeaf, TParent>, isOpen, setOpen), {
|
||||
type: 'press',
|
||||
pointerType: 'keyboard',
|
||||
target: event.currentTarget,
|
||||
shiftKey: event.shiftKey,
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
default: {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (id) {
|
||||
// Make sure the node that was pressed is in view
|
||||
internalRef.current?.scrollToItem(id)
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onNodePress, onNodeKeyDown])
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
setHasFocus(true)
|
||||
|
||||
const state = internalRef.current?.state
|
||||
|
||||
const currentIndex = currentSelectionIndex()
|
||||
|
||||
if (currentIndex === -1 && state && (state.order?.length ?? 0) > 0) {
|
||||
// No selected row
|
||||
dispatch(state.order![0])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
const onBlur = useCallback(() => setHasFocus(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
// Clear selected node
|
||||
dispatch(undefined)
|
||||
|
||||
internalRef.current?.recomputeTree({
|
||||
refreshNodes: true,
|
||||
useDefaultHeight: true,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tree])
|
||||
|
||||
const treeRow = useCallback((props: NodeComponentProps<TNodeData, VariableSizeNodePublicState<TNodeData>>) => {
|
||||
return (
|
||||
<TreeChild
|
||||
{...props}
|
||||
indentSize={indentSize}
|
||||
showRoot={showRoot}
|
||||
shouldMeasure={shouldMeasure}
|
||||
onNodeKeyDown={onNodeKeyDown}
|
||||
onNodePress={onNodePress}
|
||||
onRenderLeaf={onRenderLeaf}
|
||||
onRenderParent={onRenderParent}
|
||||
/>
|
||||
)
|
||||
}, [showRoot, indentSize, shouldMeasure, onNodePress, onNodeKeyDown, onRenderLeaf, onRenderParent])
|
||||
|
||||
const sizer = useCallback(({ width, height }) => (
|
||||
<div ref={wrapperRef} className={styles.focusWrapper} tabIndex={0} data-cy="virtualized-tree" onKeyDown={onKeyDown} onFocus={onFocus} onBlur={onBlur}>
|
||||
<VariableSizeTree<TNodeData>
|
||||
{...props}
|
||||
ref={internalRef}
|
||||
treeWalker={treeWalker}
|
||||
width={width}
|
||||
height={height}
|
||||
overscanCount={overscanCount}
|
||||
>
|
||||
{treeRow}
|
||||
</VariableSizeTree>
|
||||
</div>
|
||||
), [overscanCount, props, treeRow, treeWalker, onKeyDown, onFocus, onBlur])
|
||||
|
||||
// TODO: Figure out the proper accessibility wrappers
|
||||
return (
|
||||
<FocusStateHasFocusContext.Provider value={hasFocus}>
|
||||
<AutoSizer>
|
||||
{sizer}
|
||||
</AutoSizer>
|
||||
</FocusStateHasFocusContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const VirtualizedTree = <
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
>(props: VirtualizedTreeProps<TLeaf, TParent>) => (
|
||||
<FocusStateContext>
|
||||
<VirtualizedTreeContents {...props} />
|
||||
</FocusStateContext>
|
||||
)
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { memo, useCallback, useMemo } from 'react'
|
||||
import { usePress } from '@react-aria/interactions'
|
||||
import { PressEvent } from '@react-types/shared'
|
||||
import cs from 'classnames'
|
||||
|
||||
import { useMeasure } from 'hooks/useMeasure'
|
||||
import { createPressEventNode, InternalChildProps, InternalOnRenderChildProps, isParent, LeafTreeBase, ParentTreeBase, treeChildClass } from './types'
|
||||
import { useFocusState } from './focusState'
|
||||
|
||||
import styles from './VirtualizedTree.module.scss'
|
||||
|
||||
export const TreeChild = <
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
>({
|
||||
data,
|
||||
isOpen,
|
||||
style,
|
||||
height,
|
||||
indentSize,
|
||||
showRoot,
|
||||
shouldMeasure,
|
||||
onNodePress,
|
||||
setOpen,
|
||||
resize,
|
||||
onRenderLeaf,
|
||||
onRenderParent,
|
||||
}: InternalChildProps<TLeaf, TParent>) => {
|
||||
const globalFocusId = useFocusState()
|
||||
const id = data.node.id
|
||||
|
||||
const isFocused = globalFocusId === id
|
||||
|
||||
const resizer = useCallback((height: number) => resize(height, true), [resize])
|
||||
const { setRef, remeasure } = useMeasure(height, resizer, [data, style, isOpen], !shouldMeasure)
|
||||
|
||||
const onPress = useMemo(() => onNodePress ? {
|
||||
onPress: (event: PressEvent) =>
|
||||
onNodePress(createPressEventNode(data, isOpen, setOpen), event),
|
||||
} : {}, [data, isOpen, setOpen, onNodePress])
|
||||
|
||||
const { pressProps } = usePress(onPress)
|
||||
|
||||
return id !== 'root' || showRoot ? (
|
||||
// Wrapper is required for indent margin to work correctly with the tree's absolute positioning
|
||||
<span style={style}>
|
||||
<div
|
||||
ref={setRef}
|
||||
{...pressProps}
|
||||
className={cs(treeChildClass, styles.child, { [styles.focus]: isFocused })}
|
||||
style={indentSize ? { marginLeft: `${data.nestingLevel * indentSize}rem` } : undefined}
|
||||
>
|
||||
<MemoedOnRenderChild
|
||||
data={data}
|
||||
isOpen={isOpen}
|
||||
setOpen={setOpen}
|
||||
remeasure={remeasure}
|
||||
onRenderLeaf={onRenderLeaf}
|
||||
onRenderParent={onRenderParent}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
|
||||
const OnRenderChild = <
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
>({
|
||||
data: { node, nestingLevel },
|
||||
isOpen,
|
||||
setOpen,
|
||||
remeasure,
|
||||
onRenderLeaf,
|
||||
onRenderParent,
|
||||
}: InternalOnRenderChildProps<TLeaf, TParent>) =>
|
||||
isParent(node)
|
||||
? onRenderParent({
|
||||
parent: node,
|
||||
depth: nestingLevel,
|
||||
isOpen,
|
||||
setOpen,
|
||||
remeasure,
|
||||
})
|
||||
: onRenderLeaf({
|
||||
leaf: node,
|
||||
depth: nestingLevel,
|
||||
remeasure,
|
||||
})
|
||||
|
||||
// Memo `onRender` callback results to double the speed of height calculation
|
||||
const MemoedOnRenderChild = memo(OnRenderChild) as typeof OnRenderChild
|
||||
@@ -1,36 +0,0 @@
|
||||
import React, { createContext, Dispatch, RefObject, useContext, useMemo, useRef, useState } from 'react'
|
||||
|
||||
type FocusDispatch = [RefObject<string | undefined>, Dispatch<string | undefined>]
|
||||
|
||||
const FocusStateIdContext = createContext<string | undefined>(undefined)
|
||||
const FocusStateDispatchContext = createContext<FocusDispatch>(undefined as any)
|
||||
|
||||
export const FocusStateHasFocusContext = createContext<boolean>(false)
|
||||
|
||||
export const FocusStateContext: React.FC = ({ children }) => {
|
||||
const [focusState, setFocusState] = useState<string>()
|
||||
const focusRef = useRef<string | undefined>()
|
||||
|
||||
const focusDispatch = useMemo((): FocusDispatch => ([focusRef, (value: string | undefined) => {
|
||||
focusRef.current = value
|
||||
setFocusState(value)
|
||||
}]), [])
|
||||
|
||||
return (
|
||||
<FocusStateDispatchContext.Provider value={focusDispatch}>
|
||||
<FocusStateIdContext.Provider value={focusState}>
|
||||
{children}
|
||||
</FocusStateIdContext.Provider>
|
||||
</FocusStateDispatchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useFocusState = () => {
|
||||
const focusedId = useContext(FocusStateIdContext)
|
||||
|
||||
const hasFocus = useContext(FocusStateHasFocusContext)
|
||||
|
||||
return hasFocus ? focusedId : undefined
|
||||
}
|
||||
|
||||
export const useFocusDispatch = () => useContext(FocusStateDispatchContext)
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './VirtualizedTree'
|
||||
|
||||
export * from './VirtualizedTreeChild'
|
||||
|
||||
export * from './types'
|
||||
@@ -1,175 +0,0 @@
|
||||
import type { PressEvent } from '@react-types/shared'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { NodeComponentProps } from 'react-vtree/dist/lib/Tree'
|
||||
import type { VariableSizeNodePublicState } from 'react-vtree/dist/lib/VariableSizeTree'
|
||||
import type { VariableSizeTree } from 'react-vtree'
|
||||
import type { ListProps } from 'react-window'
|
||||
|
||||
// Props
|
||||
|
||||
export interface VirtualizedTreeRef {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
export interface VirtualizedTreeProps<
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
> extends RenderFunctions<TLeaf, TParent>, Omit<ListProps, 'children' | 'itemCount' | 'width' | 'height'> {
|
||||
/**
|
||||
* Use instead of `ref`. React/TS still doesn't have a good solution for `forwardRef` generics
|
||||
*/
|
||||
innerRef?: MutableRefObject<VirtualizedTreeRef>
|
||||
|
||||
treeRef?: MutableRefObject<VariableSizeTree<
|
||||
TreeNodeData<TLeaf, TParent>
|
||||
> | null>
|
||||
tree: TParent
|
||||
|
||||
defaultItemSize: number
|
||||
showRoot?: boolean
|
||||
|
||||
/**
|
||||
* If true, calculate the size of each child node
|
||||
*/
|
||||
shouldMeasure?: boolean
|
||||
/**
|
||||
* See `react-window` `overscanCount`. Defaults to 20
|
||||
*/
|
||||
overscanCount?: number
|
||||
|
||||
/**
|
||||
* If specified, automatically indent children elements by the specified size in REM units
|
||||
*/
|
||||
indentSize?: number
|
||||
|
||||
onNodePress?: OnNodePress<TLeaf, TParent>
|
||||
onNodeKeyDown?: OnNodeKeyDown<TLeaf, TParent>
|
||||
}
|
||||
|
||||
export interface LeafProps<T> {
|
||||
leaf: T
|
||||
depth: number
|
||||
remeasure: () => void
|
||||
}
|
||||
|
||||
export interface ParentProps<T> {
|
||||
parent: T
|
||||
depth: number
|
||||
isOpen: boolean
|
||||
setOpen: (isOpen: boolean) => void
|
||||
remeasure: () => void
|
||||
}
|
||||
|
||||
export interface RenderFunctions<TLeaf, TParent> {
|
||||
onRenderLeaf: (props: LeafProps<TLeaf>) => JSX.Element
|
||||
|
||||
onRenderParent: (props: ParentProps<TParent>) => JSX.Element | null
|
||||
}
|
||||
|
||||
export type ChildComponentProps<
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
> = NodeComponentProps<TreeNodeData<TLeaf, TParent>, VariableSizeNodePublicState<TreeNodeData<TLeaf, TParent>>> & {
|
||||
onNodePress?: OnNodePress<TLeaf, TParent>
|
||||
onNodeKeyDown?: OnNodeKeyDown<TLeaf, TParent>
|
||||
}
|
||||
|
||||
export interface InternalChildProps<
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
> extends ChildComponentProps<TLeaf, TParent>, RenderFunctions<TLeaf, TParent> {
|
||||
indentSize?: number
|
||||
showRoot?: boolean
|
||||
shouldMeasure?: boolean
|
||||
}
|
||||
|
||||
export interface InternalOnRenderChildProps<
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
> extends Pick<ChildComponentProps<TLeaf, TParent>, 'data' | 'isOpen' | 'setOpen'>, RenderFunctions<TLeaf, TParent> {
|
||||
remeasure: () => void
|
||||
}
|
||||
|
||||
// Base
|
||||
|
||||
export interface NodeBase {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface ParentTreeBase<T extends LeafTreeBase> extends NodeBase {
|
||||
children: Array<ParentTreeBase<T> | T>
|
||||
}
|
||||
|
||||
export type LeafTreeBase = NodeBase
|
||||
|
||||
export interface TreeNode<
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
> {
|
||||
data: TreeNodeData<TLeaf, TParent>
|
||||
}
|
||||
|
||||
export type TreeNodeData<
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
> = SpecificTreeNode<TLeaf | TParent>
|
||||
|
||||
export interface SpecificTreeNode<T> {
|
||||
id: string
|
||||
nestingLevel: number
|
||||
node: T
|
||||
isOpenByDefault: boolean
|
||||
defaultHeight: number
|
||||
isFirst: boolean
|
||||
}
|
||||
|
||||
type NodeCallbackData<
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
> = Pick<ChildComponentProps<TLeaf, TParent>, 'isOpen' | 'setOpen'> & (
|
||||
{
|
||||
type: 'leaf'
|
||||
data: SpecificTreeNode<TLeaf>
|
||||
} | {
|
||||
type: 'parent'
|
||||
data: SpecificTreeNode<TParent>
|
||||
}
|
||||
)
|
||||
|
||||
export type OnNodePress<
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
> = (node: NodeCallbackData<TLeaf, TParent>, event: PressEvent) => void
|
||||
|
||||
export type OnNodeKeyDown<
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
> = (node: NodeCallbackData<TLeaf, TParent>, event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
|
||||
export const isParent = <
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
>(
|
||||
input: TLeaf | TParent,
|
||||
): input is TParent => {
|
||||
return 'children' in input
|
||||
}
|
||||
|
||||
export const createPressEventNode = <
|
||||
TLeaf extends LeafTreeBase,
|
||||
TParent extends ParentTreeBase<TLeaf>
|
||||
>(data: TreeNodeData<TLeaf, TParent>, isOpen: boolean, setOpen: (state: boolean) => Promise<void>) => {
|
||||
return isParent(data.node) ? {
|
||||
type: 'parent' as const,
|
||||
data: data as SpecificTreeNode<TParent>,
|
||||
isOpen,
|
||||
setOpen,
|
||||
} : {
|
||||
type: 'leaf' as const,
|
||||
data: data as SpecificTreeNode<TLeaf>,
|
||||
isOpen,
|
||||
setOpen,
|
||||
}
|
||||
}
|
||||
|
||||
export const treeChildClass = 'treeChild'
|
||||
@@ -1,28 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { composeStories } from '@storybook/testing-react'
|
||||
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
|
||||
import * as stories from './Button.stories'
|
||||
const { Button, IconButton } = composeStories(stories)
|
||||
|
||||
// TODO: Autogenerate from stories
|
||||
describe('<Button />', () => {
|
||||
it('Button', () => {
|
||||
mountAndSnapshot(<Button />)
|
||||
})
|
||||
|
||||
it('ButtonSizes', () => {
|
||||
const ButtonSizes = () => (
|
||||
<div style={{ width: 500 }}>
|
||||
{stories.buttonSizesWithSizes(['text-xs', 'text-s', 'text-ms', 'text-m', 'text-ml', 'text-l', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl'])}
|
||||
</div>
|
||||
)
|
||||
|
||||
mountAndSnapshot(<ButtonSizes />)
|
||||
})
|
||||
|
||||
it('IconButton', () => {
|
||||
mountAndSnapshot(<IconButton />)
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import * as React from 'react'
|
||||
// TODO: This is causing a "module not defined error"
|
||||
// Find out why and fix it
|
||||
// import { action } from '@storybook/addon-actions'
|
||||
|
||||
import { createStory, createStorybookConfig } from 'stories/util'
|
||||
|
||||
import { Button as ButtonComponent, LinkButton } from './Button'
|
||||
import { IconButton as IconButtonComponent } from './IconButton'
|
||||
|
||||
import typography from 'css/derived/jsTypography.scss'
|
||||
import { TextSize } from 'css'
|
||||
import { PaddedBox } from '../surface/paddedBox/PaddedBox'
|
||||
import { Icon } from '../icon/Icon'
|
||||
|
||||
// stub it for now
|
||||
const action = (action: string) => undefined
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Core/Button',
|
||||
})
|
||||
|
||||
export const Button = createStory(() => (
|
||||
<div>
|
||||
<PaddedBox>
|
||||
<ButtonComponent aria-label="buttonPress" onPress={action('buttonPress')}>Simple button</ButtonComponent>
|
||||
<LinkButton aria-label="anchorButtonPress" onPress={action('anchorButtonPress')}>Anchor button</LinkButton>
|
||||
</PaddedBox>
|
||||
<PaddedBox style={{ backgroundColor: 'var(--brand-00)' }}>
|
||||
<ButtonComponent aria-label="buttonPress" color='white' onPress={action('buttonPress')}>Simple button</ButtonComponent>
|
||||
<LinkButton aria-label="anchorButtonPress" color='white' onPress={action('anchorButtonPress')}>Anchor button</LinkButton>
|
||||
</PaddedBox>
|
||||
<PaddedBox>
|
||||
<ButtonComponent aria-label="buttonPress" color='white' onPress={action('buttonPress')}>Simple button</ButtonComponent>
|
||||
<LinkButton aria-label="anchorButtonPress" color='white' onPress={action('anchorButtonPress')}>Anchor button</LinkButton>
|
||||
</PaddedBox>
|
||||
</div>
|
||||
))
|
||||
|
||||
export const buttonSizesWithSizes = (sizes: string[]) => sizes.filter((key) => key !== 'type' && !key.startsWith('line-height') && !key.startsWith('text-mono')).map((key) => {
|
||||
const size = key.replace('text-', '')
|
||||
|
||||
return (
|
||||
<ButtonComponent
|
||||
key={key}
|
||||
size={size as TextSize}
|
||||
aria-label="buttonPress"
|
||||
>
|
||||
{`Button ${size}`}
|
||||
</ButtonComponent>
|
||||
)
|
||||
})
|
||||
|
||||
export const ButtonSizes = createStory(() => (
|
||||
<div>
|
||||
<div style={{ width: 500 }}>
|
||||
{buttonSizesWithSizes(Object.keys(typography))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
export const IconButton = createStory(() => (
|
||||
<div>
|
||||
<div style={{ width: 500 }}>
|
||||
<IconButtonComponent aria-label="iconButton" elementType='button' icon='horse' />
|
||||
</div>
|
||||
<PaddedBox>
|
||||
<IconButtonComponent aria-label="iconButton" elementType='button' icon='hotdog' />
|
||||
<ButtonComponent aria-label="normalButton">Text button</ButtonComponent>
|
||||
<LinkButton aria-label="linkButton">
|
||||
<Icon icon='jedi' />
|
||||
{' Inline Icon with text'}
|
||||
</LinkButton>
|
||||
</PaddedBox>
|
||||
<PaddedBox style={{ backgroundColor: 'var(--brand-00)' }}>
|
||||
<IconButtonComponent aria-label="iconButton" elementType='button' icon='hotdog' color='white' />
|
||||
<ButtonComponent aria-label="normalButton" color='white'>Text button</ButtonComponent>
|
||||
<LinkButton aria-label="linkButton" color='white'>
|
||||
<Icon icon='jedi' />
|
||||
{' Inline Icon with text'}
|
||||
</LinkButton>
|
||||
</PaddedBox>
|
||||
</div>
|
||||
))
|
||||
@@ -1,69 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import cs from 'classnames'
|
||||
|
||||
import { useButton } from '@react-aria/button'
|
||||
import { AriaButtonProps } from '@react-types/button'
|
||||
import { TextSizableComponent } from '../shared'
|
||||
import { styledTextSizeClassNames } from 'core/text/styledText'
|
||||
|
||||
import styles from './Button.module.scss'
|
||||
import { FocusRing } from '@react-aria/focus'
|
||||
import { focusClass } from 'css/derived/util'
|
||||
|
||||
const { useRef } = React
|
||||
|
||||
interface SharedButtonProps extends TextSizableComponent {
|
||||
/**
|
||||
* Defaults to 'blue'
|
||||
*/
|
||||
color?: 'blue' | 'white'
|
||||
noBorder?: boolean
|
||||
|
||||
['aria-label']: string
|
||||
}
|
||||
|
||||
export type BaseButtonProps = SharedButtonProps & (({
|
||||
elementType: 'button'
|
||||
} & AriaButtonProps<'button'>) | ({
|
||||
elementType: 'a'
|
||||
} & AriaButtonProps<'a'>))
|
||||
|
||||
export type ButtonProps = SharedButtonProps & Omit<AriaButtonProps<'button'>, 'elementType'>
|
||||
|
||||
export type LinkButtonProps = ButtonProps & Omit<AriaButtonProps<'a'>, 'elementType'>
|
||||
|
||||
export const BaseButton: React.FC<BaseButtonProps> = ({ size, color, noBorder, children, ...props }) => {
|
||||
const buttonRef = useRef<HTMLAnchorElement | HTMLButtonElement>(null)
|
||||
|
||||
const { buttonProps } = useButton(props, buttonRef)
|
||||
|
||||
const textClass = styledTextSizeClassNames(size)
|
||||
|
||||
const classNames = cs(textClass, styles.button, {
|
||||
[styles.white]: color === 'white',
|
||||
[styles.disableBorder]: noBorder,
|
||||
}, buttonProps.className, props.className)
|
||||
|
||||
return (
|
||||
<FocusRing focusRingClass={focusClass}>
|
||||
{props.elementType === 'button' ? (
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={buttonRef as RefObject<HTMLButtonElement>}
|
||||
className={classNames}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
) : (
|
||||
<a {...buttonProps} ref={buttonRef as RefObject<HTMLAnchorElement>} className={classNames}>
|
||||
{children}
|
||||
</a>
|
||||
)}
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps & Omit<AriaButtonProps<'button'>, 'elementType'>> = (props) => <BaseButton {...props} elementType='button' />
|
||||
|
||||
export const LinkButton: React.FC<ButtonProps & Omit<AriaButtonProps<'a'>, 'elementType'>> = (props) => <BaseButton {...props} elementType='a' />
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Icon, IconProps } from '../icon/Icon'
|
||||
import { BaseButton, BaseButtonProps } from './Button'
|
||||
|
||||
export type IconButtonProps = {
|
||||
iconClassName?: string
|
||||
} & BaseButtonProps & IconProps;
|
||||
|
||||
// We don't actually need to spread several of these props, but FontAwesome complains if it receives extra props
|
||||
export const IconButton: React.FC<IconButtonProps> = ({ className, iconClassName, color, elementType, noBorder, onPress, ...props }) => (
|
||||
// Cast to button just to prevent TS error
|
||||
<BaseButton
|
||||
{...props}
|
||||
elementType={elementType as 'button'}
|
||||
className={className}
|
||||
color={color}
|
||||
noBorder={noBorder}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Icon ignoreTextCenter={true} {...props} aria-label={undefined} className={iconClassName} />
|
||||
</BaseButton>
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './Button'
|
||||
|
||||
export * from './IconButton'
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
|
||||
import styles from './Icon.stories.module.scss'
|
||||
import { iconLines } from './Icon.stories'
|
||||
|
||||
// TODO: Autogenerate from stories
|
||||
describe('<Icon />', () => {
|
||||
it('Standard icons', () => {
|
||||
const Icons = () => (
|
||||
<div>
|
||||
<Icon className={styles.icon} icon='check' size='xl' />
|
||||
<Icon className={styles.icon} icon='exclamation' size='xl' />
|
||||
<Icon className={styles.icon} icon='home' size='xl' />
|
||||
<Icon className={styles.icon} icon='arrow-circle-up' size='xl' />
|
||||
</div>
|
||||
)
|
||||
|
||||
mountAndSnapshot(<Icons />)
|
||||
})
|
||||
|
||||
it('Icon lines', () => {
|
||||
const Icons = () => (
|
||||
<>
|
||||
{iconLines(['text-xs', 'text-s', 'text-ms', 'text-m', 'text-ml', 'text-l', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl'])}
|
||||
</>
|
||||
)
|
||||
|
||||
mountAndSnapshot(<Icons />)
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { createStory, createStorybookConfig } from 'stories/util'
|
||||
|
||||
import { Icon as IconComponent } from './Icon'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fab } from '@fortawesome/free-brands-svg-icons'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import typography from 'css/derived/jsTypography.scss'
|
||||
import styles from './Icon.stories.module.scss'
|
||||
import { TextSize } from 'css'
|
||||
import { Baseline } from '../../measure/baseline/Baseline'
|
||||
|
||||
library.add(fas)
|
||||
library.add(fab)
|
||||
|
||||
const fontOptions = ['-apple-system, BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica']
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Core/Icon',
|
||||
argTypes: {
|
||||
font: {
|
||||
control: {
|
||||
type: 'select',
|
||||
options: fontOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
excludeStories: ['IconLines'],
|
||||
})
|
||||
|
||||
export const iconLines = (sizes: string[]) => sizes.filter((key) => key !== 'type').map((key) => {
|
||||
const size = key.replace('text-', '')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
marginBottom: '2em',
|
||||
}}
|
||||
>
|
||||
<div className="text-mono-m">
|
||||
{size}
|
||||
</div>
|
||||
<Baseline className={key}>
|
||||
<IconComponent className={styles.textIcon} icon='square' size={size as TextSize} />
|
||||
<IconComponent className={styles.textIcon} icon='exclamation' size={size as TextSize} />
|
||||
The five boxing wizards jump quickly
|
||||
<IconComponent icon='exclamation' size={size as TextSize} />
|
||||
<IconComponent icon='bell' size={size as TextSize} />
|
||||
</Baseline>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Icon = createStory<{ font: string }>(({ font }) => (
|
||||
<div style={{
|
||||
'--font-stack-sans': font,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<IconComponent className={styles.icon} icon='check' size='xl' />
|
||||
<IconComponent className={styles.icon} icon='exclamation' size='xl' />
|
||||
<IconComponent className={styles.icon} icon='home' size='xl' />
|
||||
<IconComponent className={styles.icon} icon='arrow-circle-up' size='xl' />
|
||||
<br />
|
||||
{iconLines(Object.keys(typography))}
|
||||
</div>
|
||||
), {
|
||||
font: fontOptions[0],
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import type { SVGAttributes } from 'react'
|
||||
import cs from 'classnames'
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IconName } from '@fortawesome/fontawesome-svg-core'
|
||||
import { styledTextSizeClassNames } from 'core/text/styledText'
|
||||
|
||||
import styles from './Icon.module.scss'
|
||||
import { TextSizableComponent } from '../shared'
|
||||
|
||||
export interface IconProps extends TextSizableComponent, Omit<SVGAttributes<SVGSVGElement>, 'mask'> {
|
||||
// TODO: Limit literals to only those available in the iconset
|
||||
icon: IconName
|
||||
|
||||
/**
|
||||
* Render icon at 1em without centering
|
||||
*/
|
||||
ignoreTextCenter?: boolean
|
||||
|
||||
/**
|
||||
* Render icon at text size without centering
|
||||
*/
|
||||
sizeWithoutCenter?: boolean
|
||||
}
|
||||
|
||||
// Currently only a passthrough for FontAwesome. This provides a single place to swap out the icon library
|
||||
export const Icon: React.FC<IconProps> = ({ className, size, lineHeight, icon, ignoreTextCenter, sizeWithoutCenter, ...props }) => (
|
||||
<FontAwesomeIcon
|
||||
{...props}
|
||||
className={cs(styledTextSizeClassNames(size, lineHeight), styles.icon, {
|
||||
[styles.ignoreTextCenter]: ignoreTextCenter,
|
||||
[styles.sizeWithoutCenter]: sizeWithoutCenter,
|
||||
}, className)}
|
||||
icon={icon}
|
||||
/>
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
export * from './button'
|
||||
|
||||
export * from './icon/Icon'
|
||||
|
||||
export * from './input'
|
||||
|
||||
export * from './surface'
|
||||
|
||||
export * from './text'
|
||||
@@ -1,95 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import type { RefAttributes } from 'react'
|
||||
import cs from 'classnames'
|
||||
import { useFocusRing } from '@react-aria/focus'
|
||||
import { PressEvent } from '@react-types/shared'
|
||||
import { Icon, IconProps } from '../icon/Icon'
|
||||
import { BasicInput, InputBase, InputProps, InputRenderer } from './InputBase'
|
||||
|
||||
import { focusClass, modifySize } from 'css/derived/util'
|
||||
import { textSizeToClassName } from 'core/text/styledText'
|
||||
import { IconButton, IconButtonProps } from '../button/IconButton'
|
||||
|
||||
import styles from './IconInput.module.scss'
|
||||
|
||||
export type IconSettings = {
|
||||
className?: string
|
||||
icon: IconProps['icon']
|
||||
hideOnFocus?: boolean
|
||||
hidden?: boolean
|
||||
} & ({
|
||||
// If click is specified, it _must_ have an aria label
|
||||
onPress: (event: PressEvent) => void
|
||||
['aria-label']: string
|
||||
} | {
|
||||
onPress?: undefined
|
||||
['aria-label']?: string | undefined
|
||||
})
|
||||
|
||||
export type IconInputProps = InputProps<{
|
||||
prefixIcon?: IconSettings
|
||||
suffixIcon?: IconSettings
|
||||
}>
|
||||
& RefAttributes<HTMLInputElement>
|
||||
|
||||
export const IconInput: React.FC<IconInputProps> = (props) => <InputBase {...props} InputRenderer={IconInputComponent} />
|
||||
|
||||
const IconInputComponent: InputRenderer<IconInputProps> = ({ componentProps: { size = 'm', prefixIcon, suffixIcon, className, ...props }, inputProps, inputRef }) => {
|
||||
const iconSize = modifySize(size, 2)
|
||||
const { isFocused, focusProps } = useFocusRing({ isTextInput: true })
|
||||
|
||||
const prefixIconProps = prefixIcon ? {
|
||||
className: cs(prefixIcon.onPress ? styles.iconButton : styles.icon, prefixIcon.className),
|
||||
size: iconSize,
|
||||
['aria-label']: prefixIcon['aria-label'],
|
||||
} : {}
|
||||
|
||||
const suffixIconProps = suffixIcon ? {
|
||||
className: cs(suffixIcon.onPress ? styles.iconButton : styles.icon, suffixIcon.className),
|
||||
size: iconSize,
|
||||
['aria-label']: suffixIcon['aria-label'],
|
||||
} : {}
|
||||
|
||||
return (
|
||||
<span className={cs(styles.iconInput, { [focusClass]: isFocused }, className)}>
|
||||
{prefixIcon && (
|
||||
prefixIcon.onPress ? (
|
||||
<IconButton
|
||||
{...prefixIconProps as IconButtonProps}
|
||||
elementType='button'
|
||||
color='white'
|
||||
noBorder={true}
|
||||
ignoreTextCenter={false}
|
||||
icon={prefixIcon.icon}
|
||||
onPress={prefixIcon.onPress}
|
||||
/>
|
||||
) : <Icon {...prefixIconProps} icon={prefixIcon.icon} />
|
||||
)}
|
||||
{/* Apply iconSize to input wrapper, so we have the same em measure */}
|
||||
<div className={cs(textSizeToClassName(iconSize), styles.wrapper)}>
|
||||
<BasicInput
|
||||
{...props}
|
||||
{...inputProps}
|
||||
{...focusProps}
|
||||
inputRef={inputRef}
|
||||
textArea={false}
|
||||
className={cs(styles.input)}
|
||||
size={size}
|
||||
/>
|
||||
</div>
|
||||
{suffixIcon && (
|
||||
suffixIcon.onPress ? (
|
||||
<IconButton
|
||||
{...suffixIconProps as IconButtonProps}
|
||||
elementType='button'
|
||||
color='white'
|
||||
noBorder={true}
|
||||
ignoreTextCenter={false}
|
||||
icon={suffixIcon.icon}
|
||||
onPress={suffixIcon.onPress}
|
||||
/>
|
||||
) : <Icon {...suffixIconProps} icon={suffixIcon.icon} />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { composeStories } from '@storybook/testing-react'
|
||||
import * as stories from './Input.stories'
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
// import { iconSizesWithSizes } from './Input.stories'
|
||||
|
||||
const {
|
||||
Input,
|
||||
// Icon
|
||||
} = composeStories(stories)
|
||||
|
||||
// TODO: Autogenerate from stories
|
||||
describe('<Input />', () => {
|
||||
it('Standard input', () => {
|
||||
mountAndSnapshot(<Input />)
|
||||
})
|
||||
|
||||
it('IconInput', () => {
|
||||
// mountAndSnapshot(<Icon />)
|
||||
})
|
||||
|
||||
// it('IconInput sizes', () => {
|
||||
// const IconInput = () => (
|
||||
// <>
|
||||
// {iconSizesWithSizes(['xs', 's', 'ms', 'm', 'ml', 'l', 'xl', '2xl'])}
|
||||
// </>
|
||||
// )
|
||||
|
||||
// mountAndSnapshot(<IconInput />)
|
||||
// })
|
||||
})
|
||||
@@ -1,146 +0,0 @@
|
||||
import * as React from 'react'
|
||||
// TODO: This is causing a "module not defined error"
|
||||
// Find out why and fix it
|
||||
// import { action } from '@storybook/addon-actions'
|
||||
|
||||
import { createStory, createStorybookConfig } from '../../stories/util'
|
||||
|
||||
import { Input as InputComponent } from './Input'
|
||||
import { IconInput as IconInputComponent } from './IconInput'
|
||||
|
||||
import { TextSize } from 'css'
|
||||
|
||||
// stub it for now
|
||||
const action = (action: string) => undefined
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Core/Input',
|
||||
excludeStories: ['iconSizesWithSizes'],
|
||||
})
|
||||
|
||||
export const Input = createStory(() => (
|
||||
<div>
|
||||
<InputComponent label={{ type: 'aria', contents: 'aria labeled input' }} />
|
||||
<InputComponent label={{
|
||||
type: 'tag',
|
||||
contents: 'Labeled input',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
export const Icon = createStory(() => (
|
||||
<div>
|
||||
<div>
|
||||
<input />
|
||||
</div>
|
||||
<div>
|
||||
<InputComponent label={{ type: 'aria', contents: 'foo' }} />
|
||||
</div>
|
||||
<div>
|
||||
<IconInputComponent
|
||||
label={{ type: 'aria', contents: 'full width input' }}
|
||||
prefixIcon={{
|
||||
icon: 'home',
|
||||
// onPress: action('onPrefixClick'),
|
||||
'aria-label': 'onPrefixClick',
|
||||
}}
|
||||
suffixIcon={{
|
||||
icon: 'times',
|
||||
// onPress: action('onSuffixClick'),
|
||||
'aria-label': 'onSuffixClick',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 500 }}>
|
||||
<IconInputComponent
|
||||
label={{ type: 'aria', contents: '500px width input' }}
|
||||
suffixIcon={{
|
||||
icon: 'times',
|
||||
// onPress: action('onSuffixClick'),
|
||||
'aria-label': 'onSuffixClick',
|
||||
}}
|
||||
value="This is a very long string in an IconInput. This displays the padding on the input section"
|
||||
/>
|
||||
<IconInputComponent
|
||||
label={{ type: 'aria', contents: '500px width input' }}
|
||||
prefixIcon={{
|
||||
icon: 'home',
|
||||
// onPress: action('onPrefixClick'),
|
||||
'aria-label': 'onPrefixClick',
|
||||
}}
|
||||
value="This is a very long string in an IconInput. This displays the padding on the input section"
|
||||
/>
|
||||
<IconInputComponent
|
||||
label={{
|
||||
type: 'tag',
|
||||
contents: 'Labeled IconInput',
|
||||
}}
|
||||
prefixIcon={{
|
||||
icon: 'home',
|
||||
// onPress: action('onPrefixClick'),
|
||||
'aria-label': 'onPrefixClick',
|
||||
}}
|
||||
suffixIcon={{
|
||||
icon: 'times',
|
||||
// onPress: action('onSuffixClick'),
|
||||
'aria-label': 'onSuffixClick',
|
||||
}}
|
||||
/>
|
||||
<IconInputComponent
|
||||
label={{ type: 'aria', contents: 'trailing button only' }}
|
||||
prefixIcon={{
|
||||
icon: 'home',
|
||||
}}
|
||||
suffixIcon={{
|
||||
icon: 'times',
|
||||
// onPress: action('onSuffixClick'),
|
||||
'aria-label': 'onSuffixClick',
|
||||
}}
|
||||
placeholder="The leading icon isn't a button"
|
||||
/>
|
||||
<IconInputComponent
|
||||
label={{ type: 'aria', contents: 'leading button only' }}
|
||||
prefixIcon={{
|
||||
icon: 'home',
|
||||
// onPress: action('onPrefixClick'),
|
||||
'aria-label': 'onPrefixClick',
|
||||
}}
|
||||
suffixIcon={{
|
||||
icon: 'times',
|
||||
}}
|
||||
placeholder="The trailing icon isn't a button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
export const iconSizesWithSizes = (sizes: string[]) => sizes.map((key) => {
|
||||
const size = key.replace('text-', '')
|
||||
|
||||
return (
|
||||
<IconInputComponent
|
||||
key={key}
|
||||
label={{ type: 'aria', contents: `input size ${size}` }}
|
||||
size={size as TextSize}
|
||||
prefixIcon={{
|
||||
icon: 'home',
|
||||
onPress: action('onPrefixClick'),
|
||||
'aria-label': 'onPrefixClick',
|
||||
}}
|
||||
suffixIcon={{
|
||||
icon: 'times',
|
||||
onPress: action('onSuffixClick'),
|
||||
'aria-label': 'onSuffixClick',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
// export const IconSizes = createStory(() => (
|
||||
// <div>
|
||||
// <div style={{ width: 500 }}>
|
||||
// {iconSizesWithSizes(Object.keys(typography).filter((key) => key !== 'type' && !key.startsWith('line-height') && !key.startsWith('text-mono') && key !== 'text-3xl' && key !== 'text-4xl'))}
|
||||
// </div>
|
||||
// </div>
|
||||
// ))
|
||||
@@ -1,30 +0,0 @@
|
||||
import React, { InputHTMLAttributes } from 'react'
|
||||
import { useFocusRing } from 'react-aria'
|
||||
import cs from 'classnames'
|
||||
|
||||
import { focusClass } from 'css/derived/util'
|
||||
import { BasicInput, BasicInputProps, InputBase, InputProps, InputRenderer } from './InputBase'
|
||||
|
||||
import styles from './InputBase.module.scss'
|
||||
|
||||
export const Input: React.FC<InputProps<{}>> = (props) => (
|
||||
<InputBase
|
||||
{...props}
|
||||
InputRenderer={BasicInputRenderer}
|
||||
/>
|
||||
)
|
||||
|
||||
// TODO: The types here are not as elegant as I would like
|
||||
const BasicInputRenderer: InputRenderer<Omit<BasicInputProps, 'inputRef'>> = ({ componentProps, inputProps, inputRef }) => {
|
||||
const { isFocused, focusProps } = useFocusRing({ isTextInput: true })
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cs(styles.wrapper, {
|
||||
[focusClass]: isFocused,
|
||||
}, componentProps.className)}
|
||||
>
|
||||
<BasicInput {...inputProps as Omit<InputHTMLAttributes<HTMLInputElement>, 'size'>} {...focusProps} {...componentProps} inputRef={inputRef} className={inputProps.className} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import type { CSSProperties, InputHTMLAttributes, MutableRefObject, ReactNode, RefObject, TextareaHTMLAttributes } from 'react'
|
||||
import { useTextField } from 'react-aria'
|
||||
import cs from 'classnames'
|
||||
|
||||
import { ExtractFirstArg } from 'util/types'
|
||||
import { LineHeight, TextSize } from 'css'
|
||||
import { styledTextSizeClassNames } from 'core/text/styledText'
|
||||
import { SizingProps } from 'core/shared'
|
||||
|
||||
import styles from './InputBase.module.scss'
|
||||
import { useCombinedRefs } from 'hooks/useCombinedRefs'
|
||||
|
||||
const { useMemo, useRef } = React
|
||||
|
||||
export interface SharedInputBaseProps extends SizingProps, Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
inputRef?: MutableRefObject<HTMLTextAreaElement | HTMLInputElement | null> | null
|
||||
|
||||
label: {
|
||||
type: 'tag'
|
||||
contents: ReactNode
|
||||
labelClassName?: string
|
||||
size?: TextSize
|
||||
lineHeight?: LineHeight
|
||||
} | {
|
||||
type: 'aria'
|
||||
contents: string
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, render as a textarea (multiline) instead of an input. Defaults to false
|
||||
*/
|
||||
textArea?: boolean
|
||||
}
|
||||
|
||||
export type InputProps<T> = SharedInputBaseProps & {
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
} & T
|
||||
|
||||
export interface InputRendererProps<T> {
|
||||
componentProps: Omit<InputProps<T>, 'label'>
|
||||
inputProps: InputHTMLAttributes<HTMLInputElement>
|
||||
inputRef: RefObject<HTMLTextAreaElement | HTMLInputElement>
|
||||
}
|
||||
|
||||
export type InputRenderer<T> = React.FC<InputRendererProps<T>>
|
||||
|
||||
export type InputBaseProps<T> = SharedInputBaseProps & {
|
||||
InputRenderer: InputRenderer<T>
|
||||
} & T
|
||||
|
||||
export const InputBase = <T, >({ InputRenderer, label, textArea, inputRef: externalInputRef = null, ...props }: InputBaseProps<T>) => {
|
||||
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null)
|
||||
|
||||
useCombinedRefs(inputRef, externalInputRef)
|
||||
|
||||
const textFieldProps = useMemo((): ExtractFirstArg<typeof useTextField> => {
|
||||
const newProps = {
|
||||
...props,
|
||||
inputElementType: textArea ? 'textarea' : 'input',
|
||||
} as ExtractFirstArg<typeof useTextField>
|
||||
|
||||
if (label.type === 'aria') {
|
||||
newProps['aria-label'] = label.contents
|
||||
} else if (label.type === 'tag') {
|
||||
newProps.label = label.contents
|
||||
}
|
||||
|
||||
return newProps
|
||||
}, [label, textArea, props])
|
||||
|
||||
const { inputProps, labelProps } = useTextField(textFieldProps, inputRef)
|
||||
|
||||
return (
|
||||
<>
|
||||
{label.type === 'tag' && (
|
||||
<label {...labelProps} className={cs(styledTextSizeClassNames(label.size, label.lineHeight), labelProps.className)}>
|
||||
{label.contents}
|
||||
</label>
|
||||
)}
|
||||
{/* TODO: This cast is incorrect. It can be textarea */}
|
||||
<InputRenderer componentProps={props as Omit<InputProps<T>, 'label'>} inputProps={inputProps as InputHTMLAttributes<HTMLInputElement>} inputRef={inputRef} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type BasicInputProps = SizingProps & Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & {
|
||||
inputRef: RefObject<HTMLTextAreaElement | HTMLInputElement>
|
||||
|
||||
/**
|
||||
* If true, render as a textarea (multiline) instead of an input. Defaults to false
|
||||
*/
|
||||
textArea?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* **Note:** Should not be directly rendered in app code. This should only be provided in an `inputRenderer` function
|
||||
*/
|
||||
export const BasicInput: React.FC<BasicInputProps> = ({ inputRef, className, size, lineHeight, textArea, ...props }) => {
|
||||
const textClass = styledTextSizeClassNames(size, lineHeight)
|
||||
|
||||
return textArea
|
||||
? <textarea {...props as TextareaHTMLAttributes<HTMLTextAreaElement>} ref={inputRef as RefObject<HTMLTextAreaElement>} className={cs(textClass, styles.input, className)} />
|
||||
: <input {...props as InputHTMLAttributes<HTMLInputElement>} ref={inputRef as RefObject<HTMLInputElement>} className={cs(textClass, styles.input, className)} />
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './Input'
|
||||
|
||||
export * from './IconInput'
|
||||
@@ -1,19 +0,0 @@
|
||||
import { LineHeight, TextSize } from 'css'
|
||||
|
||||
export interface CoreComponent {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface SizingProps {
|
||||
/**
|
||||
* Defaults to 'm'
|
||||
*/
|
||||
size?: TextSize
|
||||
|
||||
/**
|
||||
* Defaults to 'normal'
|
||||
*/
|
||||
lineHeight?: LineHeight
|
||||
}
|
||||
|
||||
export type TextSizableComponent = CoreComponent & SizingProps
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { composeStories } from '@storybook/testing-react'
|
||||
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
|
||||
import * as stories from './Elevation.stories'
|
||||
const { Elevation } = composeStories(stories)
|
||||
|
||||
// TODO: Autogenerate from stories
|
||||
describe('<Elevation />', () => {
|
||||
it('Elevation', () => {
|
||||
mountAndSnapshot(<Elevation />)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { createStory, createStorybookConfig } from 'stories/util'
|
||||
|
||||
import { Elevation as ElevationComponent } from './Elevation'
|
||||
import { lorem } from 'util/lorem'
|
||||
import { StoryHighlightWrapper } from 'util/storybook/storyHighlightWrapper/StoryHighlightWrapper'
|
||||
import { SurfaceElevation } from 'css'
|
||||
|
||||
import surfaces from 'css/derived/jsSurfaces.scss'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Core/Surfaces/Elevation',
|
||||
argTypes: {
|
||||
elevation: {
|
||||
control: {
|
||||
type: 'select',
|
||||
options: Object.keys(surfaces).map((key) => key.replace('shadow-', '')),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const Elevation = createStory<{
|
||||
elevation: SurfaceElevation
|
||||
}>(({ elevation }) => (
|
||||
<div>
|
||||
<ElevationComponent elevation={elevation}>
|
||||
{lorem}
|
||||
</ElevationComponent>
|
||||
<br />
|
||||
<StoryHighlightWrapper>
|
||||
<ElevationComponent elevation={elevation}>
|
||||
{lorem}
|
||||
</ElevationComponent>
|
||||
</StoryHighlightWrapper>
|
||||
</div>
|
||||
), {
|
||||
elevation: 'bordered',
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import cs from 'classnames'
|
||||
|
||||
import { SurfaceElevation } from 'css'
|
||||
|
||||
export interface ElevationProps extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* Defaults to 'flat'
|
||||
*/
|
||||
elevation?: SurfaceElevation
|
||||
}
|
||||
|
||||
export const Elevation: React.FC<ElevationProps> = ({ className, elevation, children, ...props }) => (
|
||||
<div {...props} className={cs(`depth-${elevation ?? 'flat'}`, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './elevation/Elevation'
|
||||
|
||||
export * from './paddedBox/PaddedBox'
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { composeStories } from '@storybook/testing-react'
|
||||
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
|
||||
import * as stories from './PaddedBox.stories'
|
||||
const { PaddedBox } = composeStories(stories)
|
||||
|
||||
// TODO: Autogenerate from stories
|
||||
describe('<PaddedBox />', () => {
|
||||
it('PaddedBox', () => {
|
||||
mountAndSnapshot(<PaddedBox />)
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { createStory, createStorybookConfig } from 'stories/util'
|
||||
|
||||
import { PaddedBox as PaddedComponent } from './PaddedBox'
|
||||
import { lorem } from 'util/lorem'
|
||||
import { StoryHighlightWrapper } from 'util/storybook/storyHighlightWrapper/StoryHighlightWrapper'
|
||||
import { Spacing } from 'css'
|
||||
|
||||
import spacing from 'css/derived/jsSpacing.scss'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Core/Surfaces/PaddedBox',
|
||||
argTypes: {
|
||||
padding: {
|
||||
control: {
|
||||
type: 'select',
|
||||
options: Object.keys(spacing).map((key) => key.replace('space-', '')),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const PaddedBox = createStory<{
|
||||
padding: Spacing
|
||||
}>(({ padding }) => (
|
||||
<div>
|
||||
<StoryHighlightWrapper>
|
||||
<PaddedComponent padding={padding}>
|
||||
{lorem}
|
||||
</PaddedComponent>
|
||||
</StoryHighlightWrapper>
|
||||
</div>
|
||||
), {
|
||||
padding: 'm',
|
||||
})
|
||||
|
||||
// Required to prevent Storybook from separating into two words and creating unnecessary nesting
|
||||
PaddedBox.storyName = 'PaddedBox'
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import cs from 'classnames'
|
||||
|
||||
import { Spacing } from 'css'
|
||||
import { CoreComponent } from 'core/shared'
|
||||
import { paddingClass } from 'css/derived/util'
|
||||
|
||||
export interface PaddedBoxProps extends CoreComponent, React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
|
||||
style?: CSSProperties
|
||||
|
||||
/**
|
||||
* Defaults to 'm'
|
||||
*/
|
||||
padding?: Spacing
|
||||
}
|
||||
|
||||
export const PaddedBox: React.FC<PaddedBoxProps> = ({ className, style, padding, children, ...props }) => (
|
||||
<div {...props} className={cs(paddingClass(padding ?? 'm'), className)} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './placeholder/Placeholder'
|
||||
|
||||
export * from './styledText/StyledText'
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { composeStories } from '@storybook/testing-react'
|
||||
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
|
||||
import * as stories from './Placeholder.stories'
|
||||
const { Placeholder } = composeStories(stories)
|
||||
|
||||
// TODO: Autogenerate from stories
|
||||
describe('<Placeholder />', () => {
|
||||
it('Placeholder', () => {
|
||||
mountAndSnapshot(<Placeholder />)
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { Placeholder as PlaceholderComponent } from './Placeholder'
|
||||
import { createStory, createStorybookConfig } from 'stories/util'
|
||||
|
||||
import { lorem } from 'util/lorem'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Core/Placeholder',
|
||||
})
|
||||
|
||||
export const Placeholder = createStory(() => (
|
||||
<div>
|
||||
<div>
|
||||
<PlaceholderComponent>
|
||||
This is placeholder text
|
||||
</PlaceholderComponent>
|
||||
</div>
|
||||
<div>
|
||||
<PlaceholderComponent>
|
||||
{lorem}
|
||||
</PlaceholderComponent>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from 'react'
|
||||
import cs from 'classnames'
|
||||
|
||||
import { StyledText, StyledTextProps } from '../styledText/StyledText'
|
||||
|
||||
import styles from './Placeholder.module.scss'
|
||||
|
||||
/**
|
||||
* StyledText designed to act as a placeholder state. Size defaults to ms
|
||||
*/
|
||||
export const Placeholder: React.FC<StyledTextProps> = (props) => (
|
||||
<StyledText {...props} className={cs(styles.placeholder, props.className)} size={props.size ?? 'ms'} />
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
export * from './Placeholder'
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { mountAndSnapshot } from 'util/testing'
|
||||
|
||||
import { styledTextWithSizes } from './StyledText.stories'
|
||||
|
||||
// TODO: Autogenerate from stories
|
||||
describe('<StyledText />', () => {
|
||||
it('StyledText', () => {
|
||||
const StyledText = () => (
|
||||
<>
|
||||
{styledTextWithSizes(['text-xs', 'text-s', 'text-ms', 'text-m', 'text-ml', 'text-l', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl'])}
|
||||
</>
|
||||
)
|
||||
|
||||
mountAndSnapshot(<StyledText />)
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { StyledText as TextComponent } from './StyledText'
|
||||
import { createStory, createStorybookConfig } from 'stories/util'
|
||||
|
||||
import typography from 'css/derived/jsTypography.scss'
|
||||
import { TextSize } from 'css'
|
||||
import { lorem } from 'util/lorem'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'Core/StyledText',
|
||||
excludeStories: ['styledTextWithSizes'],
|
||||
})
|
||||
|
||||
export const styledTextWithSizes = (sizes: string[]) => sizes.filter((key) => !key.startsWith('line-height')).map((key) => {
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<TextComponent size='mono-m'>
|
||||
{key}
|
||||
</TextComponent>
|
||||
</h3>
|
||||
<p key={key}>
|
||||
<TextComponent size={key.replace('text-', '') as TextSize}>
|
||||
{lorem}
|
||||
</TextComponent>
|
||||
</p>
|
||||
<hr />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export const StyledText = createStory(() => (
|
||||
<div>
|
||||
{styledTextWithSizes(Object.keys(typography))}
|
||||
</div>
|
||||
))
|
||||
|
||||
StyledText.storyName = 'StyledText'
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import cs from 'classnames'
|
||||
|
||||
import { LineHeight, TextSize } from 'css'
|
||||
import { TextSizableComponent } from 'core/shared'
|
||||
|
||||
export type StyledTextProps = {
|
||||
style?: CSSProperties
|
||||
} & TextSizableComponent & React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
|
||||
|
||||
// Named "StyledText" instead of "Text" to avoid collision with top level React type
|
||||
export const StyledText: React.FC<StyledTextProps> = ({ className, size, lineHeight, children, ...props }) => {
|
||||
return (
|
||||
<span {...props} className={cs(styledTextSizeClassNames(size, lineHeight), className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const styledTextSizeClassNames = (size: TextSize = 'm', lineHeight: LineHeight = 'normal'): string =>
|
||||
`${textSizeToClassName(size)} ${lineHeightToClassName(lineHeight)}`
|
||||
|
||||
export const textSizeToClassName = (size: TextSize): string => `text-${size}`
|
||||
|
||||
const lineHeightToClassName = (lineHeight: LineHeight): string => `line-height-${lineHeight}`
|
||||
@@ -1 +0,0 @@
|
||||
export * from './StyledText'
|
||||
@@ -1,61 +0,0 @@
|
||||
export type Styles = {
|
||||
accent00: string;
|
||||
accent01: string;
|
||||
accent02: string;
|
||||
brand00: string;
|
||||
brand01: string;
|
||||
chill05: string;
|
||||
chill10: string;
|
||||
chill20: string;
|
||||
chill30: string;
|
||||
chill40: string;
|
||||
chill50: string;
|
||||
chill60: string;
|
||||
chill70: string;
|
||||
chill80: string;
|
||||
chill90: string;
|
||||
cran50: string;
|
||||
green05: string;
|
||||
green10: string;
|
||||
green20: string;
|
||||
green30: string;
|
||||
green40: string;
|
||||
green50: string;
|
||||
green60: string;
|
||||
metal00: string;
|
||||
metal05: string;
|
||||
metal10: string;
|
||||
metal100: string;
|
||||
metal20: string;
|
||||
metal30: string;
|
||||
metal40: string;
|
||||
metal50: string;
|
||||
metal60: string;
|
||||
metal70: string;
|
||||
metal80: string;
|
||||
metal90: string;
|
||||
olive05: string;
|
||||
olive10: string;
|
||||
olive20: string;
|
||||
olive30: string;
|
||||
olive40: string;
|
||||
olive50: string;
|
||||
olive60: string;
|
||||
papaya05: string;
|
||||
papaya10: string;
|
||||
papaya20: string;
|
||||
papaya30: string;
|
||||
papaya40: string;
|
||||
papaya50: string;
|
||||
papaya60: string;
|
||||
red40: string;
|
||||
red50: string;
|
||||
red60: string;
|
||||
red70: string;
|
||||
};
|
||||
|
||||
export type ClassNames = keyof Styles;
|
||||
|
||||
declare const styles: Styles;
|
||||
|
||||
export default styles;
|
||||
@@ -1,28 +0,0 @@
|
||||
export type Styles = {
|
||||
"padding-2xl": string;
|
||||
"padding-3xl": string;
|
||||
"padding-4xl": string;
|
||||
"padding-l": string;
|
||||
"padding-m": string;
|
||||
"padding-ml": string;
|
||||
"padding-ms": string;
|
||||
"padding-s": string;
|
||||
"padding-xl": string;
|
||||
"padding-xs": string;
|
||||
"space-2xl": string;
|
||||
"space-3xl": string;
|
||||
"space-4xl": string;
|
||||
"space-l": string;
|
||||
"space-m": string;
|
||||
"space-ml": string;
|
||||
"space-ms": string;
|
||||
"space-s": string;
|
||||
"space-xl": string;
|
||||
"space-xs": string;
|
||||
};
|
||||
|
||||
export type ClassNames = keyof Styles;
|
||||
|
||||
declare const styles: Styles;
|
||||
|
||||
export default styles;
|
||||
@@ -1,26 +0,0 @@
|
||||
export type Styles = {
|
||||
"depth-3": string;
|
||||
"depth-4": string;
|
||||
"depth-6": string;
|
||||
"depth-bordered": string;
|
||||
"depth-flat": string;
|
||||
"depth-float": string;
|
||||
"depth-inset-slight": string;
|
||||
"depth-inset-well": string;
|
||||
"depth-slight": string;
|
||||
"shadow-3": string;
|
||||
"shadow-4": string;
|
||||
"shadow-6": string;
|
||||
"shadow-bordered": string;
|
||||
"shadow-flat": string;
|
||||
"shadow-float": string;
|
||||
"shadow-inset-slight": string;
|
||||
"shadow-inset-well": string;
|
||||
"shadow-slight": string;
|
||||
};
|
||||
|
||||
export type ClassNames = keyof Styles;
|
||||
|
||||
declare const styles: Styles;
|
||||
|
||||
export default styles;
|
||||
@@ -1,23 +0,0 @@
|
||||
export type Styles = {
|
||||
"line-height-condensed": string;
|
||||
"line-height-normal": string;
|
||||
"line-height-tight": string;
|
||||
"text-2xl": string;
|
||||
"text-3xl": string;
|
||||
"text-4xl": string;
|
||||
"text-l": string;
|
||||
"text-m": string;
|
||||
"text-ml": string;
|
||||
"text-mono-m": string;
|
||||
"text-mono-s": string;
|
||||
"text-ms": string;
|
||||
"text-s": string;
|
||||
"text-xl": string;
|
||||
"text-xs": string;
|
||||
};
|
||||
|
||||
export type ClassNames = keyof Styles;
|
||||
|
||||
declare const styles: Styles;
|
||||
|
||||
export default styles;
|
||||
@@ -1,32 +0,0 @@
|
||||
/* eslint-disable */
|
||||
import type colors from './jsColors.scss'
|
||||
import type { ClassNames as JsSpacing } from './jsSpacing.scss'
|
||||
import type { ClassNames as JsTypography } from './jsTypography.scss'
|
||||
import type {ClassNames as JsSurface } from './jsSurfaces.scss'
|
||||
|
||||
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
|
||||
type TwoDigit = `${Digit}${Digit}`
|
||||
|
||||
/**
|
||||
* Retrieve all names in `TString` that are followed by a suffix `TSuffix`
|
||||
*/
|
||||
type ExtractStringBeforeSuffix<TString extends string, TSuffix extends string> = TString extends `${infer S}${TSuffix}` ? S : never
|
||||
|
||||
/**
|
||||
* Retrieve all substrings following the prefix `TPrefix` in `TString`
|
||||
*/
|
||||
type ExtractStringAfterPrefix<TString extends string, TPrefix extends string> = TString extends `${TPrefix}${infer S}` ? S : never
|
||||
|
||||
/**
|
||||
* Convert numbered names resulting from collapsing CSS class names into their original, hyphenated form
|
||||
*/
|
||||
type HyphenateNumberedName<T extends string> = {
|
||||
[Key in ExtractStringBeforeSuffix<T, TwoDigit>]: `${Key}-${ExtractStringAfterPrefix<T, Key>}`
|
||||
}[ExtractStringBeforeSuffix<T, TwoDigit>]
|
||||
|
||||
export type Color = HyphenateNumberedName<keyof typeof colors>
|
||||
export type Spacing = ExtractStringAfterPrefix<JsSpacing, 'space-'>
|
||||
export type SurfaceElevation = ExtractStringAfterPrefix<JsSurface, 'shadow-'>
|
||||
|
||||
export type TextSize = ExtractStringAfterPrefix<JsTypography, 'text-'>
|
||||
export type LineHeight = ExtractStringAfterPrefix<JsTypography, 'line-height-'>
|
||||
@@ -1,37 +0,0 @@
|
||||
import { TextSize } from './types'
|
||||
|
||||
import jsTypography from './jsTypography.scss'
|
||||
|
||||
const sizes: TextSize[] = ['xs', 's', 'ms', 'm', 'ml', 'l', 'xl', '2xl', '3xl', '4xl']
|
||||
const monoSizes: TextSize[] = ['mono-s', 'mono-m']
|
||||
|
||||
/**
|
||||
* Converts a t-shirt typography size into the corresponding REM float
|
||||
*/
|
||||
export const typographySizeFromSize = (size: TextSize): number => {
|
||||
const key = `text-${size}` as const
|
||||
|
||||
return parseFloat(jsTypography[key])
|
||||
}
|
||||
|
||||
export const modifySize = (size: TextSize, numberOfSizes: number): TextSize => {
|
||||
const sizeArray = !size.startsWith('mono') ? sizes : monoSizes
|
||||
|
||||
const index = sizeArray.indexOf(size)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Could not find size ${size}`)
|
||||
}
|
||||
|
||||
if (numberOfSizes > 0 && index + numberOfSizes < sizeArray.length) {
|
||||
return sizeArray[index + numberOfSizes]
|
||||
} else if (numberOfSizes < 0 && index + numberOfSizes > -1) {
|
||||
return sizeArray[index + numberOfSizes]
|
||||
}
|
||||
|
||||
throw new Error(`Cannot add ${numberOfSizes} to size ${size}`)
|
||||
}
|
||||
|
||||
export const paddingClass = (padding: TextSize): string => `padding-${padding}`
|
||||
|
||||
export const focusClass = 'focused'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './derived/types'
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
// declaration.d.ts
|
||||
declare module '*.scss';
|
||||
declare module '*.module.scss';
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { MutableRefObject, RefCallback } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Joins the `externalRef` to receive the same boxed value as `localRef`
|
||||
*
|
||||
* **NOTE:** This will not update values after initial render. This is only sufficient for element refs
|
||||
*/
|
||||
export const useCombinedRefs = <T, >(localRef: MutableRefObject<T>, externalRef: MutableRefObject<T> | RefCallback<T> | null) => {
|
||||
useEffect(() => {
|
||||
if (!externalRef) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof externalRef === 'function') {
|
||||
externalRef(localRef.current)
|
||||
} else {
|
||||
externalRef.current = localRef.current
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [externalRef])
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Wraps the provided `value` in a Concurrent safe ref for consumption in `useEffect` and similar without forcing reruns on value changes
|
||||
*
|
||||
* **CAUTION:** `useCurrent` makes it easy to shoot yourself in the foot, particularly in scenarios involving callbacks. Use sparingly
|
||||
*/
|
||||
export const useCurrent = <T>(value: T) => {
|
||||
const ref = useRef(value)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current = value
|
||||
}, [value])
|
||||
|
||||
return ref
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useState } from 'react'
|
||||
import { useCurrent } from './useCurrent'
|
||||
|
||||
/**
|
||||
* Calculates the height of a DOM element
|
||||
*
|
||||
* **NOTE:** This causes multiple renders of your component to measure
|
||||
*/
|
||||
export const useMeasure = (expectedHeight: number, resizer: (height: number) => void, deps: any[], disable = false) => {
|
||||
const [ref, setRef] = useState<HTMLDivElement | null>(null)
|
||||
const [measuring, setMeasuring] = useState(false)
|
||||
|
||||
const currentRef = useCurrent(ref)
|
||||
const currentMeasuring = useCurrent(measuring)
|
||||
|
||||
const remeasure = useCallback(() => setMeasuring(true), [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (disable) {
|
||||
return
|
||||
}
|
||||
|
||||
// On a new render (prop update), mark the component as ready to measure
|
||||
if (currentMeasuring.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setMeasuring(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// When we are measuring, measure the current DOM and update if necessary
|
||||
if (!measuring || !currentRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const measuredHeight = currentRef.current.getBoundingClientRect().height
|
||||
|
||||
if (measuredHeight !== expectedHeight) {
|
||||
resizer(measuredHeight)
|
||||
}
|
||||
|
||||
setMeasuring(false)
|
||||
}, [measuring, expectedHeight, resizer, currentRef])
|
||||
|
||||
return {
|
||||
ref: currentRef,
|
||||
setRef,
|
||||
remeasure,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Add global CSS to the bundle
|
||||
import './global.scss'
|
||||
|
||||
export * from './components/collapsibleGroup/CollapsibleGroup'
|
||||
|
||||
export * from './components/collapsibleGroup/CollapsibleGroupHeader'
|
||||
|
||||
export * from './components/CypressLogo/CypressLogo'
|
||||
|
||||
export * from './components/fileTree'
|
||||
|
||||
export * from './components/Nav'
|
||||
|
||||
export * from './components/searchInput/SearchInput'
|
||||
|
||||
export * from './components/virtualizedTree'
|
||||
|
||||
export * from './core'
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import cs from 'classnames'
|
||||
|
||||
import styles from './Baseline.module.scss'
|
||||
|
||||
export interface BaselineProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Baseline: React.FC<BaselineProps> = ({ className, children }) => (
|
||||
<div className={cs(styles.baseline, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Story } from '@storybook/react'
|
||||
|
||||
import { createStory, createStorybookConfig } from './util'
|
||||
|
||||
import styles from './colors.module.scss'
|
||||
import colors from 'css/derived/jsColors.scss'
|
||||
import '../index.scss'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'System/Colors',
|
||||
})
|
||||
|
||||
const Color: React.FC<{
|
||||
name: string
|
||||
}> = ({ name }) => {
|
||||
const match = name.match(/([A-Z]+)([0-9]+)/i)
|
||||
|
||||
if (!match) {
|
||||
return (
|
||||
<div>
|
||||
{`Could not parse color ${name}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Group colors based on type
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [, type, number] = match
|
||||
|
||||
let textColor = parseInt(number, 10) < 50 ? '--metal-90' : '--metal-10'
|
||||
|
||||
if (name === 'brand01') {
|
||||
// Override secondary brand
|
||||
textColor = '--metal-10'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.colorBlock} style={{ backgroundColor: `var(--${type}-${number})`, color: `var(${textColor})` }}>
|
||||
{name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Template: Story = () => (
|
||||
<div>
|
||||
{Object.keys(colors).map((name: string) => <Color key={name} name={name} />)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Colors = createStory(Template)
|
||||
@@ -1,51 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Story } from '@storybook/react'
|
||||
|
||||
import { createStory, createStorybookConfig } from './util'
|
||||
|
||||
import styles from './spacing.module.scss'
|
||||
import spacing from 'css/derived/jsSpacing.scss'
|
||||
import '../index.scss'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'System/Spacing',
|
||||
})
|
||||
|
||||
const currentFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
|
||||
const pixelsFromRem = (rem: string) => {
|
||||
const remFloat = parseFloat(rem.replace('rem', ''))
|
||||
|
||||
return remFloat * currentFontSize()
|
||||
}
|
||||
|
||||
const Cube: React.FC<{
|
||||
name: string
|
||||
}> = ({ name }) => {
|
||||
const size: string = spacing[name as keyof typeof spacing]
|
||||
const pixelSize = pixelsFromRem(size)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{`${name}: ${size} (${pixelSize}px @ ${currentFontSize()})`}
|
||||
</div>
|
||||
<div
|
||||
className={styles.cube}
|
||||
style={{
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Template: Story = () => (
|
||||
<div>
|
||||
{Object.keys(spacing).map((name) => <Cube key={name} name={name} />)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Spacing = createStory(Template)
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Story } from '@storybook/react'
|
||||
|
||||
import { createStory, createStorybookConfig } from './util'
|
||||
|
||||
import styles from './surfaces.module.scss'
|
||||
import surfaces from 'css/derived/jsSurfaces.scss'
|
||||
import '../index.scss'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'System/Surfaces',
|
||||
})
|
||||
|
||||
const Surface: React.FC<{
|
||||
className: string
|
||||
}> = ({ className }) => {
|
||||
const level = className.replace('shadow-', '')
|
||||
|
||||
return (
|
||||
<div className={`${styles.surface} ${`depth-${level}`}`}>
|
||||
{`Level ${level}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Template: Story = () => (
|
||||
<div>
|
||||
{Object.keys(surfaces).map((className) => <Surface key={className} className={className} />)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Surfaces = createStory(Template)
|
||||
@@ -1,35 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Story } from '@storybook/react'
|
||||
|
||||
import { createStory, createStorybookConfig } from './util'
|
||||
|
||||
import typography from 'css/derived/jsTypography.scss'
|
||||
import '../index.scss'
|
||||
|
||||
export default createStorybookConfig({
|
||||
title: 'System/Typography',
|
||||
})
|
||||
|
||||
const Template: Story = () => (
|
||||
<div>
|
||||
{Object.keys(typography).filter((key) => key !== 'type').map((key) => {
|
||||
const size = key.replace('text-', '')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
marginBottom: '2em',
|
||||
}}
|
||||
>
|
||||
<div className="text-mono-m">
|
||||
{size}
|
||||
</div>
|
||||
<div className={key}>The five boxing wizards jump quickly</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Typography = createStory(Template)
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Story, Meta } from '@storybook/react'
|
||||
|
||||
/**
|
||||
* Passthrough config creator for typing without casting
|
||||
*/
|
||||
export const createStorybookConfig = (config: Meta): Meta => config
|
||||
|
||||
/**
|
||||
* Compact way of declaring a new story
|
||||
*/
|
||||
export const createStory = <T = {}>(template: Story<T>, args?: Partial<T>) => {
|
||||
const story = template.bind({})
|
||||
|
||||
story.args = args
|
||||
|
||||
return story
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user