Merge branch 'develop' into b45fd0e8a-master-into-develop

This commit is contained in:
ElevateBart
2021-04-22 09:48:40 -05:00
177 changed files with 7623 additions and 3011 deletions
+4 -4
View File
@@ -290,10 +290,7 @@ temp/
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
#Thumbnails
._*
# Files that might appear in the root of a volume
@@ -340,3 +337,6 @@ $RECYCLE.BIN/
/npm/react/bin/*
# End of https://www.gitignore.io/api/osx,git,node,windows,intellij,linux
# Circle cache artifacts
globbed_node_modules
+16 -12
View File
@@ -126,7 +126,10 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress
```
- Test the new version of Cypress against the Cypress dashboard repo.
7. Deploy the release-specific documentation and changelog in [cypress-documentation](https://github.com/cypress-io/cypress-documentation).
7. Confirm that every issue labeled [stage: pending release](https://github.com/cypress-io/cypress/issues?q=label%3A%22stage%3A+pending+release%22+is%3Aclosed) has a ZenHub release set. **Tip:** there is a command in [`release-automations`](https://github.com/cypress-io/release-automations)'s `issues-in-release` tool to list and check such issues. Without a ZenHub release issues will not be included in the right changelog.
8. Deploy the release-specific documentation and changelog in [cypress-documentation](https://github.com/cypress-io/cypress-documentation).
- If there is not already a release-specific PR open, create one. You can use [`release-automations`](https://github.com/cypress-io/release-automations)'s `issues-in-release` tool to generate a starting point for the changelog, based off of ZenHub:
```
cd packages/issues-in-release
@@ -136,26 +139,26 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress
- Merge any release-specific documentation changes into the main release PR.
- Merging this PR into `develop` will deploy to `docs-staging` and then a PR will be automatically created against `master`. It will be automatically merged after it passes and will deploy to production.
8. Make the new npm version the "latest" version by updating the dist-tag `latest` to point to the new version:
9. Make the new npm version the "latest" version by updating the dist-tag `latest` to point to the new version:
```shell
npm dist-tag add cypress@X.Y.Z
```
9. Run `binary-release` to update the [download server's manifest](https://download.cypress.io/desktop.json):
10. Run `binary-release` to update the [download server's manifest](https://download.cypress.io/desktop.json):
```shell
yarn run binary-release --version X.Y.Z
```
10. If needed, push out any updated changes to the links manifest to [`on.cypress.io`](https://github.com/cypress-io/cypress-services/tree/develop/packages/on).
11. If needed, push out any updated changes to the links manifest to [`on.cypress.io`](https://github.com/cypress-io/cypress-services/tree/develop/packages/on).
11. If needed, deploy the updated [`cypress-example-kitchensink`][cypress-example-kitchensink] to `example.cypress.io` by following [these instructions under "Deployment"](./packages/example/README.md).
12. If needed, deploy the updated [`cypress-example-kitchensink`][cypress-example-kitchensink] to `example.cypress.io` by following [these instructions under "Deployment"](./packages/example/README.md).
12. Update the releases in [ZenHub](https://app.zenhub.com/workspaces/test-runner-5c3ea3baeb1e75374f7b0708/reports/release):
13. Update the releases in [ZenHub](https://app.zenhub.com/workspaces/test-runner-5c3ea3baeb1e75374f7b0708/reports/release):
- Close the current release in ZenHub.
- Create a new patch release (and a new minor release, if this is a minor release) in ZenHub, and schedule them both to be completed 2 weeks from the current date.
- Move all issues that are still open from the current release to the appropriate future release.
13. Bump `version` in [`package.json`](package.json), commit it to `develop`, tag it with the version, and push the tag up:
14. Bump `version` in [`package.json`](package.json), commit it to `develop`, tag it with the version, and push the tag up:
```shell
git commit -am "release X.Y.Z [skip ci]"
git log --pretty=oneline
@@ -163,7 +166,7 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress
git tag -a vX.Y.Z <sha>
git push origin vX.Y.Z
```
14. Merge `develop` into `master` and push both branches up. Note: pushing to `master` will automatically publish any independent npm packages that have not yet been published.
15. Merge `develop` into `master` and push both branches up. Note: pushing to `master` will automatically publish any independent npm packages that have not yet been published.
```shell
git push origin develop
git checkout master
@@ -171,7 +174,7 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress
git push origin master
```
15. Inside of [cypress-io/release-automations][release-automations]:
16. Inside of [cypress-io/release-automations][release-automations]:
- Publish GitHub release to [cypress-io/cypress/releases](https://github.com/cypress-io/cypress/releases) using package `set-releases`:
```shell
cd packages/set-releases && npm run release-log -- --version X.Y.Z
@@ -180,10 +183,11 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress
```shell
cd packages/issues-in-release && npm run do:comment -- --release X.Y.Z
```
- Confirm there are no issues with the label [stage: pending release](https://github.com/cypress-io/cypress/issues?q=label%3A%22stage%3A+pending+release%22+is%3Aclosed) left
16. Publish a new docker image in [`cypress-docker-images`](https://github.com/cypress-io/cypress-docker-images) under `included` for the new cypress version.
17. Publish a new docker image in [`cypress-docker-images`](https://github.com/cypress-io/cypress-docker-images) under `included` for the new cypress version. Note: we use the base image with the Node version matching the bundled Node version.
17. Update example projects to the new version. For most projects, you can go to the Renovate dependency issue and check the box next to `Update dependency cypress to X.Y.Z`. It will automatically create a PR. Once it passes, you can merge it. Try updating at least the following projects:
18. Update example projects to the new version. For most projects, you can go to the Renovate dependency issue and check the box next to `Update dependency cypress to X.Y.Z`. It will automatically create a PR. Once it passes, you can merge it. Try updating at least the following projects:
- [cypress-example-todomvc](https://github.com/cypress-io/cypress-example-todomvc/issues/99)
- [cypress-example-todomvc-redux](https://github.com/cypress-io/cypress-example-todomvc-redux/issues/1)
- [cypress-example-realworld](https://github.com/cypress-io/cypress-example-realworld/issues/2)
@@ -195,7 +199,7 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress
- [cypress-documentation](https://github.com/cypress-io/cypress-documentation/issues/1313)
- [cypress-example-docker-compose](https://github.com/cypress-io/cypress-example-docker-compose) - Doesn't have a Renovate issue, but will auto-create and auto-merge non-major Cypress updates as long as the tests pass.
18. Check if any test or example repositories have a branch for testing the features or fixes from the newly published version `x.y.z`. The branch should also be named `x.y.z`. Check all `cypress-test-*` and `cypress-example-*` repositories, and if there is a branch named `x.y.z`, merge it into `master`.
19. Check if any test or example repositories have a branch for testing the features or fixes from the newly published version `x.y.z`. The branch should also be named `x.y.z`. Check all `cypress-test-*` and `cypress-example-*` repositories, and if there is a branch named `x.y.z`, merge it into `master`.
**Test Repos**
+290 -213
View File
@@ -18,6 +18,13 @@ defaults: &defaults
executor:
type: executor
default: cy-doc
is-mac:
type: boolean
default: false
arch:
type: enum
default: 'linux'
enum: ['linux', 'darwin']
executor: <<parameters.executor>>
environment:
## set specific timezone
@@ -31,7 +38,7 @@ defaults: &defaults
LINES: 24
# filters and requires for testing binary with Firefox
testBinaryFirefox: &testBinaryFirefox
onlyMainBranches: &onlyMainBranches
filters:
branches:
only:
@@ -74,6 +81,187 @@ executors:
PLATFORM: mac
commands:
restore_workspace_binaries:
steps:
- attach_workspace:
at: ~/
# make sure we have cypress.zip received
- run: ls -l
- run: ls -l cypress.zip cypress.tgz
- run: node --version
- run: npm --version
restore_cached_workspace:
steps:
- attach_workspace:
at: ~/
- unpack-dependencies
prepare-modules-cache:
parameters:
dont-move:
type: boolean
default: false
steps:
- run: node scripts/circle-cache.js --action prepare
- unless:
condition: << parameters.dont-move >>
steps:
- run:
name: Move to /tmp dir for consistent caching across root/non-root users
command: |
mkdir -p /tmp/node_modules_cache
mv ~/cypress/node_modules /tmp/node_modules_cache/root_node_modules
mv ~/cypress/cli/node_modules /tmp/node_modules_cache/cli_node_modules
mv ~/cypress/globbed_node_modules /tmp/node_modules_cache/globbed_node_modules
build-and-persist:
description: Save entire folder as artifact for other jobs to run without reinstalling
steps:
- run:
name: Build packages
command: yarn build
- prepare-modules-cache # So we don't throw these in the workspace cache
- persist_to_workspace:
root: ~/
paths:
- cypress
- .ssh
- node_modules # contains the npm i -g modules
unpack-dependencies:
description: 'Unpacks dependencies associated with the current workflow'
steps:
- run:
name: Generate Circle Cache Key
command: node scripts/circle-cache.js --action cacheKey > circle_cache_key
- restore_cache:
key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-{{ checksum "circle_cache_key" }}
- run:
name: Move node_modules back from /tmp
command: |
if [[ -d "/tmp/node_modules_cache" ]]; then
mv /tmp/node_modules_cache/root_node_modules ~/cypress/node_modules
mv /tmp/node_modules_cache/cli_node_modules ~/cypress/cli/node_modules
mv /tmp/node_modules_cache/globbed_node_modules ~/cypress/globbed_node_modules
rm -rf /tmp/node_modules_cache
fi
- run:
name: Restore all node_modules to proper workspace folders
command: node scripts/circle-cache.js --action unpack
caching-dependency-installer:
description: 'Installs & caches the dependencies based on yarn lock & package json dependencies'
parameters:
is-mac:
type: boolean
default: false
arch:
type: enum
enum: ['linux', 'darwin']
steps:
- run:
name: Generate Circle Cache Key
command: node scripts/circle-cache.js --action cacheKey > circle_cache_key
- restore_cache:
name: Restore cache state, to check for known modules cache existence
key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-state-{{ checksum "circle_cache_key" }}
- run:
name: Bail if cache exists
command: |
if [[ -f "node_modules_installed" ]]; then
echo "Node modules already cached for dependencies, exiting"
circleci-agent step halt
fi
- run: date +%Y-%U > cache_date
- restore_cache:
name: Restore weekly yarn cache
keys:
- v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-deps-root-weekly-{{ checksum "cache_date" }}
- run:
name: Install Node Modules
command: |
. ./scripts/load-nvm.sh
yarn --prefer-offline --frozen-lockfile --cache-folder ~/.yarn-<< parameters.arch >>
no_output_timeout: 20m
- prepare-modules-cache:
dont-move: <<parameters.is-mac>> # we don't move, so we don't hit any issues unpacking symlinks
- when:
condition: <<parameters.is-mac>> # on mac, we don't move to /tmp since we don't need to worry about different users
steps:
- save_cache:
name: Saving node modules for root, cli, and all globbed workspace packages
key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-{{ checksum "circle_cache_key" }}
paths:
- node_modules
- cli/node_modules
- globbed_node_modules
- unless:
condition: <<parameters.is-mac>>
steps:
- save_cache:
name: Saving node modules for root, cli, and all globbed workspace packages
key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-{{ checksum "circle_cache_key" }}
paths:
- /tmp/node_modules_cache
- run: touch node_modules_installed
- save_cache:
name: Saving node-modules cache state key
key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-state-{{ checksum "circle_cache_key" }}
paths:
- node_modules_installed
- save_cache:
name: Save weekly yarn cache
key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-deps-root-weekly-{{ checksum "cache_date" }}
paths:
- ~/.yarn-<< parameters.arch >>
install-build-setup:
description: Common commands run when setting up for build or yarn install
steps:
- run:
name: Print working folder
command: echo $PWD
- run:
name: print global yarn cache path
command: echo $(yarn global bin)
- run:
name: print Node version
command: |
. ./scripts/load-nvm.sh
echo "nvm use default"
nvm use default
node -v
- run:
name: print yarn version
command: yarn -v
- run:
name: check Node version
command: |
. ./scripts/load-nvm.sh
yarn check-node-version
## make sure the TERM is set to 'xterm' in node (Linux only)
## else colors (and tests) will fail
## See the following information
## * http://andykdocs.de/development/Docker/Fixing+the+Docker+TERM+variable+issue
## * https://unix.stackexchange.com/questions/43945/whats-the-difference-between-various-term-variables
- run:
name: Check terminal
command: |
. ./scripts/load-nvm.sh
yarn check-terminal
- run:
name: Stop .only
command: |
. ./scripts/load-nvm.sh
yarn stop-only-all
- run:
# Deps needed by circle-cache.js, before we "yarn" or unpack cached node_modules
name: Cache Helper Deps
working_directory: ~/
command: npm i globby fs-extra minimist fast-json-stable-stringify
install-required-node:
# https://discuss.circleci.com/t/switch-nodejs-version-on-machine-executor-solved/26675/2
description: Install Node version matching .node-version
@@ -126,8 +314,7 @@ commands:
description: browser shortname to target
type: string
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
environment:
CYPRESS_KONFIG_ENV: production
@@ -167,8 +354,7 @@ commands:
type: boolean
default: false
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
command: |
cmd=$([[ <<parameters.percy>> == 'true' ]] && echo 'yarn percy exec --') || true
@@ -194,8 +380,7 @@ commands:
type: boolean
default: false
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
command: |
cmd=$([[ <<parameters.percy>> == 'true' ]] && echo 'yarn percy exec --') || true
@@ -223,8 +408,7 @@ commands:
description: browser shortname to target
type: string
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
command: yarn workspace @packages/server test ./test/e2e/$(( $CIRCLE_NODE_INDEX ))_*spec* --browser <<parameters.browser>>
- verify-mocha-results
@@ -273,8 +457,7 @@ commands:
description: "Name of the github repo to clone like: cypress-example-kitchensink"
type: string
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: "Cloning test project: <<parameters.repo>>"
command: |
@@ -321,11 +504,6 @@ commands:
type: string
default: "CI=true yarn start"
steps:
- attach_workspace:
at: ~/
# make sure the binary and NPM package files are present
- run: ls -l
- run: ls -l cypress.zip cypress.tgz
- clone-repo-and-checkout-release-branch:
repo: <<parameters.repo>>
- when:
@@ -456,11 +634,6 @@ commands:
type: string
default: "npm start --if-present"
steps:
- attach_workspace:
at: ~/
# make sure the binary and NPM package files are present
- run: ls -l
- run: ls -l cypress.zip cypress.tgz
- clone-repo-and-checkout-release-branch:
repo: <<parameters.repo>>
- when:
@@ -474,16 +647,16 @@ commands:
git checkout pr-<<parameters.pull_request_id>>
git log -n 2
- run:
# Install with yarn if yarn.lock present
# Install deps + Cypress binary with yarn if yarn.lock present
command: |
[[ -f yarn.lock ]] && yarn || npm install
if [[ -f yarn.lock ]]; then
yarn --frozen-lockfile
CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip yarn add -D ~/cypress/cypress.tgz
else
npm install
CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm install ~/cypress/cypress.tgz
fi
working_directory: /tmp/<<parameters.repo>>
- run:
name: Install Cypress
working_directory: /tmp/<<parameters.repo>>
# force installing the freshly built binary
command: |
CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i ~/cypress/cypress.tgz
- run:
name: Print Cypress version
working_directory: /tmp/<<parameters.repo>>
@@ -685,90 +858,37 @@ commands:
- cypress/npm-package-url.json
jobs:
## code checkout and yarn installs
## Checks if we already have a valid cache for the node_modules_install and if it has,
## skips ahead to the build step, otherwise installs and caches the node_modules
node_modules_install:
<<: *defaults
steps:
- checkout
- install-required-node
- install-build-setup
- caching-dependency-installer:
arch: << parameters.arch >>
is-mac: <<parameters.is-mac>>
- store-npm-logs
## restores node_modules from previous step & builds if first step skipped
build:
<<: *defaults
steps:
- checkout
- install-required-node
- run:
name: Print working folder
command: echo $PWD
- run:
name: print global yarn cache path
command: echo $(yarn global bin)
- run:
name: print Node version
command: |
. ./scripts/load-nvm.sh
echo "nvm use default"
nvm use default
node -v
- run:
name: print yarn version
command: yarn -v
- run:
name: check Node version
command: |
. ./scripts/load-nvm.sh
yarn check-node-version
## make sure the TERM is set to 'xterm' in node (Linux only)
## else colors (and tests) will fail
## See the following information
## * http://andykdocs.de/development/Docker/Fixing+the+Docker+TERM+variable+issue
## * https://unix.stackexchange.com/questions/43945/whats-the-difference-between-various-term-variables
- run:
name: Check terminal
command: |
. ./scripts/load-nvm.sh
yarn check-terminal
- run:
name: Stop .only
command: |
. ./scripts/load-nvm.sh
yarn stop-only-all
- restore_cache:
name: Restore yarn cache
key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-deps-root-{{ checksum "yarn.lock" }}
# show what is already cached globally
- run: ls $(yarn global bin)
- run: ls $(yarn global bin)/../lib/node_modules
# try several times, because flaky NPM installs ...
- run:
name: install and build
command: |
. ./scripts/load-nvm.sh
yarn --frozen-lockfile || yarn --frozen-lockfile
yarn build-prod
- install-build-setup
- unpack-dependencies
- run:
name: Top level packages
command: yarn list --depth=0 || true
- build-and-persist
- store-npm-logs
- save_cache:
name: Save yarn cache
key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-deps-root-{{ checksum "yarn.lock" }}
paths:
- ~/.cache
## save entire folder as artifact for other jobs to run without reinstalling
- persist_to_workspace:
root: ~/
paths:
- cypress
- .ssh
lint:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- install-required-node
## this will catch ".only"s in js/coffee as well
- run:
@@ -791,8 +911,7 @@ jobs:
required_env_var:
type: env_var_name
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
# if this is an external pull request, the environment variables
# are NOT set for security reasons, thus no need to poll -
@@ -819,8 +938,7 @@ jobs:
<<: *defaults
parallelism: 8
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run: mkdir -p cli/visual-snapshots
- run:
command: node cli/bin/cypress info --dev | yarn --silent term-to-html | node scripts/sanitize --type cli-info > cli/visual-snapshots/cypress-info.html
@@ -843,8 +961,7 @@ jobs:
<<: *defaults
parallelism: 1
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
# make sure mocha runs
- run: yarn test-mocha
# test binary build code
@@ -870,8 +987,7 @@ jobs:
<<: *defaults
parallelism: 1
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
command: ls -la types
working_directory: cli
@@ -890,8 +1006,7 @@ jobs:
<<: *defaults
parallelism: 1
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run: yarn test-unit --scope @packages/server
- verify-mocha-results:
expectedResultCount: 1
@@ -903,8 +1018,7 @@ jobs:
<<: *defaults
parallelism: 1
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run: yarn test-unit --scope @packages/server-ct
- verify-mocha-results:
expectedResultCount: 1
@@ -916,8 +1030,7 @@ jobs:
<<: *defaults
parallelism: 1
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run: yarn test-integration --scope @packages/server
- verify-mocha-results:
expectedResultCount: 1
@@ -928,8 +1041,7 @@ jobs:
server-performance-tests:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
command: yarn workspace @packages/server test-performance
- verify-mocha-results:
@@ -964,8 +1076,7 @@ jobs:
server-e2e-tests-non-root:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
command: yarn workspace @packages/server test ./test/e2e/non_root*spec* --browser electron
- verify-mocha-results
@@ -1016,8 +1127,7 @@ jobs:
<<: *defaults
parallelism: 2
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
command: yarn build-prod
working_directory: packages/desktop-gui
@@ -1042,8 +1152,7 @@ jobs:
<<: *defaults
parallelism: 1
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
# will use PERCY_TOKEN environment variable if available
command: |
@@ -1060,8 +1169,7 @@ jobs:
reporter-integration-tests:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
command: yarn build-for-tests
working_directory: packages/reporter
@@ -1084,8 +1192,7 @@ jobs:
ui-components-integration-tests:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
command: yarn build-for-tests
working_directory: packages/ui-components
@@ -1105,8 +1212,7 @@ jobs:
npm-webpack-preprocessor:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Build
command: yarn workspace @cypress/webpack-preprocessor build
@@ -1143,16 +1249,14 @@ jobs:
npm-webpack-dev-server:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Run tests
command: yarn workspace @cypress/webpack-dev-server test
npm-vite-dev-server:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Run tests
command: yarn test --reporter cypress-circleci-reporter --reporter-options resultsDir=./test_results
@@ -1166,8 +1270,7 @@ jobs:
npm-rollup-dev-server:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Run tests
command: yarn workspace @cypress/rollup-dev-server test
@@ -1175,8 +1278,7 @@ jobs:
npm-webpack-batteries-included-preprocessor:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Run tests
command: yarn workspace @cypress/webpack-batteries-included-preprocessor test
@@ -1185,8 +1287,7 @@ jobs:
<<: *defaults
parallelism: 3
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Build
command: yarn workspace @cypress/vue build
@@ -1203,15 +1304,14 @@ jobs:
npm-design-system:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Build
command: yarn workspace @cypress/design-system build
- run:
name: Run tests
command: yarn test
working_directory: npm/design-system
# - run:
# name: Run tests
# command: yarn test
# working_directory: npm/design-system
- store-npm-logs
@@ -1219,8 +1319,7 @@ jobs:
<<: *defaults
parallelism: 8
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- restore_cache:
name: Restore yarn cache
key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-npm-react-babel-cache
@@ -1240,8 +1339,7 @@ jobs:
npm-mount-utils:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Build
command: yarn workspace @cypress/mount-utils build
@@ -1250,8 +1348,7 @@ jobs:
npm-create-cypress-tests:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run: yarn workspace create-cypress-tests build
- run:
name: Run unit test
@@ -1260,8 +1357,7 @@ jobs:
npm-eslint-plugin-dev:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Run tests
command: yarn workspace @cypress/eslint-plugin-dev test
@@ -1269,8 +1365,7 @@ jobs:
npm-release:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- run:
name: Release packages after all jobs pass
command: yarn npm-release
@@ -1279,8 +1374,7 @@ jobs:
<<: *defaults
shell: /bin/bash --login
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
- build-binary
- build-npm-package
- run:
@@ -1362,8 +1456,7 @@ jobs:
<<: *defaults
steps:
# needs uploaded NPM and test binary
- attach_workspace:
at: ~/
- restore_cached_workspace
- run: ls -la
# make sure JSON files with uploaded urls are present
- run: ls -la binary-url.json npm-package-url.json
@@ -1398,8 +1491,7 @@ jobs:
"test-npm-module-and-verify-binary":
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_cached_workspace
# make sure we have cypress.zip received
- run: ls -l
- run: ls -l cypress.zip cypress.tgz
@@ -1437,14 +1529,8 @@ jobs:
docker:
- image: cypress/base:12.0.0-libgbm
steps:
- attach_workspace:
at: ~/
# make sure we have cypress.zip received
- run: ls -l
- run: ls -l cypress.zip cypress.tgz
- restore_workspace_binaries
- run: mkdir test-binary
- run: node --version
- run: npm --version
- run:
name: Create new NPM package
working_directory: test-binary
@@ -1478,14 +1564,8 @@ jobs:
default: /root/test-cypress-and-jest
<<: *defaults
steps:
- attach_workspace:
at: ~/
# make sure we have cypress.zip received
- run: ls -l
- run: ls -l cypress.zip cypress.tgz
- restore_workspace_binaries
- run: mkdir <<parameters.wd>>
- run: node --version
- run: npm --version
- run:
name: Create new NPM package ⚗️
working_directory: <<parameters.wd>>
@@ -1521,14 +1601,8 @@ jobs:
default: /root/test-scaffold
<<: *defaults
steps:
- attach_workspace:
at: ~/
# make sure we have cypress.zip received
- run: ls -l
- run: ls -l cypress.zip cypress.tgz
- restore_workspace_binaries
- run: mkdir <<parameters.wd>>
- run: node --version
- run: npm --version
- run:
name: Create new NPM package ⚗️
working_directory: <<parameters.wd>>
@@ -1564,14 +1638,8 @@ jobs:
default: /root/test-full-typescript
<<: *defaults
steps:
- attach_workspace:
at: ~/
# make sure we have cypress.zip received
- run: ls -l
- run: ls -l cypress.zip cypress.tgz
- restore_workspace_binaries
- run: mkdir <<parameters.wd>>
- run: node --version
- run: npm --version
- run:
name: Create new NPM package ⚗️
working_directory: <<parameters.wd>>
@@ -1596,11 +1664,7 @@ jobs:
"test-binary-against-staging":
<<: *defaults
steps:
- attach_workspace:
at: ~/
- run: ls -l
# make sure we have the binary and NPM package
- run: ls -l cypress.zip cypress.tgz
- restore_workspace_binaries
- clone-repo-and-checkout-release-branch:
repo: cypress-test-tiny
- run:
@@ -1687,15 +1751,16 @@ jobs:
- test-binary-against-repo:
repo: cypress-documentation
browser: firefox
command: "npm run cypress:run"
wait-on: http://localhost:2222
command: "yarn cypress run"
wait-on: http://localhost:3000
server-start-command: yarn serve:dist
"test-binary-against-realworld-firefox":
"test-binary-against-conduit-chrome":
<<: *defaults
steps:
- test-binary-against-repo:
repo: cypress-example-realworld
browser: firefox
repo: cypress-example-conduit-app
browser: chrome
command: "npm run cypress:run"
"test-binary-against-api-testing-firefox":
@@ -1726,8 +1791,7 @@ jobs:
test-binary-as-specific-user:
<<: *defaults
steps:
- attach_workspace:
at: ~/
- restore_workspace_binaries
# the user should be "node"
- run: whoami
- run: pwd
@@ -1773,7 +1837,11 @@ jobs:
linux-workflow: &linux-workflow
jobs:
- build
- node_modules_install:
arch: 'linux'
- build:
requires:
- node_modules_install
- lint:
name: Linux lint
requires:
@@ -1887,7 +1955,7 @@ linux-workflow: &linux-workflow
# Any attempts to automate this are welcome
# If CircleCI provided an "after all" hook, then this wouldn't be necessary
- npm-release:
requires:
requires:
- build
- npm-eslint-plugin-dev
- npm-create-cypress-tests
@@ -2011,22 +2079,22 @@ linux-workflow: &linux-workflow
requires:
- create-build-artifacts
- test-binary-against-recipes-firefox:
<<: *testBinaryFirefox
- test-binary-against-kitchensink-firefox:
<<: *testBinaryFirefox
- test-binary-against-kitchensink-chrome:
<<: *testBinaryFirefox
<<: *onlyMainBranches
- test-binary-against-conduit-chrome:
<<: *onlyMainBranches
- test-binary-against-recipes-firefox:
<<: *onlyMainBranches
- test-binary-against-kitchensink-firefox:
<<: *onlyMainBranches
- test-binary-against-todomvc-firefox:
<<: *testBinaryFirefox
<<: *onlyMainBranches
- test-binary-against-documentation-firefox:
<<: *testBinaryFirefox
<<: *onlyMainBranches
- test-binary-against-api-testing-firefox:
<<: *testBinaryFirefox
- test-binary-against-realworld-firefox:
<<: *testBinaryFirefox
<<: *onlyMainBranches
- test-binary-against-piechopper-firefox:
<<: *testBinaryFirefox
<<: *onlyMainBranches
- test-binary-against-cypress-realworld-app:
executor: cy-doc-plus
filters:
@@ -2049,9 +2117,18 @@ linux-workflow: &linux-workflow
mac-workflow: &mac-workflow
jobs:
- node_modules_install:
name: darwin-node-modules-install
executor: mac
arch: darwin
is-mac: true
<<: *macBuildFilters
- build:
name: darwin-build
executor: mac
requires:
- darwin-node-modules-install
<<: *macBuildFilters
- lint:
+16 -1
View File
@@ -392,7 +392,7 @@ declare namespace Cypress {
Cookies: {
debug(enabled: boolean, options?: Partial<DebugOptions>): void
preserveOnce(...names: string[]): void
defaults(options: Partial<CookieDefaults>): void
defaults(options: Partial<CookieDefaults>): CookieDefaults
}
/**
@@ -498,6 +498,14 @@ declare namespace Cypress {
defaults(options: Partial<ScreenshotDefaultsOptions>): void
}
/**
* @see https://on.cypress.io/selector-playground-api
*/
SelectorPlayground: {
defaults(options: Partial<SelectorPlaygroundDefaultsOptions>): void
getSelector($el: JQuery): JQuery.Selector
}
/**
* These events come from Cypress as it issues commands and reacts to their state. These are all useful to listen to for debugging purposes.
* @see https://on.cypress.io/catalog-of-events#App-Events
@@ -2882,6 +2890,11 @@ declare namespace Cypress {
screenshotOnRunFailure: boolean
}
interface SelectorPlaygroundDefaultsOptions {
selectorPriority: string[]
onElement: ($el: JQuery) => string | null | undefined
}
interface ScrollToOptions extends Loggable, Timeoutable {
/**
* Scrolls over the duration (in ms)
@@ -5458,6 +5471,8 @@ declare namespace Cypress {
interface LogConfig extends Timeoutable {
/** The JQuery element for the command. This will highlight the command in the main window when debugging */
$el: JQuery
/** The scope of the log entry. If child, will appear nested below parents, prefixed with '-' */
type: 'parent' | 'child'
/** Allows the name of the command to be overwritten */
name: string
/** Override *name* for display purposes only */
+1 -1
View File
@@ -6,7 +6,7 @@ Installs and injects all the required configuration to run cypress tests.
```
cd my-app
npx create-cypress-test
npx create-cypress-tests
npx cypress open
```
+7
View File
@@ -16,6 +16,13 @@
"cypress/globals": true
},
"rules": {
"no-duplicate-imports": "off",
"no-else-return": [
"error",
{
"allowElseIf": true
}
],
"react/display-name": "off",
"react/function-component-definition": [
"error",
+63
View File
@@ -0,0 +1,63 @@
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
},
}
+5
View File
@@ -0,0 +1,5 @@
import '../src/global.scss'
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
}
+2 -2
View File
@@ -35,10 +35,10 @@ SCSS usage:
```scss
// scoped within the *.scss file
@use '@cypress/design-system/src/index.scss' as *;
@use '@cypress/design-system' as *;
// import variables and mixins throughout the whole project
// or @import('@cypress/design-system/src/index.scss');
// or @import('@cypress/design-system');
.my-component {
text-color: $accent-color-01;
+4
View File
@@ -0,0 +1,4 @@
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,2 +1,11 @@
import 'regenerator-runtime/runtime'
import 'cypress-real-events/support'
// 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)
+23 -8
View File
@@ -6,10 +6,13 @@
"scripts": {
"build": "rimraf dist && yarn 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-ct --project ${PWD}",
"cy:open:debug": "node --inspect-brk ../../scripts/start.js --component-testing --project ${PWD}",
"cy:run": "node ../../scripts/cypress.js run-ct --project ${PWD}",
"cy:run:debug": "node --inspect-brk ../../scripts/start.js --component-testing --run-project ${PWD}",
"storybook": "start-storybook -p 6006",
"pretest": "yarn transpile",
"test": "yarn cy:run",
"transpile": "tsc",
@@ -22,7 +25,8 @@
"@fortawesome/free-solid-svg-icons": "5.15.2",
"@fortawesome/react-fontawesome": "0.1.14",
"classnames": "2.2.6",
"debug": "4.3.2"
"debug": "4.3.2",
"react-aria": "^3.5.0"
},
"devDependencies": {
"@babel/core": "7.4.5",
@@ -33,29 +37,39 @@
"@cypress/react": "0.0.0-development",
"@cypress/vite-dev-server": "0.0.0-development",
"@percy/cypress": "2.3.2",
"@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",
"@types/node": "14.14.31",
"@types/semver": "7.3.4",
"babel-loader": "8.0.6",
"css-loader": "2.1.1",
"css-loader": "^5.1.3",
"cypress": "0.0.0-development",
"cypress-real-events": "1.1.0",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0",
"postcss": "^8.2.8",
"react": "16.8.6",
"react-dom": "16.8.6",
"rollup": "^2.38.5",
"rollup-plugin-copy-assets": "2.0.3",
"rollup-plugin-copy": "^3.4.0",
"rollup-plugin-peer-deps-external": "2.2.4",
"rollup-plugin-postcss-modules": "2.0.2",
"rollup-plugin-typescript2": "^0.29.0",
"rollup-plugin-postcss": "^4.0.0",
"sass": "1.32.8",
"sass-loader": "10.1.1",
"style-loader": "0.23.1",
"style-loader": "^2.0.0",
"svg-url-loader": "3.0.3",
"tsconfig-paths-webpack-plugin": "^3.5.1",
"typed-scss-modules": "^4.1.1",
"typescript": "^4.2.3",
"vite": "2.1.3",
"webpack": "4.44.1"
@@ -74,7 +88,7 @@
"type": "git",
"url": "https://github.com/cypress-io/cypress.git"
},
"author": "Jessica Sachs <jess@jessicasachs.io>",
"author": "Cypress.io",
"keywords": [
"design-system",
"cypress",
@@ -82,6 +96,7 @@
],
"unpkg": "dist/cypress-design-system.browser.js",
"module": "dist/cypress-design-system.esm-bundler.js",
"style": "dist/index.scss",
"publishConfig": {
"access": "restricted"
},
@@ -92,4 +107,4 @@
"expect"
]
}
}
}
+36 -19
View File
@@ -1,12 +1,14 @@
import ts from 'rollup-plugin-typescript2'
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-modules'
import postcss from 'rollup-plugin-postcss'
import pkg from './package.json'
import image from '@rollup/plugin-image'
import copy from 'rollup-plugin-copy-assets'
import copy from 'rollup-plugin-copy'
const cssFolders = require('./css.folders')
const banner = `
/**
@@ -31,14 +33,42 @@ function createEntry (options) {
],
plugins: [
peerDepsExternal(),
ts({
// check: format === 'es' && isBrowser,
declaration: format === 'es',
target: 'es5', // not sure what this should be?
module: format === 'cjs' ? 'es2015' : 'esnext',
}),
resolve(),
json(),
commonjs(),
postcss({ writeDefinitions: false }),
postcss({
modules: {
globalModulePaths: ['src/css/derived/export.scss'],
},
use: [
['sass', {
includePaths: cssFolders,
}],
],
}),
image(),
copy({
assets: [
'./index.scss',
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',
},
],
}),
],
@@ -68,19 +98,6 @@ function createEntry (options) {
/* eslint-disable no-console */
console.log(`Building ${format}: ${config.output.file}`)
config.plugins.push(
ts({
check: format === 'es' && isBrowser,
tsconfigOverride: {
compilerOptions: {
declaration: format === 'es',
target: 'es5', // not sure what this should be?
module: format === 'cjs' ? 'es2015' : 'esnext',
},
},
}),
)
return config
}
@@ -1,10 +0,0 @@
@use '../../index.scss' as *;
.button {
background: $accent-01;
&:hover {
@extend .text-black;
background: $papaya-05;
}
}
@@ -1,10 +0,0 @@
import React from 'react'
import { mount } from '@cypress/react'
import { Button } from './Button'
describe('Button', () => {
it('renders', () => {
mount(<Button />)
cy.get('button').should('exist')
})
})
@@ -1,6 +0,0 @@
import React from 'react'
import { button } from './Button.module.scss'
export const Button = () => (
<button className={button}>Hello World</button>
)
@@ -1 +0,0 @@
export * from './Button'
@@ -1,4 +1,6 @@
@use '../../index.scss' as *;
@use 'baseColors' as *;
@use 'semanticColors' as *;
@use 'typography' as *;
$left-nav-width: 48px;
$icon-color: $metal-20;
@@ -33,7 +35,7 @@ $active-color: $brand-01;
.item {
height: 100%;
font-size: $text-ml;
font-size: text(ml);
cursor: pointer;
color: $icon-color;
@@ -1,6 +1,5 @@
import React from 'react'
import { CypressLogo } from './CypressLogo/CypressLogo'
import { SearchInput } from './SearchInput/SearchInput'
import { mount } from '@cypress/react'
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -22,52 +21,4 @@ describe('Playground', () => {
</>,
)
})
it('search input', () => {
const Wrapper = (props) => {
const [value, setValue] = React.useState(props.value || '')
const inputRef = React.useRef<HTMLInputElement>(null)
return (
<SearchInput
prefixIcon={props.prefixIcon}
placeholder={props.placeholder}
inputRef={inputRef}
value={value}
onSuffixClicked={() => {
setValue('')
inputRef.current.focus()
}}
onChange={(event) => setValue(event.target.value)}
>
</SearchInput>
)
}
mount(
<>
<Wrapper placeholder="Find components..." prefixIcon="search" />
<br />
{/* <Wrapper placeholder="Find components..." prefixIcon="coffee"/> */}
<br />
{/* <Wrapper placeholder="Find components..." prefixIcon="search" suffixIcon="times"/> */}
</>,
)
cy.get('input').should('exist')
cy.get('input').should('exist').first()
// .its('placeholder').should('be', placeholderText)
// .click()
cy.get('input').first().type('Hello World!').clear().type('WHATS UP ⚡️')
cy.get('input').first().should('contain.value', '⚡️')
cy.get('input')
.click()
.type('hello')
.get('[data-testid=close]')
.click()
.get('input')
.should('be.focused')
})
})
@@ -1,62 +0,0 @@
@use '../../index.scss' as *;
$active-color: $metal-50;
$inactive-color: $metal-20;
$input-color: $metal-50;
@mixin quick-transition($param) {
transition: $param 0.15s ease-in-out;
}
.searchInput {
@include text-style-light-xs;
@include quick-transition(border);
color: $input-color;
padding: 0 $text-m 0 $text-xxs;
width: 100%;
border: unset;
outline: unset;
border-bottom: 1px solid $inactive-color;
&.hasPrefix {
padding: 0.2rem $text-m;
}
}
// prefix and suffix math
$icon-size: $text-xs;
$icon-negative-margin: calc(#{$icon-size} * (-4 / 3));
$icon-margin: calc(#{$icon-size} / 3);
.prefix, .suffix {
@include quick-transition(color);
position: absolute;
font-size: $icon-size;
color: $inactive-color;
}
.prefix {
margin: $icon-margin;
}
.suffix {
margin: $icon-margin $icon-negative-margin;
right: 20px;
}
.inputButton {
position: relative;
display: flex;
margin: 1rem;
align-items: center;
&:active, &:focus-within {
.searchInput {
border-bottom: 1px solid $active-color;
}
.prefix {
color: $active-color;
}
}
}
@@ -1,55 +0,0 @@
import React from 'react'
import { FontAwesomeIcon, FontAwesomeIconProps } from '@fortawesome/react-fontawesome'
import cs from 'classnames'
import styles from './SearchInput.module.scss'
interface SearchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
prefixIcon?: FontAwesomeIconProps['icon']
placeholder: string
onSuffixClicked?: () => void
onPrefixClicked?: () => void
inputRef: React.RefObject<HTMLInputElement>
}
export const SearchInput: React.FC<SearchInputProps> = (props) => {
const { onSuffixClicked } = props
const prefixIcon = props.prefixIcon && (
<FontAwesomeIcon
className={styles.prefix}
icon={props.prefixIcon}
/>
)
const onKeyPress = React.useCallback((e: React.KeyboardEvent<SVGSVGElement>) => {
if (e.key === 'Enter') {
onSuffixClicked?.()
}
}, [onSuffixClicked])
return (
<span className={styles.inputButton}>
{prefixIcon}
<input
ref={props.inputRef}
type="text"
className={cs([styles.searchInput, props.prefixIcon ? styles.hasPrefix : ''])}
placeholder={props.placeholder}
value={props.value}
onChange={props.onChange}
/>
{
props.value && (
<FontAwesomeIcon
data-testid="close"
className={styles.suffix}
tabIndex={0}
icon="times"
onClick={props.onSuffixClicked}
onKeyPress={onKeyPress}
/>
)
}
</span>
)
}
@@ -0,0 +1,70 @@
import * as React from 'react'
import { mount } from '@cypress/react'
import { SearchInput } from './SearchInput'
import { useCallback, useState } from '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', () => {
mount(<SearchInput placeholder="foo" value="" aria-label="Search" 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')
})
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', () => {
mount(<SearchInput placeholder="foo" value="a value" aria-label="Search" onInput={() => {}} />)
cy.get('[aria-label="Clear search"]').click()
cy.get('input').should('be.focused')
})
})
})
@@ -0,0 +1,20 @@
import * as React from 'react'
import { createStory, createStorybookConfig } from 'stories/util'
import { SearchInput as SearchInputComponent } from './SearchInput'
import { useState } from '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>
)
})
@@ -0,0 +1,50 @@
import * as React from 'react'
import { 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
['aria-label']: string
}
const prefixItem: IconSettings = {
icon: 'search',
}
export const SearchInput: React.FC<SearchInputProps> = ({ inputRef = null, onInput: externalOnInput, ...props }) => {
const ref = React.useRef<HTMLInputElement>(null)
useCombinedRefs(ref, inputRef)
const onInput = useCallback((e: FormEvent<HTMLInputElement>) => externalOnInput(e.currentTarget.value), [externalOnInput])
const onClear = useCallback(() => {
externalOnInput('')
ref.current?.focus()
}, [externalOnInput])
return (
<IconInput
{...props}
inputRef={ref}
label={{ type: 'aria', contents: props['aria-label'] }}
prefixIcon={prefixItem}
suffixIcon={props.value.length > 0 ? { icon: 'times', onPress: onClear, 'aria-label': 'Clear search' } : undefined}
onInput={onInput}
/>
)
}
@@ -0,0 +1,69 @@
@use 'semanticColors' as *;
@use 'typography' as *;
@use 'spacing' as *;
@use 'surfaces' as *;
@use 'func' as *;
@use 'util';
$button-vertical-padding: change-rem-unit-to-em(spacing(xs));
$button-horizontal-padding: change-rem-unit-to-em(spacing(s));
.button {
position: relative;
display: inline-block;
// Reset
@include util.no-selection;
border: 0;
text-decoration: none;
cursor: pointer;
// Style
padding: $button-vertical-padding $button-horizontal-padding;
border-radius: $button-radius;
color: $control-text-color-white;
background-color: $button-blue-color;
&:hover {
background-color: $button-blue-hover-color;
}
&:active {
background-color: $button-blue-push-color;
}
&:focus {
outline: none;
}
&.white {
color: $control-text-color-black;
background-color: $button-white-color;
border: 1px solid $control-border-color;
&:hover {
background-color: $button-white-hover-color;
}
&:active {
background-color: $button-white-push-color;
}
}
&.disableBorder {
border: 0;
}
}
:global {
:local(.button.white:not(.disableBorder)) {
&.focused::after {
// White buttons have a border that needs to be compensated for
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
}
}
@@ -0,0 +1,77 @@
import * as React from 'react'
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'
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 ButtonSizes = createStory(() => (
<div>
<div style={{ width: 500 }}>
{Object.keys(typography).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>
)
})}
</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>
))
@@ -0,0 +1,67 @@
import * as React from 'react'
import { useRef, 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 '../text/StyledText'
import styles from './Button.module.scss'
import { FocusRing } from '@react-aria/focus'
import { focusClass } from 'css/derived/util'
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' />
@@ -0,0 +1,22 @@
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>
)
@@ -0,0 +1,23 @@
$icon-margin: 0.15em;
$icon-bottom-offset: 0.125em;
$icon-size: 1em - $icon-margin * 2;
:global(.svg-inline--fa) {
// TODO: Is there ever a need for the icon to not take a square space as long as it's properly centered?
&.icon {
position: relative;
width: $icon-size;
height: $icon-size;
bottom: $icon-bottom-offset;
}
&.ignoreTextCenter {
width: 1em;
height: 1em;
bottom: auto;
}
}
@@ -0,0 +1,9 @@
.icon {
background-color: orange;
border: 1px solid black;
}
.textIcon {
border: 1px solid black;
}
@@ -0,0 +1,74 @@
import * as React from 'react'
import { Story } from '@storybook/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,
},
},
},
})
const Template: Story<{
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 />
{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>
<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>
)
})}
</div>
)
export const Icon = createStory(Template, {
font: fontOptions[0],
})
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react'
import { SVGAttributes } from 'react'
import cs from 'classnames'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconName } from '@fortawesome/fontawesome-svg-core'
import { styledTextSizeClassNames } from '../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
ignoreTextCenter?: 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, ...props }) => (
<FontAwesomeIcon
{...props}
className={cs(styledTextSizeClassNames(size, lineHeight), styles.icon, {
[styles.ignoreTextCenter]: ignoreTextCenter,
}, className)}
icon={icon}
/>
)
@@ -0,0 +1,98 @@
@use 'spacing' as *;
@use 'semanticColors' as *;
@use '../icon/Icon.module.scss' as *;
@use 'surfaces' as *;
$icon-overall-size: 1.5em;
$input-icon-margin: 0.25em;
.iconInput {
position: relative;
display: flex;
align-items: center;
// No actual border drawn. This provides the mask for the rounded corners to clip child elements
border-radius: $button-radius;
overflow: hidden;
z-index: 0;
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 1px solid $control-border-color;
border-radius: $button-radius;
pointer-events: none;
}
.wrapper {
display: flex;
flex-grow: 1;
&:first-child {
// If first child, apply border padding
padding-left: spacing(ms);
}
&:last-child {
// If last child, apply border padding
padding-right: spacing(ms);
}
}
.input {
flex-grow: 1;
// Required to allow input to shrink in certain size scenarios
width: 0;
background-color: transparent;
border: 0;
&:focus {
outline: none;
}
}
.icon {
flex-grow: 0;
flex-shrink: 0;
// Icon
margin: 0 $input-icon-margin;
bottom: 0;
color: $control-text-color-black;
}
.iconButton {
// See global expression below
// Make sure button fills entire height, to enable the button focus ring to cover the IconInput border
align-self: stretch;
padding: 0 $input-icon-margin;
line-height: 1;
border-radius: 0;
&::after {
z-index: 1;
}
}
}
:global {
:local(.iconButton) {
&.focused {
// Set overflow and border radius to clip corners when focused
overflow: hidden;
border-radius: $button-radius;
}
}
}
@@ -0,0 +1,94 @@
import * as React from 'react'
import { InputHTMLAttributes, 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 '../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']?: undefined
})
export type IconInputProps = InputProps<{
prefixIcon?: IconSettings
suffixIcon?: IconSettings
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'size'>>
& RefAttributes<HTMLInputElement>
export const IconInput: React.FC<IconInputProps> = (props) => <InputBase {...props} inputRenderer={IconInputComponent} />
const IconInputComponent: InputRenderer<IconInputProps> = ({ 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
{...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>
)
}
@@ -0,0 +1,136 @@
import * as React from 'react'
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 typography from 'css/derived/jsTypography.scss'
import { TextSize } from 'css'
export default createStorybookConfig({
title: 'Core/Input',
})
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>
<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 IconSizes = createStory(() => (
<div>
<div style={{ width: 500 }}>
{Object.keys(typography).filter((key) => key !== 'type' && !key.startsWith('line-height') && !key.startsWith('text-mono') && key !== 'text-3xl' && key !== 'text-4xl').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',
}}
/>
)
})}
</div>
</div>
))
@@ -0,0 +1,4 @@
import * as React from 'react'
import { basicInputRenderer, InputBase, InputProps } from './InputBase'
export const Input: React.FC<InputProps<{}>> = (props) => <InputBase {...props} inputRenderer={basicInputRenderer} />
@@ -0,0 +1,9 @@
@use 'semanticColors' as *;
@use 'surfaces' as *;
input.input {
font-weight: normal;
border: 1px solid $control-border-color;
border-radius: $button-radius;
}
@@ -0,0 +1,102 @@
import * as React from 'react'
import { CSSProperties, InputHTMLAttributes, MutableRefObject, ReactNode, RefObject, TextareaHTMLAttributes, useMemo, useRef } from 'react'
import { useTextField } from 'react-aria'
import cs from 'classnames'
import { ExtractFirstArg } from 'util/types'
import { LineHeight, TextSize } from 'css'
import { styledTextSizeClassNames } from '../text/StyledText'
import { SizingProps } from 'core/shared'
import styles from './InputBase.module.scss'
import { useCombinedRefs } from 'hooks/useCombinedRefs'
export interface SharedInputBaseProps extends SizingProps {
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 type InputRenderer<T> = (componentProps: Omit<InputProps<T>, 'label'>, inputProps: InputHTMLAttributes<HTMLInputElement>, inputRef: RefObject<HTMLTextAreaElement | HTMLInputElement>) => ReactNode
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: ExtractFirstArg<typeof useTextField> = {
...props,
inputElementType: textArea ? 'textarea' : 'input',
}
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(props as Omit<InputProps<T>, 'label'>, inputProps as InputHTMLAttributes<HTMLInputElement>, 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)} />
}
// TODO: The types here are not as elegant as I would like
export const basicInputRenderer: InputRenderer<Omit<BasicInputProps, 'inputRef'>> = (props, inputProps, inputRef) =>
<BasicInput {...inputProps as Omit<InputHTMLAttributes<HTMLInputElement>, 'size'>} {...props} inputRef={inputRef} className={cs(inputProps.className, props.className)} />
+19
View File
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,43 @@
import * as React from 'react'
import { Story } from '@storybook/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-', '')),
},
},
},
})
const Template: Story<{
elevation: SurfaceElevation
}> = ({ elevation }) => (
<div>
<ElevationComponent elevation={elevation}>
{lorem}
</ElevationComponent>
<br />
<StoryHighlightWrapper>
<ElevationComponent elevation={elevation}>
{lorem}
</ElevationComponent>
</StoryHighlightWrapper>
</div>
)
export const Elevation = createStory(Template, {
elevation: 'bordered',
})
@@ -0,0 +1,19 @@
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>
)
@@ -0,0 +1,42 @@
import * as React from 'react'
import { Story } from '@storybook/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-', '')),
},
},
},
})
const Template: Story<{
padding: Spacing
}> = ({ padding }) => (
<div>
<StoryHighlightWrapper>
<PaddedComponent padding={padding}>
{lorem}
</PaddedComponent>
</StoryHighlightWrapper>
</div>
)
export const PaddedBox = createStory(Template, {
padding: 'm',
})
// Required to prevent Storybook from separating into two words and creating unnecessary nesting
PaddedBox.storyName = 'PaddedBox'
@@ -0,0 +1,22 @@
import * as React from 'react'
import { 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>
)
@@ -0,0 +1,39 @@
import * as React from 'react'
import { Story } from '@storybook/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',
})
const Template: Story = () => (
<div>
{Object.keys(typography).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 />
</>
)
})}
</div>
)
export const StyledText = createStory(Template)
StyledText.storyName = 'StyledText'
@@ -0,0 +1,26 @@
import * as React from 'react'
import { CSSProperties } from 'react'
import cs from 'classnames'
import { LineHeight, TextSize } from 'css'
import { TextSizableComponent } from '../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}`
+139
View File
@@ -0,0 +1,139 @@
@import './func.scss';
$colors: (
metal-100: rgba(0, 0, 0, 1), // #000000;
metal-90: rgba(33, 36, 38, 1), // #212426;
metal-80: rgba(45, 49, 52, 1), // #2d3134;
metal-70: rgba(62, 67, 71, 1), // #3e4347;
metal-60: rgba(83, 90, 95, 1), // #535a5f;
metal-50: rgba(107, 116, 123, 1), // #6b747b;
metal-40: rgba(135, 144, 151, 1), // #879097;
metal-30: rgba(160, 167, 172, 1), // #a0a7ac;
metal-20: rgba(190, 194, 198, 1), // #bec2c6;
metal-10: rgba(230, 232, 234, 1), // #e6e8ea;
metal-05: rgba(244, 245, 246, 1), // #f4f5f6;
metal-00: rgba(255, 255, 255, 1), // #ffffff;
red-70: rgba(153, 11, 18, 1), // #990b12;
red-60: rgba(191, 13, 22, 1), // #bf0d16;
red-50: rgba(224, 16, 26, 1), // #e0101a;
red-40: rgba(241, 55, 64, 1), // #f13740;
chill-90: rgba(8, 33, 68, 1), // #082144;
chill-80: rgba(12, 49, 100, 1), // #0c3164;
chill-70: rgba(16, 66, 137, 1), // #104289;
chill-60: rgba(21, 86, 178, 1), // #1556b2;
chill-50: rgba(27, 111, 228, 1), // #1b6fe4;
chill-40: rgba(73, 142, 238, 1), // #498eee;
chill-30: rgba(118, 168, 239, 1), // #76a8ef;
chill-20: rgba(164, 197, 244, 1), // #a4c5f4;
chill-10: rgba(222, 235, 252, 1), // #deebfc;
chill-05: rgba(237, 243, 253, 1), // #edf3fd;
olive-60: rgba(93, 100, 12, 1), // #5d640c;
olive-50: rgba(114, 123, 15, 1), // #727b0f;
olive-40: rgba(143, 154, 25, 1), // #8f9a19;
olive-30: rgba(211, 228, 27, 1), // #d3e41b;
olive-20: rgba(225, 237, 100, 1), // #e1ed64;
olive-10: rgba(238, 245, 168, 1), // #eef5a8;
olive-05: rgba(246, 250, 209, 1), // #f6fad1;
papaya-60: rgba(143, 68, 10, 1), // #8f440a;
papaya-50: rgba(190, 90, 14, 1), // #be5a0e;
papaya-40: rgba(235, 107, 10, 1), // #eb6b0a;
papaya-30: rgba(242, 141, 64, 1), // #f28d40;
papaya-20: rgba(246, 175, 121, 1), // #f6af79;
papaya-10: rgba(252, 229, 212, 1), // #fce5d4;
papaya-05: rgba(253, 241, 231, 1), // #fdf1e7;
green-60: rgba(41, 102, 10, 1), // #29660a;
green-50: rgba(54, 133, 15, 1), // #36850f;
green-40: rgba(68, 164, 20, 1), // #44a414;
green-30: rgba(79, 191, 23, 1), // #4fbf17;
green-20: rgba(107, 219, 51, 1), // #6bdb33;
green-10: rgba(204, 244, 185, 1), // #ccf4b9;
green-05: rgba(236, 251, 228, 1), // #ecfbe4;
cran-50: rgba(228, 28, 95, 1), // #e41c5f;
brand-00: rgba(28, 228, 150, 1), // #1ce496;
// TODO: Rewrite these names
brand-01: rgba(8, 41, 63, 1), // #08293f;
accent-00: rgba(177, 99, 255, 1), // #b163ff;
accent-01: rgba(54, 197, 255, 1), // #36c5ff;
accent-02: rgba(230, 255, 30, 1), // #e6ff1e;
);
// TODO: Is this needed?
// :root {
// @each $name, $color in $colors {
// .text-#{"" + $name} {
// color: $color;
// }
// .bg-#{"" + $name} {
// background-color: $color;
// }
// }
// }
// --- Color Variables
@function color($name: string) {
@return var(--#{"" + $name});
}
// Must be manually written out as SASS does not support dynamic variable creation
// Exposes SASS variables as references to CSS variables
$metal-100: color('metal-100');
$metal-90: color('metal-90');
$metal-80: color('metal-80');
$metal-70: color('metal-70');
$metal-60: color('metal-60');
$metal-50: color('metal-50');
$metal-40: color('metal-40');
$metal-30: color('metal-30');
$metal-20: color('metal-20');
$metal-10: color('metal-10');
$metal-05: color('metal-05');
$metal-00: color('metal-00');
$red-70: color('red-70');
$red-60: color('red-60');
$red-50: color('red-50');
$red-40: color('red-40');
$chill-90: color('chill-90');
$chill-80: color('chill-80');
$chill-70: color('chill-70');
$chill-60: color('chill-60');
$chill-50: color('chill-50');
$chill-40: color('chill-40');
$chill-30: color('chill-30');
$chill-20: color('chill-20');
$chill-10: color('chill-10');
$chill-05: color('chill-05');
$olive-60: color('olive-60');
$olive-50: color('olive-50');
$olive-40: color('olive-40');
$olive-30: color('olive-30');
$olive-20: color('olive-20');
$olive-10: color('olive-10');
$olive-05: color('olive-05');
$papaya-60: color('papaya-60');
$papaya-50: color('papaya-50');
$papaya-40: color('papaya-40');
$papaya-30: color('papaya-30');
$papaya-20: color('papaya-20');
$papaya-10: color('papaya-10');
$papaya-05: color('papaya-05');
$green-60: color('green-60');
$green-50: color('green-50');
$green-40: color('green-40');
$green-30: color('green-30');
$green-20: color('green-20');
$green-10: color('green-10');
$green-05: color('green-05');
$cran-50: color('cran-50');
$brand-00: color('brand-00');
$brand-01: color('brand-01');
$accent-00: color('accent-00');
$accent-01: color('accent-01');
$accent-02: color('accent-02');
@@ -0,0 +1,78 @@
@use 'baseColors' as *;
@use 'semanticColors' as *;
@use 'spacing' as *;
@use 'surfaces' as *;
@use 'spacing' as *;
@use 'typography' as *;
// BaseColors
:root {
@each $name, $color in $colors {
--#{"" + $name}: #{$color};
}
}
// SemanticColors
// Write semantic CSS color variables to root
// **NOTE**: Most variables do not need to be exposed as a CSS variable
:root {
// See def in semanticColors.scss
--black-rgb-color: #{extract-rgb('metal-100')};
}
// Spacing
@each $name, $text-def in $spacing {
$suffix: str-replace('' + $name, 'space-', '');
.#{'padding-' + $suffix} {
@include padding($suffix)
}
}
// Surfaces
@each $name, $def in $shadow {
$suffix: str-replace('' + $name, 'shadow-', '');
.#{'depth-' + $suffix} {
@include depth($suffix)
}
}
.focused {
@include focused;
}
// Typography
// See typography.scss
:root {
--font-stack-sans: #{$internal-font-stack-sans};
--font-stack-mono: #{$internal-font-stack-mono};
}
// :root {
@each $name, $text-def in $text {
.#{$name} {
@include text(str-replace($name, 'text-', ''))
}
}
.text-mono-m {
@include text-mono-m;
}
.text-mono-s {
@include text-mono-s;
}
.line-height-normal {
@include line-height-normal;
}
.line-height-condensed {
@include line-height-condensed;
}
.line-height-tight {
@include line-height-tight;
}
// }
@@ -0,0 +1,8 @@
@use '../baseColors' as *;
// Exposes $color, stripped of hyphens, as a JS variable when directly imported
:export {
@each $name, $color in $colors {
#{str-replace("" + $name, '-', '')}: #{$color};
}
}
+61
View File
@@ -0,0 +1,61 @@
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;
@@ -0,0 +1,8 @@
@use '../spacing.scss' as *;
// Exposes $spacing, as a JS variable when directly imported
:export {
@each $name, $size in $spacing {
#{$name}: #{$size};
}
}
+28
View File
@@ -0,0 +1,28 @@
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;
@@ -0,0 +1,8 @@
@use '../surfaces.scss' as *;
// Exposes $shadow as a JS variable when directly imported
:export {
@each $name, $shadow in $shadow {
#{$name}: #{$shadow};
}
}
+26
View File
@@ -0,0 +1,26 @@
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;
@@ -0,0 +1,18 @@
@use '../typography.scss' as *;
@use '../func.scss' as *;
// Exposes $text, as a JS variable when directly imported
// This is the only way for JS to directly receive the exposed `.text-*` classes
:export {
@each $name, $text-def in $text {
#{$name}: text(#{str-replace("" + $name, 'text-', '')})
}
// TODO: Can this be improved?
text-mono-m: text-mono-m;
text-mono-s: text-mono-s;
line-height-normal: line-height-normal;
line-height-condensed: line-height-condensed;
line-height-tight: line-height-tight;
}
@@ -0,0 +1,23 @@
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;
@@ -0,0 +1,32 @@
/* 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-'>
+37
View File
@@ -0,0 +1,37 @@
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'
+22
View File
@@ -0,0 +1,22 @@
/**
* Replace `$search` with `$replace` in `$string`
* @author Hugo Giraudel
* @param {String} $string - Initial string
* @param {String} $search - Substring to replace
* @param {String} $replace ('') - New value
* @return {String} - Updated string
*/
// Taken from https://gist.github.com/PuddingNL/51866d4b9f1151963fbd973bf1d66116
@function str-replace($string, $search, $replace: '') {
$index: str-index($string, $search);
@if $index {
@return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
}
@return $string;
}
@function change-rem-unit-to-em($number) {
@return ($number / 1rem) + 0em;
}
+5
View File
@@ -0,0 +1,5 @@
@forward 'baseColors';
@forward 'semanticColors';
@forward 'surfaces';
@forward 'typography';
@forward 'spacing';
+1
View File
@@ -0,0 +1 @@
export * from './derived/types'
@@ -0,0 +1,33 @@
@use 'baseColors' as *;
@use 'func';
/**
* Extracts RGB color channels from a named color definition
*/
@function extract-rgb($color-name: string) {
$color: map-get($colors, $color-name);
@if $color {
@return red($color), green($color), blue($color);
}
@return null;
}
// Provides raw black as a RGB number tuple for substitution into alpha modifying situations (shadows)
// See exposed variable in export.scss
$black-rgb-color: var(--black-rgb-color);
$control-border-color: $metal-30;
$control-focus-color: $chill-40;
$button-blue-color: $chill-50;
$button-blue-hover-color: $chill-60;
$button-blue-push-color: $chill-70;
$button-white-color: $metal-00;
$button-white-hover-color: $metal-05;
$button-white-push-color: $metal-10;
$control-text-color-white: $metal-05;
$control-text-color-black: $metal-90;
+26
View File
@@ -0,0 +1,26 @@
@use 'func' as *;
$_base-space: 1rem;
$spacing: (
space-xs: 0.25 * $_base-space, // 4px
space-s: 0.5 * $_base-space, // 8px
space-ms: 0.75 * $_base-space, // 12px
space-m: 1 * $_base-space, // 16px
space-ml: 1.25 * $_base-space, // 20px
space-l: 1.5 * $_base-space, // 24px
space-xl: 2 * $_base-space, // 32px
space-2xl: 2.5 * $_base-space, // 40px
space-3xl: 3 * $_base-space, // 48px
space-4xl: 4 * $_base-space, // 64px
);
@function spacing($name: string) {
@return map-get($spacing, "space-" + $name);
}
@mixin padding($name: string) {
padding: spacing($name);
}
+45
View File
@@ -0,0 +1,45 @@
@use 'semanticColors' as *;
@use 'func' as *;
// --- Shadows
// TODO: Need better semantic names
$shadow: (
shadow-flat: none,
shadow-slight: 0 1px 2px 0 rgba($black-rgb-color, 0.05),
shadow-bordered: (0 1px 3px 0 rgba($black-rgb-color, 0.1), 0 1px 2px 0 rgba($black-rgb-color, 0.06)),
shadow-3: (0 4px 6px -1px rgba($black-rgb-color, 0.1), 0 2px 4px -1px rgba($black-rgb-color, 0.06)),
shadow-4: (0 10px 15px -3px rgba($black-rgb-color, 0.1), 0 4px 6px -2px rgba($black-rgb-color, 0.05)),
shadow-popup: (0 20px 25px -5px rgba($black-rgb-color, 0.1), 0 10px 10px -5px rgba($black-rgb-color, 0.04)),
shadow-6: (0 25px 50px -12px rgba($black-rgb-color, 0.25)),
shadow-inset-slight: inset 0 2px 4px 0 rgba($black-rgb-color, 0.06),
shadow-inset-well: inset 0 3px 5px 0 rgba($black-rgb-color, 0.1),
);
// $shadow-outline: 0 0 0 3px rgba(66, 153, 225, 0.5);
$button-radius: 0.5rem; // 8px
@function shadow($name: string) {
@return map-get($shadow, "shadow-" + $name);
}
@mixin depth($name: string) {
box-shadow: shadow($name);
}
@mixin focused {
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px solid $control-focus-color;
border-radius: $button-radius;
pointer-events: none;
}
}
+119
View File
@@ -0,0 +1,119 @@
@use 'func' as *;
// --- Font Families
/**
* Do not directly reference. Use the $font-stack-sans and $font-stack-mono variables instead
*/
$internal-font-stack-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$internal-font-stack-mono: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
// See export.scss
$font-stack-sans: var(--font-stack-sans);
$font-stack-mono: var(--font-stack-mono);
// --- Text Sizes/Scale
// Chosen to produce integer sizes from a base font size of 16px
$text: (
text-xs: (
size: 0.5rem, // 8px
),
text-s: (
size: 0.75rem, // 12px
),
text-ms: (
size: 0.875rem, // 14px
),
text-m: (
size: 1rem, // 16px
weight: bolder,
),
text-ml: (
size: 1.25rem, // 18px
weight: bolder,
),
text-l: (
size: 1.5rem, // 24px
weight: bolder,
),
text-xl: (
size: 2rem, // 32px
weight: bolder,
),
text-2xl: (
size: 2.5rem, // 40px
weight: bolder,
),
text-3xl: (
size: 3rem, // 48px
weight: bolder,
),
text-4xl: (
size: 4rem, // 64px
weight: bolder,
)
);
@function text($name: string) {
@return map-get(map-get($text, "text-" + #{$name}), size);
}
// --- Line heights
$lh-tight: 1;
$lh-condensed: 1.25;
$lh-normal: 1.5;
// --- Mixins
@mixin _text-base {
font-family: $font-stack-sans;
font-weight: normal;
font-style: normal;
}
// General text
@mixin text($name: string) {
$text-def: map-get($text, "text-" + $name);
$size: map-get($text-def, size);
$weight: map-get($text-def, weight);
@include _text-base;
@if $size {
font-size: $size;
}
@if $weight {
font-weight: $weight;
}
}
@mixin text-mono-m {
font-size: text(m);
font-family: $font-stack-mono;
font-weight: bold;
font-style: normal;
}
@mixin text-mono-s {
font-size: text(s);
font-family: $font-stack-mono;
font-weight: bold;
font-style: normal;
}
// Line heights
@mixin line-height-normal {
line-height: $lh-normal;
}
@mixin line-height-condensed {
line-height: $lh-condensed;
}
@mixin line-height-tight {
line-height: $lh-tight;
}
+9
View File
@@ -0,0 +1,9 @@
@mixin no-selection {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Old versions of Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently
supported by Chrome, Edge, Opera and Firefox */
}
+13
View File
@@ -0,0 +1,13 @@
/*
* Global CSS values to wrap into the bundle. Should not be imported by dependants
*/
@use 'derived/export.scss';
@use 'typography';
@use 'normalize.css/normalize.css';
// probably should leave this for the consumer to set?
body, html {
font-size: typography.text(m);
font-family: typography.$font-stack-sans;
}
@@ -0,0 +1,21 @@
import { MutableRefObject, RefCallback, 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])
}
+5 -259
View File
@@ -1,260 +1,6 @@
// Color map
$colors: (
black: rgba(0, 0, 0, 1),
metal-90: rgba(33, 36, 38, 1),
metal-80: rgba(45, 49, 52, 1),
metal-70: rgba(62, 67, 71, 1),
metal-60: rgba(83, 90, 95, 1),
metal-50: rgba(107, 116, 123, 1),
metal-40: rgba(135, 144, 151, 1),
metal-30: rgba(160, 167, 172, 1),
metal-20: rgba(190, 194, 198, 1),
metal-10: rgba(230, 232, 234, 1),
metal-05: rgba(244, 245, 246, 1),
white: rgba(255, 255, 255, 1),
red-70: rgba(153, 11, 18, 1),
red-60: rgba(191, 13, 22, 1),
red-50: rgba(224, 16, 26, 1),
red-40: rgba(241, 55, 64, 1),
chill-90: rgba(8, 33, 68, 1),
chill-80: rgba(12, 49, 100, 1),
chill-70: rgba(16, 66, 137, 1),
chill-60: rgba(21, 86, 178, 1),
chill-50: rgba(27, 111, 228, 1),
chill-40: rgba(73, 142, 238, 1),
chill-30: rgba(118, 168, 239, 1),
chill-20: rgba(164, 197, 244, 1),
chill-10: rgba(222, 235, 252, 1),
chill-05: rgba(237, 243, 253, 1),
olive-60: rgba(93, 100, 12, 1),
olive-50: rgba(114, 123, 15, 1),
olive-40: rgba(143, 154, 25, 1),
olive-30: rgba(211, 228, 27, 1),
olive-20: rgba(225, 237, 100, 1),
olive-10: rgba(238, 245, 168, 1),
olive-05: rgba(246, 250, 209, 1),
papaya-60: rgba(143, 68, 10, 1),
papaya-50: rgba(190, 90, 14, 1),
papaya-40: rgba(235, 107, 10, 1),
papaya-30: rgba(242, 141, 64, 1),
papaya-20: rgba(246, 175, 121, 1),
papaya-10: rgba(252, 229, 212, 1),
papaya-05: rgba(253, 241, 231, 1),
green-60: rgba(41, 102, 10, 1),
green-50: rgba(54, 133, 15, 1),
green-40: rgba(68, 164, 20, 1),
green-30: rgba(79, 191, 23, 1),
green-20: rgba(107, 219, 51, 1),
green-10: rgba(204, 244, 185, 1),
green-05: rgba(236, 251, 228, 1),
cran-50: rgba(228, 28, 95, 1),
brand-00: rgba(28, 228, 150, 1),
brand-01: rgba(8, 41, 63, 1),
accent-00: rgba(177, 99, 255, 1),
accent-01: rgba(54, 197, 255, 1),
accent-02: rgba(230, 255, 30, 1),
);
/*
* Base SCSS file for external SASS imports (all dependants should import from this file to use the SASS vars)
*/
// --- Generate color classes
// .text- color utilities
// .bg- color utilities
@each $name, $color in $colors {
.text-#{"" + $name} {
color: $color;
}
.bg-#{"" + $name} {
background-color: $color;
}
}
// --- Color Variables
$black: #000000;
$metal-90: #212426;
$metal-80: #2d3134;
$metal-70: #3e4347;
$metal-60: #535a5f;
$metal-50: #6b747b;
$metal-40: #879097;
$metal-30: #a0a7ac;
$metal-20: #bec2c6;
$metal-10: #e6e8ea;
$metal-05: #f4f5f6;
$white: #ffffff;
$red-70: #990b12;
$red-60: #bf0d16;
$red-50: #e0101a;
$red-40: #f13740;
$chill-90: #082144;
$chill-80: #0c3164;
$chill-70: #104289;
$chill-60: #1556b2;
$chill-50: #1b6fe4;
$chill-40: #498eee;
$chill-30: #76a8ef;
$chill-20: #a4c5f4;
$chill-10: #deebfc;
$chill-05: #edf3fd;
$olve-60: #5d640c;
$olive-50: #727b0f;
$olive-40: #8f9a19;
$olive-30: #d3e41b;
$olive-20: #e1ed64;
$olive-10: #eef5a8;
$olive-05: #f6fad1;
$papaya-60: #8f440a;
$papaya-50: #be5a0e;
$papaya-40: #eb6b0a;
$papaya-30: #f28d40;
$papaya-20: #f6af79;
$papaya-10: #fce5d4;
$papaya-05: #fdf1e7;
$green-60: #29660a;
$green-50: #36850f;
$green-40: #44a414;
$green-30: #4fbf17;
$green-20: #6bdb33;
$green-10: #ccf4b9;
$green-05: #ecfbe4;
$cran-50: #e41c5f;
$brand-00: #1ce496;
$brand-01: #08293f;
$accent-00: #b163ff;
$accent-01: #36c5ff;
$accent-02: #e6ff1e;
// ---
// Typography
// --- Font Families
$font-stack-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$font-stack-mono: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
// --- Text Sizes/Scale
$text-xxs: 0.5rem;
$text-xs: 0.75rem;
$text-s: 0.875rem;
$text-base: 1rem;
$text-m: 1.25rem;
$text-ml: 1.5rem;
$text-l: 2rem;
$text-xl: 2.5rem;
$text-xxl: 3rem;
$text-xxxl: 4rem;
$text-xxxxl: 4.75rem;
// Line heights
$lh-tight: 1;
$lh-condensed: 1.25;
$lh-default: 1.5;
// --- Shadows
$shadow-xs: 0 0 0 1px rgba(0, 0, 0, 0.05);
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
$shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
$shadow-outline: 0 0 0 3px rgba(66, 153, 225, 0.5);
$shadow-none: none;
// --- Mixins
@mixin text-style-text-l {
font-size: $text-l;
font-family: $font-stack-sans;
font-weight: bolder;
font-style: normal;
}
@mixin text-style-text-m {
font-size: $text-m;
font-family: $font-stack-sans;
font-weight: bolder;
font-style: normal;
}
@mixin text-style-text-s {
font-size: $text-s;
font-family: $font-stack-sans;
font-weight: normal;
font-style: normal;
}
@mixin text-style-text-xs {
font-size: $text-xs;
font-family: $font-stack-sans;
font-weight: normal;
font-style: normal;
}
@mixin text-style-text-base {
font-size: $text-base;
font-family: $font-stack-sans;
font-weight: normal;
font-style: normal;
}
@mixin text-style-text-xl {
font-size: $text-xl;
font-family: $font-stack-sans;
font-weight: bolder;
font-style: normal;
}
@mixin text-style-text-xxl {
font-size: $text-xxl;
font-family: $font-stack-sans;
font-weight: bolder;
font-style: normal;
}
@mixin text-style-text-xxxl {
font-size: $text-xxxl;
font-family: $font-stack-sans;
font-weight: bolder;
font-style: normal;
}
@mixin text-style-text-xxxxl {
font-size: $text-xxxxl;
font-family: $font-stack-sans;
font-weight: bolder;
font-style: normal;
}
@mixin text-style-text-s-caps {
font-size: $text-s;
font-family: $font-stack-sans;
text-transform: uppercase;
font-weight: normal;
font-style: normal;
}
@mixin text-style-text-mono-base {
font-size: $text-base;
font-family: $font-stack-mono;
font-weight: bold;
font-style: normal;
}
@mixin text-style-text-mono-s {
font-size: $text-s;
font-family: $font-stack-mono;
font-weight: bold;
font-style: normal;
}
// Lighter text for accent, etc
@mixin text-style-light-xs {
color: $metal-30;
font-size: $text-xs;
font-family: $font-stack-sans;
font-weight: 400;
font-style: normal;
}
// probably should leave this for the consumer to set?
body, html {
font-size: $text-m;
font-family: $font-stack-sans;
}
// css prefix for clarity
@forward 'css/index';
+3 -2
View File
@@ -1,7 +1,8 @@
export * from './components/Button'
// Add global CSS to the bundle
import './global.scss'
export * from './components/CypressLogo/CypressLogo'
export * from './components/SearchInput/SearchInput'
export * from './components/searchInput/SearchInput'
export * from './components/Nav'
@@ -0,0 +1,31 @@
/**
* Draws the baseline and toplines for a DOM element
*/
.baseline {
position: relative;
&:before {
content: "";
position: absolute;
width: 100%;
height: 1px;
top: 0.2em;
background-color: rgba(red, 0.2);
}
&:after {
content: "";
position: absolute;
width: 100%;
height: 1px;
bottom: 0.2em;
left: 0;
background-color: rgba(red, 0.2);
}
}
@@ -0,0 +1,14 @@
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>
)
@@ -0,0 +1,51 @@
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)
@@ -0,0 +1,51 @@
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)
@@ -0,0 +1,32 @@
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)
@@ -0,0 +1,35 @@
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)
@@ -0,0 +1,9 @@
.colorBlock {
display: inline-block;
height: 7rem;
width: 7rem;
padding: 1.5rem;
margin-right: 1rem;
margin-bottom: 1rem;
}
@@ -0,0 +1,6 @@
@use 'baseColors' as *;
.cube {
margin: 1rem;
background-color: $brand-00;
}
@@ -0,0 +1,9 @@
.surface {
display: inline-block;
height: 12rem;
width: 12rem;
padding: 1.5rem;
margin-right: 4rem;
margin-bottom: 4rem;
}
+17
View File
@@ -0,0 +1,17 @@
import { 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
}
+2
View File
@@ -0,0 +1,2 @@
export const lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut tempus dapibus mi. Sed convallis interdum aliquet.' +
' Donec pellentesque felis non diam finibus, ac interdum orci tincidunt. Nam a nunc non mi auctor congue. Mauris id tempus urna.'
@@ -0,0 +1,9 @@
import * as React from 'react'
import styles from './storyHighlightWrapper.module.scss'
export const StoryHighlightWrapper: React.FC = ({ children }) => (
<div className={styles.wrapper}>
{children}
</div>
)
@@ -0,0 +1,5 @@
@use 'baseColors' as *;
.wrapper {
background-color: $brand-00;
}
+1
View File
@@ -0,0 +1 @@
export type ExtractFirstArg<T extends (...args: any[]) => unknown> = T extends (arg0: infer S, ...otherArgs: any[]) => unknown ? S : never
+12 -2
View File
@@ -20,8 +20,18 @@
"cypress"
] /* Type declaration files to be included in compilation. */,
"allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
"esModuleInterop": true
"esModuleInterop": true,
"baseUrl": "src",
"paths": {
"core/*": ["core/*"],
"css/*": ["css/*"],
"components/*": ["components/*"],
"hooks/*": ["hooks/*"],
"shared/*": ["shared/*"],
"stories/*": ["stories/*"],
"util/*": ["util/*"],
}
},
"include": ["src"],
"exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"]
"exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"],
}
+2
View File
@@ -1,3 +1,5 @@
/// <reference types="cypress" />
/**
* Additional styles to inject into the document.
* A component might need 3rd party libraries from CDN,
@@ -8,7 +8,7 @@ it('should select null after timing out', () => {
const onSelect = cy.stub().as('selected')
mount(<Card onSelect={onSelect} />)
cy.get('@selected', { timeout: 5100 }).should('have.been.calledWith', null)
cy.get('@selected', { timeout: 6000 }).should('have.been.calledWith', null)
})
it('should accept selections', () => {
@@ -1,5 +0,0 @@
# re-render
If you want to see how a component re-renders in reaction to changed props, you have to make a mini-web app around it. See [spec.js](spec.js) but in essence you never keep the component, but create a new one.
![Spec](images/my-input.gif)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 591 KiB

@@ -1,46 +0,0 @@
import React from 'react'
import { mount } from '@cypress/react'
const MyInput = ({ inputVal, onInputChanged }) => {
console.log('MyInput "%s"', inputVal)
return (
<>
<input
type="text"
value={inputVal}
onChange={(e) => onInputChanged(e.target.value)}
/>
<p>You entered {inputVal} </p>
</>
)
}
describe('My Input', () => {
it('updates when props change', () => {
// can we shorten this example
// that checks if the MyInput is re-rendering?
const App = () => {
const [message, setMessage] = React.useState('')
return (
<>
<MyInput
inputVal={message}
onInputChanged={(newValue) => {
setMessage(newValue)
return null
}}
/>
</>
)
}
mount(<App />)
/* Update props */
cy.get('input').type('hello there!')
cy.contains('You entered hello there!')
})
})
@@ -0,0 +1,17 @@
/// <reference types="cypress" />
import React from 'react'
import { mount } from '@cypress/react'
it('should properly handle swapping components', () => {
const Component1 = ({ input }) => {
return <div>{input}</div>
}
const Component2 = ({ differentProp }) => {
return <div style={{ backgroundColor: 'blue' }}>{differentProp}</div>
}
mount(<Component1 input="0" />).then(({ rerender }) => {
rerender(<Component2 differentProp="1" />).get('body').should('contain', '1').should('not.contain', '0')
})
})
@@ -0,0 +1,67 @@
/// <reference types="cypress" />
import React, { useLayoutEffect, useEffect } from 'react'
import { mount } from '@cypress/react'
it('should not run unmount effect cleanup when rerendering', () => {
const layoutEffectCleanup = cy.stub()
const effectCleanup = cy.stub()
const Component = ({ input }) => {
useLayoutEffect(() => {
return layoutEffectCleanup
}, [input])
useEffect(() => {
return effectCleanup
}, [])
return <div>{input}</div>
}
mount(<Component input="0" />).then(({ rerender }) => {
expect(layoutEffectCleanup).to.have.been.callCount(0)
expect(effectCleanup).to.have.been.callCount(0)
rerender(<Component input="0" />).then(() => {
expect(layoutEffectCleanup).to.have.been.callCount(0)
expect(effectCleanup).to.have.been.callCount(0)
})
rerender(<Component input="1" />).then(() => {
expect(layoutEffectCleanup).to.have.been.callCount(1)
expect(effectCleanup).to.have.been.callCount(0)
})
})
})
it('should run unmount effect cleanup when unmounting', () => {
const layoutEffectCleanup = cy.stub()
const effectCleanup = cy.stub()
const Component = ({ input }) => {
useLayoutEffect(() => {
return layoutEffectCleanup
}, [])
useEffect(() => {
return effectCleanup
}, [])
return <div>{input}</div>
}
mount(<Component input="0" />).then(({ rerender, unmount }) => {
expect(layoutEffectCleanup).to.have.been.callCount(0)
expect(effectCleanup).to.have.been.callCount(0)
rerender(<Component input="1" />).then(() => {
expect(layoutEffectCleanup).to.have.been.callCount(0)
expect(effectCleanup).to.have.been.callCount(0)
})
unmount().then(() => {
expect(layoutEffectCleanup).to.have.been.callCount(1)
expect(effectCleanup).to.have.been.callCount(1)
})
})
})
@@ -0,0 +1,13 @@
import React, { useEffect, useState } from 'react'
export const InputAccumulator = ({ input }) => {
const [store, setStore] = useState([])
useEffect(() => {
setStore((prev) => [...prev, input])
}, [input])
return (<ul>
{store.map((v) => <li key={v}>{v}</li>)}
</ul>)
}
@@ -0,0 +1,20 @@
/// <reference types="cypress" />
import React from 'react'
import { mount } from '@cypress/react'
import { InputAccumulator } from './input-accumulator'
it('should rerender preserving input values', () => {
mount(<InputAccumulator input="initial" />).then(({ rerender }) => {
cy.get('li').eq(0).contains('initial')
rerender(<InputAccumulator input="Rerendered value" />)
cy.get('li:nth-child(1)').should('contain', 'initial')
cy.get('li:nth-child(2)').should('contain', 'Rerendered value')
rerender(<InputAccumulator input="Second rerendered value" />)
cy.get('li:nth-child(1)').should('contain', 'initial')
cy.get('li:nth-child(2)').should('contain', 'Rerendered value')
cy.get('li:nth-child(3)').should('contain', 'Second rerendered value')
})
})
+6 -6
View File
@@ -34,7 +34,7 @@ module.exports = (on, config) => {
}
```
See example repo [bahmutov/try-cra-with-unit-test](https://github.com/bahmutov/try-cra-with-unit-test) or included example in the folder [examples/react-scripts](../examples/react-scripts).
See example repo [bahmutov/try-cra-with-unit-test](https://github.com/bahmutov/try-cra-with-unit-test) or included example in the folder [examples/react-scripts](https://github.com/cypress-io/cypress/tree/develop/npm/react/examples/react-scripts).
**Tip:** `plugins/react-scripts` is just loading `plugins/cra-v3`.
@@ -50,7 +50,7 @@ module.exports = (on, config) => {
}
```
See example in the folder [examples/nextjs](../examples/nextjs).
See example in the folder [examples/nextjs](https://github.com/cypress-io/cypress/tree/develop/npm/react/https://github.com/cypress-io/cypress/tree/develop/npm/react/examples/nextjs).
### Your Webpack config
@@ -68,7 +68,7 @@ module.exports = (on, config) => {
}
```
See example in [bahmutov/Jscrambler-Webpack-React](https://github.com/bahmutov/Jscrambler-Webpack-React) or included example in the folder [examples/webpack-file](../examples/webpack-file).
See example in [bahmutov/Jscrambler-Webpack-React](https://github.com/bahmutov/Jscrambler-Webpack-React) or included example in the folder [examples/webpack-file](https://github.com/cypress-io/cypress/tree/develop/npm/react/https://github.com/cypress-io/cypress/tree/develop/npm/react/examples/webpack-file).
### Your `.babelrc` file
@@ -85,7 +85,7 @@ module.exports = (on, config) => {
}
```
See example in the folder [examples/using-babel](../examples/using-babel) and [examples/using-babel-typescript](../examples/using-babel-typescript).
See example in the folder [examples/using-babel](https://github.com/cypress-io/cypress/tree/develop/npm/react/examples/using-babel) and [examples/using-babel-typescript](https://github.com/cypress-io/cypress/tree/develop/npm/react/examples/using-babel-typescript).
#### Add Babel plugins
@@ -129,7 +129,7 @@ When loading your `.babelrc` settings, `@cypress/react` sets `BABEL_ENV` and `NO
}
```
See [examples/using-babel](../examples/using-babel) folder for full example.
See [examples/using-babel](https://github.com/cypress-io/cypress/tree/develop/npm/react/examples/using-babel) folder for full example.
#### Using rollup config
@@ -163,7 +163,7 @@ commonjs(),
replace({ 'process.env.NODE_ENV': JSON.stringify('development') }),
```
See [examples/rollup](../examples/rollup) folder for full example.
See [react/examples/rollup](https://github.com/cypress-io/cypress/tree/develop/npm/react/examples/rollup) folder for full example.
## Usage
+68 -16
View File
@@ -30,7 +30,7 @@ const injectStyles = (options: MountOptions) => {
* @example
```
import Hello from './hello.jsx'
import {mount} from '@cypress/react'
import { mount } from '@cypress/react'
it('works', () => {
mount(<Hello onClick={cy.stub()} />)
// use Cypress commands
@@ -38,14 +38,24 @@ const injectStyles = (options: MountOptions) => {
})
```
**/
export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => _mount('mount', jsx, options)
/**
* @see `mount`
* @param type The type of mount executed
* @param rerenderKey If specified, use the provided key rather than generating a new one
*/
const _mount = (type: 'mount' | 'rerender', jsx: React.ReactNode, options: MountOptions = {}, rerenderKey?: string): globalThis.Cypress.Chainable<MountReturn> => {
// Get the display name property via the component constructor
// @ts-ignore FIXME
const componentName = getDisplayName(jsx.type, options.alias)
const displayName = options.alias || componentName
const jsxComponentName = `<${componentName} ... />`
const message = options.alias
? `<${componentName} ... /> as "${options.alias}"`
: `<${componentName} ... />`
? `${jsxComponentName} as "${options.alias}"`
: jsxComponentName
return cy
.then(injectStyles(options))
@@ -62,9 +72,9 @@ export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
)
}
const key =
const key = rerenderKey ??
// @ts-ignore provide unique key to the the wrapped component to make sure we are rerendering between tests
(Cypress?.mocha?.getRunner()?.test?.title || '') + Math.random()
(Cypress?.mocha?.getRunner()?.test?.title as string || '') + Math.random()
const props = {
key,
}
@@ -76,21 +86,24 @@ export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
)
// since we always surround the component with a fragment
// let's get back the original component
// @ts-ignore
const userComponent = reactComponent.props.children
const userComponent = (reactComponent.props as {
key: string
children: React.ReactNode
}).children
reactDomToUse.render(reactComponent, el)
if (options.log !== false) {
Cypress.log({
name: 'mount',
name: type,
type: 'parent',
message: [message],
$el: (el.children.item(0) as unknown) as JQuery<HTMLElement>,
consoleProps: () => {
return {
// @ts-ignore protect the use of jsx functional components use ReactNode
props: jsx.props,
description: 'Mounts React component',
description: type === 'mount' ? 'Mounts React component' : 'Rerenders mounted React component',
home: 'https://github.com/cypress-io/cypress',
}
},
@@ -98,15 +111,23 @@ export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
}
return (
cy
.wrap(userComponent, { log: false })
// Separate alias and returned value. Alias returns the component only, and the thenable returns the additional functions
cy.wrap<React.ReactNode>(userComponent)
.as(displayName)
.then(() => {
return cy.wrap<MountReturn>({
component: userComponent,
rerender: (newComponent) => _mount('rerender', newComponent, options, key),
unmount: () => _unmount({ boundComponentMessage: jsxComponentName, log: true }),
}, { log: false })
})
// by waiting, we delaying test execution for the next tick of event loop
// and letting hooks and component lifecycle methods to execute mount
// https://github.com/bahmutov/cypress-react-unit-test/issues/200
.wait(0, { log: false })
)
})
// Bluebird types are terrible. I don't think the return type can be carried without this cast
}) as unknown as globalThis.Cypress.Chainable<MountReturn>
}
/**
@@ -125,7 +146,9 @@ export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
})
```
*/
export const unmount = (options = { log: true }) => {
export const unmount = (options = { log: true }): globalThis.Cypress.Chainable<JQuery<HTMLElement>> => _unmount(options)
const _unmount = (options: { boundComponentMessage?: string, log: boolean }) => {
return cy.then(() => {
const selector = `#${ROOT_ID}`
@@ -133,7 +156,18 @@ export const unmount = (options = { log: true }) => {
const wasUnmounted = ReactDOM.unmountComponentAtNode($el[0])
if (wasUnmounted && options.log) {
cy.log('Unmounted component at', $el)
Cypress.log({
name: 'unmount',
type: 'parent',
message: [options.boundComponentMessage ?? 'Unmounted component'],
consoleProps: () => {
return {
description: 'Unmounts React component',
parent: $el[0],
home: 'https://github.com/cypress-io/cypress',
}
},
})
}
})
})
@@ -179,12 +213,13 @@ export const createMount = (defaultOptions: MountOptions) => {
}
/** @deprecated Should be removed in the next major version */
// TODO: Remove
export default mount
// I hope to get types and docs from functions imported from ./index one day
// but for now have to document methods in both places
// like this: import {mount} from './index'
// TODO: Clean up types
export interface ReactModule {
name: string
type: string
@@ -209,6 +244,23 @@ export interface MountReactComponentOptions {
export type MountOptions = Partial<StyleOptions & MountReactComponentOptions>
export interface MountReturn {
/**
* The component that was rendered.
*/
component: React.ReactNode
/**
* Rerenders the specified component with new props. This allows testing of components that store state (`setState`)
* or have asynchronous updates (`useEffect`, `useLayoutEffect`).
*/
rerender: (component: React.ReactNode) => globalThis.Cypress.Chainable<MountReturn>
/**
* Removes the mounted component.
* @see `unmount`
*/
unmount: () => globalThis.Cypress.Chainable<JQuery<HTMLElement>>
}
/**
* The `type` property from the transpiled JSX object.
* @example
+5 -3
View File
@@ -1,11 +1,13 @@
import { startDevServer } from '@cypress/vite-dev-server'
import viteConfig from '../vite.config'
const path = require('path')
const { startDevServer } = require('../dist')
module.exports = (on, config) => {
on('dev-server:start', async (options) => {
return startDevServer({
options,
viteConfig,
viteConfig: {
configFile: path.resolve(__dirname, '..', 'vite.config.ts'),
},
})
})
+10 -3
View File
@@ -14,10 +14,17 @@ interface Options {
}
export interface StartDevServer {
/* this is the Cypress options object */
/**
* the Cypress options object
*/
options: Options
/* support passing a path to the user's webpack config */
viteConfig?: UserConfig // TODO: implement taking in the user's vite configuration. Right now we don't
/**
* By default, vite will use your vite.config file to
* Start the server. If you need additional plugins or
* to override some options, you can do so using this.
* @optional
*/
viteConfig?: UserConfig
}
const resolveServerConfig = async ({ viteConfig, options }: StartDevServer): Promise<InlineConfig> => {
+1 -12
View File
@@ -2,7 +2,7 @@
If you component imports its own style, the style should be applied during the Cypress test. But sometimes you need more power.
You can 3 options to load additional styles:
You can 2 options to load additional styles:
```js
const myComponent = {
@@ -11,7 +11,6 @@ const myComponent = {
mount(myComponent, {
style: string, // load inline style CSS
cssFiles: string | string[], // load a single or a list of local CSS files
stylesheets: string | string[] // load external stylesheets
})
```
@@ -47,16 +46,6 @@ it('can be passed as an option', () => {
})
```
## Load local CSS file
```js
const cssFiles = 'cypress/integration/Button.css'
const myComponent = {
template: '<button class="orange"><slot/></button>'
}
mount(myComponent, { cssFiles })
```
## Load external stylesheets
```js
+13 -1
View File
@@ -7,9 +7,21 @@ const webpackConfig = require('@vue/cli-service/webpack.config')
*/
module.exports = (on, config) => {
on('dev-server:start', (options) => {
// HtmlPwaPlugin is coupled to a hook in HtmlWebpackPlugin
// that was deprecated after 3.x. We currently only support
// HtmlWebpackPlugin 4.x and 5.x.
// TODO: Figure out how to deal with 2 major versions old HtmlWebpackPlugin
// which is still in widespread usage.
const modifiedWebpackConfig = {
...webpackConfig,
plugins: (webpackConfig.plugins || []).filter((x) => {
return x.constructor.name !== 'HtmlPwaPlugin'
}),
}
return startDevServer({
options,
webpackConfig,
webpackConfig: modifiedWebpackConfig,
})
})

Some files were not shown because too many files have changed in this diff Show More