Repository: react95-io/React95
Branch: master
Commit: 871d5335daeb
Files: 246
Total size: 466.6 KB
Directory structure:
gitextract_rz85rgj9/
├── .babelrc
├── .codesandbox/
│ └── ci.json
├── .editorconfig
├── .eslintrc.js
├── .firebaserc
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .prettierrc
├── .storybook/
│ ├── decorators/
│ │ └── withGlobalStyle.tsx
│ ├── main.ts
│ ├── manager.css
│ ├── manager.ts
│ ├── preview.ts
│ ├── theme-picker/
│ │ ├── ThemeButton.tsx
│ │ ├── ThemeList.tsx
│ │ ├── ThemeProvider.tsx
│ │ ├── constants.ts
│ │ └── register.ts
│ └── theme.js
├── LICENSE
├── README.md
├── docs/
│ ├── Contributing.stories.mdx
│ ├── Getting-Started.stories.mdx
│ ├── Submit-your-Project.stories.mdx
│ └── Welcome.stories.mdx
├── firebase.json
├── jest.config.js
├── package.json
├── rollup.config.js
├── src/
│ ├── Anchor/
│ │ ├── Anchor.spec.tsx
│ │ ├── Anchor.stories.tsx
│ │ └── Anchor.tsx
│ ├── AppBar/
│ │ ├── AppBar.spec.tsx
│ │ ├── AppBar.stories.tsx
│ │ └── AppBar.tsx
│ ├── Avatar/
│ │ ├── Avatar.spec.tsx
│ │ ├── Avatar.stories.tsx
│ │ └── Avatar.tsx
│ ├── Button/
│ │ ├── Button.spec.tsx
│ │ ├── Button.stories.tsx
│ │ └── Button.tsx
│ ├── Checkbox/
│ │ ├── Checkbox.spec.tsx
│ │ ├── Checkbox.stories.tsx
│ │ └── Checkbox.tsx
│ ├── ColorInput/
│ │ ├── ColorInput.spec.tsx
│ │ ├── ColorInput.stories.tsx
│ │ └── ColorInput.tsx
│ ├── Counter/
│ │ ├── Counter.spec.tsx
│ │ ├── Counter.stories.tsx
│ │ ├── Counter.tsx
│ │ └── Digit.tsx
│ ├── DatePicker/
│ │ ├── DatePicker.stories.tsx
│ │ └── DatePicker.tsx
│ ├── Frame/
│ │ ├── Frame.spec.tsx
│ │ ├── Frame.stories.tsx
│ │ └── Frame.tsx
│ ├── GroupBox/
│ │ ├── GroupBox.spec.tsx
│ │ ├── GroupBox.stories.tsx
│ │ └── GroupBox.tsx
│ ├── Handle/
│ │ ├── Handle.spec.tsx
│ │ ├── Handle.stories.tsx
│ │ └── Handle.tsx
│ ├── Hourglass/
│ │ ├── Hourglass.spec.tsx
│ │ ├── Hourglass.stories.tsx
│ │ ├── Hourglass.tsx
│ │ └── base64hourglass.tsx
│ ├── MenuList/
│ │ ├── MenuList.spec.tsx
│ │ ├── MenuList.stories.tsx
│ │ ├── MenuList.tsx
│ │ ├── MenuListItem.spec.tsx
│ │ └── MenuListItem.tsx
│ ├── Monitor/
│ │ ├── Monitor.spec.tsx
│ │ ├── Monitor.stories.tsx
│ │ └── Monitor.tsx
│ ├── NumberInput/
│ │ ├── NumberInput.spec.tsx
│ │ ├── NumberInput.stories.tsx
│ │ └── NumberInput.tsx
│ ├── ProgressBar/
│ │ ├── ProgressBar.spec.tsx
│ │ ├── ProgressBar.stories.tsx
│ │ └── ProgressBar.tsx
│ ├── Radio/
│ │ ├── Radio.spec.tsx
│ │ ├── Radio.stories.tsx
│ │ └── Radio.tsx
│ ├── ScrollView/
│ │ ├── ScrollView.spec.tsx
│ │ ├── ScrollView.stories.tsx
│ │ └── ScrollView.tsx
│ ├── Select/
│ │ ├── Select.spec.tsx
│ │ ├── Select.stories.data.ts
│ │ ├── Select.stories.tsx
│ │ ├── Select.styles.tsx
│ │ ├── Select.tsx
│ │ ├── Select.types.ts
│ │ ├── SelectNative.spec.tsx
│ │ ├── SelectNative.tsx
│ │ ├── useSelectCommon.tsx
│ │ └── useSelectState.ts
│ ├── Separator/
│ │ ├── Separator.spec.tsx
│ │ ├── Separator.stories.tsx
│ │ └── Separator.tsx
│ ├── Slider/
│ │ ├── Slider.spec.tsx
│ │ ├── Slider.stories.tsx
│ │ └── Slider.tsx
│ ├── Table/
│ │ ├── Table.spec.tsx
│ │ ├── Table.stories.tsx
│ │ ├── Table.tsx
│ │ ├── TableBody.spec.tsx
│ │ ├── TableBody.tsx
│ │ ├── TableDataCell.spec.tsx
│ │ ├── TableDataCell.tsx
│ │ ├── TableHead.spec.tsx
│ │ ├── TableHead.tsx
│ │ ├── TableHeadCell.spec.tsx
│ │ ├── TableHeadCell.tsx
│ │ ├── TableRow.spec.tsx
│ │ └── TableRow.tsx
│ ├── Tabs/
│ │ ├── Tab.spec.tsx
│ │ ├── Tab.tsx
│ │ ├── TabBody.spec.tsx
│ │ ├── TabBody.tsx
│ │ ├── Tabs.spec.tsx
│ │ ├── Tabs.stories.tsx
│ │ └── Tabs.tsx
│ ├── TextInput/
│ │ ├── TextInput.spec.tsx
│ │ ├── TextInput.stories.tsx
│ │ └── TextInput.tsx
│ ├── Toolbar/
│ │ ├── Toolbar.spec.tsx
│ │ └── Toolbar.tsx
│ ├── Tooltip/
│ │ ├── Tooltip.spec.tsx
│ │ ├── Tooltip.stories.tsx
│ │ └── Tooltip.tsx
│ ├── TreeView/
│ │ ├── TreeView.spec.tsx
│ │ ├── TreeView.stories.tsx
│ │ └── TreeView.tsx
│ ├── Window/
│ │ ├── Window.spec.tsx
│ │ ├── Window.stories.tsx
│ │ ├── Window.tsx
│ │ ├── WindowContent.spec.tsx
│ │ ├── WindowContent.tsx
│ │ ├── WindowHeader.spec.tsx
│ │ └── WindowHeader.tsx
│ ├── assets/
│ │ ├── fonts/
│ │ │ └── src/
│ │ │ ├── ms-sans-serif/
│ │ │ │ ├── license.txt
│ │ │ │ └── readme.txt
│ │ │ └── ms-sans-serif-bold/
│ │ │ ├── license.txt
│ │ │ └── readme.txt
│ │ └── images/
│ │ └── logo.psd
│ ├── common/
│ │ ├── SwitchBase.ts
│ │ ├── constants.ts
│ │ ├── hooks/
│ │ │ ├── useControlledOrUncontrolled.ts
│ │ │ ├── useEventCallback.ts
│ │ │ ├── useForkRef.spec.tsx
│ │ │ ├── useForkRef.ts
│ │ │ ├── useId.spec.ts
│ │ │ ├── useId.ts
│ │ │ └── useIsFocusVisible.ts
│ │ ├── index.ts
│ │ ├── styleReset.ts
│ │ ├── system.ts
│ │ ├── themes/
│ │ │ ├── aiee.ts
│ │ │ ├── ash.ts
│ │ │ ├── azureOrange.ts
│ │ │ ├── bee.ts
│ │ │ ├── blackAndWhite.ts
│ │ │ ├── blue.ts
│ │ │ ├── brick.ts
│ │ │ ├── candy.ts
│ │ │ ├── cherry.ts
│ │ │ ├── coldGray.ts
│ │ │ ├── counterStrike.ts
│ │ │ ├── darkTeal.ts
│ │ │ ├── denim.ts
│ │ │ ├── eggplant.ts
│ │ │ ├── fxDev.ts
│ │ │ ├── highContrast.ts
│ │ │ ├── honey.ts
│ │ │ ├── hotChocolate.ts
│ │ │ ├── hotdogStand.ts
│ │ │ ├── index.ts
│ │ │ ├── lilac.ts
│ │ │ ├── lilacRoseDark.ts
│ │ │ ├── maple.ts
│ │ │ ├── marine.ts
│ │ │ ├── matrix.ts
│ │ │ ├── millenium.ts
│ │ │ ├── modernDark.ts
│ │ │ ├── molecule.ts
│ │ │ ├── monochrome.ts
│ │ │ ├── ninjaTurtles.ts
│ │ │ ├── olive.ts
│ │ │ ├── original.ts
│ │ │ ├── pamelaAnderson.ts
│ │ │ ├── peggysPastels.ts
│ │ │ ├── plum.ts
│ │ │ ├── polarized.ts
│ │ │ ├── powerShell.ts
│ │ │ ├── rainyDay.ts
│ │ │ ├── raspberry.ts
│ │ │ ├── redWine.ts
│ │ │ ├── rose.ts
│ │ │ ├── seawater.ts
│ │ │ ├── shelbiTeal.ts
│ │ │ ├── slate.ts
│ │ │ ├── solarizedDark.ts
│ │ │ ├── solarizedLight.ts
│ │ │ ├── spruce.ts
│ │ │ ├── stormClouds.ts
│ │ │ ├── theSixtiesUSA.ts
│ │ │ ├── tokyoDark.ts
│ │ │ ├── toner.ts
│ │ │ ├── tooSexy.ts
│ │ │ ├── travel.ts
│ │ │ ├── types.ts
│ │ │ ├── vaporTeal.ts
│ │ │ ├── vermillion.ts
│ │ │ ├── violetDark.ts
│ │ │ ├── vistaesqueMidnight.ts
│ │ │ ├── water.ts
│ │ │ ├── white.ts
│ │ │ ├── windows1.ts
│ │ │ └── wmii.ts
│ │ └── utils/
│ │ ├── events.spec.tsx
│ │ ├── events.ts
│ │ ├── index.spec.ts
│ │ └── index.ts
│ ├── index.ts
│ ├── legacy/
│ │ ├── Bar.tsx
│ │ ├── Cutout.tsx
│ │ ├── Desktop.tsx
│ │ ├── Divider.tsx
│ │ ├── Fieldset.tsx
│ │ ├── List.tsx
│ │ ├── ListItem.tsx
│ │ ├── NumberField.tsx
│ │ ├── Panel.tsx
│ │ ├── Progress.tsx
│ │ ├── TextField.tsx
│ │ └── Tree.tsx
│ └── types.ts
├── test/
│ ├── setup-test.ts
│ └── utils.tsx
├── tsconfig.build.index.json
├── tsconfig.build.themes.json
├── tsconfig.json
└── types/
├── globals.d.ts
└── themes.d.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .babelrc
================================================
{
"sourceType": "unambiguous",
"presets": [
[
"@babel/preset-env",
{
"shippedProposals": true,
"loose": true
}
],
"@babel/preset-typescript"
],
"plugins": [
"@babel/plugin-transform-shorthand-properties",
"@babel/plugin-transform-block-scoping",
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
[
"@babel/plugin-proposal-class-properties",
{
"loose": true
}
],
[
"@babel/plugin-proposal-private-property-in-object",
{
"loose": true
}
],
[
"@babel/plugin-proposal-private-methods",
{
"loose": true
}
],
"@babel/plugin-proposal-export-default-from",
"@babel/plugin-syntax-dynamic-import",
[
"@babel/plugin-proposal-object-rest-spread",
{
"loose": true,
"useBuiltIns": true
}
],
"@babel/plugin-transform-classes",
"@babel/plugin-transform-arrow-functions",
"@babel/plugin-transform-parameters",
"@babel/plugin-transform-destructuring",
"@babel/plugin-transform-spread",
"@babel/plugin-transform-for-of",
"babel-plugin-macros",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
}
================================================
FILE: .codesandbox/ci.json
================================================
{
"buildCommand": "build:prod",
"node": "16",
"sandboxes": [
"react95-template-xkfj0"
]
}
================================================
FILE: .editorconfig
================================================
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = yes
insert_final_newline = yes
================================================
FILE: .eslintrc.js
================================================
module.exports = {
extends: [
'plugin:@typescript-eslint/recommended',
'airbnb',
'plugin:prettier/recommended',
'plugin:react-hooks/recommended'
],
parser: '@typescript-eslint/parser',
plugins: ['react', 'prettier'],
env: {
browser: true,
es6: true,
jest: true
},
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-interface': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_\\d*$'
}
],
'import/extensions': ['error', { js: 'never', ts: 'never', tsx: 'never' }],
'import/no-unresolved': [
'error',
// TODO: Remove ../../test/utils when TypeScript migration is complete
{ ignore: ['react95', '../../test/utils'] }
],
'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
'import/prefer-default-export': 'off',
'jsx-a11y/label-has-associated-control': ['error', { assert: 'either' }],
'jsx-a11y/label-has-for': 'off',
'no-nested-ternary': 'off',
'prettier/prettier': 'error',
'react/forbid-prop-types': 'off',
'react/jsx-filename-extension': [
'warn',
{ extensions: ['.js', '.jsx', '.tsx'] }
],
'react/jsx-props-no-spreading': 'off',
'react/no-array-index-key': 'off',
'react/prop-types': 'off',
'react/require-default-props': 'off',
'react/static-property-placement': ['error', 'static public field']
},
overrides: [
{
files: ['*.spec.@(js|jsx|ts|tsx)', '*.stories.@(js|jsx|ts|tsx)'],
rules: {
'no-console': 'off'
}
},
{
files: ['*.@(ts|tsx)'],
rules: {
// This is handled by @typescript-eslint/no-unused-vars
'no-undef': 'off'
}
}
],
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/resolver': {
typescript: {}
}
}
};
================================================
FILE: .firebaserc
================================================
{
"projects": {
"default": "react95-storybook"
}
}
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: arturbien
patreon: arturbien
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
custom: https://www.paypal.me/react95
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Cache packages
uses: actions/cache@v3
with:
key: node_modules-v4-${{ hashFiles('yarn.lock') }}
path: |-
node_modules
*/node_modules
restore-keys: 'node_modules-v4-'
- name: Yarn install
run: yarn install --ignore-optional --frozen-lockfile
- name: Lint
run: yarn run lint
type-check:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Cache packages
uses: actions/cache@v3
with:
key: node_modules-v4-${{ hashFiles('yarn.lock') }}
path: |-
node_modules
*/node_modules
restore-keys: 'node_modules-v4-'
- name: Yarn install
run: yarn install --ignore-optional --frozen-lockfile
- name: Type Check
run: yarn run typescript
test:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Cache packages
uses: actions/cache@v3
with:
key: node_modules-v4-${{ hashFiles('yarn.lock') }}
path: |-
node_modules
*/node_modules
restore-keys: 'node_modules-v4-'
- name: Yarn install
run: yarn install --ignore-optional --frozen-lockfile
- name: Test
run: yarn run test:ci
build-library:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Cache packages
uses: actions/cache@v3
with:
key: node_modules-v4-${{ hashFiles('yarn.lock') }}
path: |-
node_modules
*/node_modules
restore-keys: 'node_modules-v4-'
- name: Yarn install
run: yarn install --ignore-optional --frozen-lockfile
- name: Build library
run: yarn run build
build-storybook:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Cache packages
uses: actions/cache@v3
with:
key: node_modules-v4-${{ hashFiles('yarn.lock') }}
path: |-
node_modules
*/node_modules
restore-keys: 'node_modules-v4-'
- name: Yarn install
run: yarn install --ignore-optional --frozen-lockfile
- name: Build Storybook
run: yarn run build:storybook
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
branches:
- master
- next
- beta
- alpha
- '*.x' # maintenance releases
jobs:
release-library:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Cache packages
uses: actions/cache@v3
with:
key: node_modules-v4-${{ hashFiles('yarn.lock') }}
path: |-
node_modules
*/node_modules
restore-keys: 'node_modules-v4-'
- name: Yarn install
run: yarn install --ignore-optional --frozen-lockfile
- name: Build library
run: yarn run build
- name: Deploy library
run: yarn run semantic-release || true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
release-storybook:
needs:
- release-library
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Cache packages
uses: actions/cache@v3
with:
key: node_modules-v4-${{ hashFiles('yarn.lock') }}
path: |-
node_modules
*/node_modules
restore-keys: 'node_modules-v4-'
- name: Yarn install
run: yarn install --ignore-optional --frozen-lockfile
- name: Build Storybook
run: yarn run build:storybook
- name: Deploy Storybook
run: ./node_modules/.bin/firebase deploy --token=$FIREBASE_TOKEN
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# library build
/cjs
/esm
/themes
/images
/fonts
/dist
# storybook
/storybook
/.firebase
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# JetBrains IDEs
.idea/*
================================================
FILE: .prettierrc
================================================
{
"arrowParens": "avoid",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": true,
"printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}
================================================
FILE: .storybook/decorators/withGlobalStyle.tsx
================================================
import { DecoratorFn } from '@storybook/react';
import React from 'react';
import { createGlobalStyle } from 'styled-components';
import ms_sans_serif from '../../src/assets/fonts/dist/ms_sans_serif.woff2';
import ms_sans_serif_bold from '../../src/assets/fonts/dist/ms_sans_serif_bold.woff2';
import styleReset from '../../src/common/styleReset';
const GlobalStyle = createGlobalStyle`
${styleReset}
@font-face {
font-family: 'ms_sans_serif';
src: url('${ms_sans_serif}') format('woff2');
font-weight: 400;
font-style: normal
}
@font-face {
font-family: 'ms_sans_serif';
src: url('${ms_sans_serif_bold}') format("woff2");
font-weight: bold;
font-style: normal
}
html, body, #root {
height: 100%;
}
#root > * {
height: 100%;
box-sizing: border-box;
}
body {
font-family: 'ms_sans_serif', 'sans-serif';
}
`;
export const withGlobalStyle: DecoratorFn = story => (
<>
{story()}
>
);
================================================
FILE: .storybook/main.ts
================================================
import type { StorybookConfig } from '@storybook/react/types';
import type { PropItem } from 'react-docgen-typescript';
const path = require('path');
const storybookConfig: StorybookConfig = {
stories: ['../@(docs|src)/**/*.stories.@(tsx|mdx)'],
addons: [
{
name: '@storybook/addon-docs',
options: {
sourceLoaderOptions: {
injectStoryParameters: false
}
}
},
'@storybook/addon-storysource',
'./theme-picker/register.ts'
],
core: {
builder: 'webpack5'
},
features: {
babelModeV7: true,
storyStoreV7: true,
modernInlineRender: true,
postcss: false
},
typescript: {
check: false,
checkOptions: {},
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop: PropItem) =>
prop.parent ? !/node_modules/.test(prop.parent.fileName) : true
}
},
webpackFinal: config => {
config.resolve = {
...config.resolve,
alias: {
...config.resolve?.alias,
react95: path.resolve(__dirname, '../src/index')
}
};
return config;
}
};
module.exports = storybookConfig;
================================================
FILE: .storybook/manager.css
================================================
/* Remove from the sidebar menu stories that contains "unstable" */
a[data-item-id$='-unstable'].sidebar-item,
a[data-item-id*='-unstable-'].sidebar-item,
button[data-item-id$='-unstable'].sidebar-item,
button[data-item-id$='-unstable-'].sidebar-item {
display: none !important;
}
================================================
FILE: .storybook/manager.ts
================================================
import './manager.css';
import { addons } from '@storybook/addons';
import theme from './theme';
addons.setConfig({
theme
});
================================================
FILE: .storybook/preview.ts
================================================
import { DecoratorFn, Parameters } from '@storybook/react';
import { withGlobalStyle } from './decorators/withGlobalStyle';
import { withThemesProvider } from './theme-picker/ThemeProvider';
export const decorators: DecoratorFn[] = [withGlobalStyle, withThemesProvider];
export const parameters: Parameters = {
layout: 'fullscreen',
options: {
storySort: {
order: [
'Docs',
[
'Welcome to React95',
'Getting Started',
'Contributing',
'Submit your Project'
],
'Controls',
'Environment',
'Layout',
'Typography',
'Other'
]
}
}
};
================================================
FILE: .storybook/theme-picker/ThemeButton.tsx
================================================
import React, { useCallback } from 'react';
import { ThemeProvider } from 'styled-components';
import { Button } from '../../src/Button/Button';
import { Theme } from '../../src/types';
export function ThemeButton({
active,
onChoose,
theme
}: {
active: boolean;
onChoose: (themeName: string) => void;
theme: Theme;
}) {
const handleClick = useCallback(() => {
onChoose(theme.name);
}, []);
return (
{theme.name}
);
}
================================================
FILE: .storybook/theme-picker/ThemeList.tsx
================================================
import { useAddonState } from '@storybook/api';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import themes from '../../src/common/themes';
import { Theme } from '../../src/types';
import { THEMES_ID } from './constants';
import { ThemeButton } from './ThemeButton';
const {
original,
rainyDay,
vaporTeal,
theSixtiesUSA,
olive,
tokyoDark,
rose,
plum,
matrix,
travel,
...otherThemes
} = themes;
const themeList = [
original,
rainyDay,
vaporTeal,
theSixtiesUSA,
olive,
tokyoDark,
rose,
plum,
matrix,
travel,
...Object.values(otherThemes)
];
type ThemesProps = {
active?: boolean;
};
const Wrapper = styled.div<{ theme: Theme }>`
display: grid;
padding: 1em;
gap: 1em;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-template-rows: repeat(auto-fill, 40px);
background-color: ${({ theme }) => theme.material};
`;
export function ThemeList({ active }: ThemesProps) {
const [themeName, setThemeName] = useAddonState(THEMES_ID, 'original');
const handleChoose = useCallback(
(newThemeName: string) => {
setThemeName(newThemeName);
},
[setThemeName]
);
if (!active) {
return <>>;
}
return (
{themeList.map(theme => (
))}
);
}
================================================
FILE: .storybook/theme-picker/ThemeProvider.tsx
================================================
import { useAddonState } from '@storybook/client-api';
import { DecoratorFn } from '@storybook/react';
import React from 'react';
import { ThemeProvider } from 'styled-components';
import themes from '../../src/common/themes/index';
import { THEMES_ID } from './constants';
export const withThemesProvider: DecoratorFn = story => {
const [themeName] = useAddonState(THEMES_ID, 'original');
return (
{story()}
);
};
================================================
FILE: .storybook/theme-picker/constants.ts
================================================
export const THEMES_ID = 'storybook/themes';
================================================
FILE: .storybook/theme-picker/register.ts
================================================
import addons, { makeDecorator, types } from '@storybook/addons';
import { THEMES_ID } from './constants';
import { ThemeList } from './ThemeList';
addons.register(THEMES_ID, () => {
addons.addPanel(`${THEMES_ID}/panel`, {
title: 'Themes',
type: types.PANEL,
render: ThemeList
});
});
export default makeDecorator({
name: 'withThemesProvider',
parameterName: 'theme',
wrapper: (getStory, context) => getStory(context)
});
================================================
FILE: .storybook/theme.js
================================================
import { create } from '@storybook/theming';
import brandImage from './logo.png';
export default create({
base: 'light',
brandTitle: 'React95',
brandUrl: 'https://react95.io',
brandImage,
brandTarget: '_self',
// UI
appBg: '#dfdfdf',
appContentBg: '#ffffff',
appBorderColor: '#848584',
appBorderRadius: 0,
// Typography
fontBase: '"ms_sans_serif", sans-serif',
fontCode: 'monospace',
// Text colors
textColor: '#0a0a0a',
textInverseColor: 'rgba(255,255,255,0.9)',
// Toolbar default and active colors
barTextColor: '#c6c6c6',
barSelectedColor: '#fefefe',
barBg: '#060084',
// Form colors
inputBg: '#ffffff',
inputBorder: '#dfdfdf',
inputTextColor: '#0a0a0a',
inputBorderRadius: 0
});
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2019 Artur Bień
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
React95
Refreshed Windows95 UI components for your modern React apps. Built with styled-components 💅

### Support
- [Become a backer or sponsor on Patreon](https://www.patreon.com/arturbien)
- [One-time donation via PayPal](https://www.paypal.me/react95)
## Getting Started
First, install component library and styled-components in your project directory:
```sh
# yarn
$ yarn add react95 styled-components
# npm
$ npm install react95 styled-components
```
Apply style reset, wrap your app with ThemeProvider with theme of your choice... and you are ready to go! 🚀
```jsx
import React from 'react';
import { createGlobalStyle, ThemeProvider } from 'styled-components';
import { MenuList, MenuListItem, Separator, styleReset } from 'react95';
// pick a theme of your choice
import original from 'react95/dist/themes/original';
// original Windows95 font (optionally)
import ms_sans_serif from 'react95/dist/fonts/ms_sans_serif.woff2';
import ms_sans_serif_bold from 'react95/dist/fonts/ms_sans_serif_bold.woff2';
const GlobalStyles = createGlobalStyle`
${styleReset}
@font-face {
font-family: 'ms_sans_serif';
src: url('${ms_sans_serif}') format('woff2');
font-weight: 400;
font-style: normal
}
@font-face {
font-family: 'ms_sans_serif';
src: url('${ms_sans_serif_bold}') format('woff2');
font-weight: bold;
font-style: normal
}
body {
font-family: 'ms_sans_serif';
}
`;
const App = () => (
🎤 Sing
💃🏻 Dance
😴 Sleep
);
export default App;
```
### Submit your project
Apps built with React95 will be featured on the official React95 [website](https://react95.io) 🤟🏻
### Contributing
Any help from UI / UX designers would be EXTREMELY appreciated. The challenge is to come up with new component designs / layouts that are broadly used in modern UIs, that weren't present back in 95.
If you want to help with the project, feel free to open pull requests and submit issues or component proposals. Let's bring this UI back to life ♥️
================================================
FILE: docs/Contributing.stories.mdx
================================================
import { Meta } from '@storybook/addon-docs';
# Contributing
Any help from UI/UX designers would be EXTREMELY appreciated. The challenge is
to come up with new component designs/layouts that are broadly used in modern
UIs, that weren't present back in 95.
If you want to help with the project, feel free to [open pull requests][1],
[submit issues or component proposals][2] and join our [Slack channels][3]!
Let's bring this UI back to life ♥️
[1]: https://github.com/arturbien/react95/pulls
[2]: https://github.com/arturbien/React95/issues
[3]: https://join.slack.com/t/react95/shared_invite/enQtOTA1NzEyNjAyNTc4LWYxZjU3NWRiMWJlMGJiMjhkNzE2MDA3ZmZjZDc1YmY0ODdlZjMwZDA1NWJiYWExYmY1NTJmNmE4OWVjNWFhMTE
================================================
FILE: docs/Getting-Started.stories.mdx
================================================
import { Meta } from '@storybook/addon-docs';
# Installation
React95 is available as an [npm package](https://www.npmjs.com/package/react95).
## npm
To install and save your `package.json` dependencies, run:
```sh
# yarn
yarn add react95 styled-components
# npm
npm install -S react95 styled-components
```
In order to have `react95` working properly, you'll also need
[styled-components 💅](https://github.com/styled-components/styled-components),
this way you can use custom themes and get the best of the library 🙂
## Usage
Apply style reset, wrap your app content with ThemeProvider with theme of your
choice... and you are ready to go! 🚀
```jsx
import React from 'react';
import { MenuList, MenuListItem, Separator, styleReset } from 'react95';
import { createGlobalStyle, ThemeProvider } from 'styled-components';
/* Pick a theme of your choice */
import original from 'react95/dist/themes/original';
/* Original Windows95 font (optional) */
import ms_sans_serif from 'react95/dist/fonts/ms_sans_serif.woff2';
import ms_sans_serif_bold from 'react95/dist/fonts/ms_sans_serif_bold.woff2';
const GlobalStyles = createGlobalStyle`
${styleReset}
@font-face {
font-family: 'ms_sans_serif';
src: url('${ms_sans_serif}') format('woff2');
font-weight: 400;
font-style: normal
}
@font-face {
font-family: 'ms_sans_serif';
src: url('${ms_sans_serif_bold}') format('woff2');
font-weight: bold;
font-style: normal
}
body, input, select, textarea {
font-family: 'ms_sans_serif';
}
`;
const App = () => (
🎤 Sing
💃🏻 Dance
😴 Sleep
);
export default App;
```
================================================
FILE: docs/Submit-your-Project.stories.mdx
================================================
import { Meta } from '@storybook/addon-docs';
# Submit your Project
Apps built with React95 will be featured on the official React95 [website](https://react95.io) 🤟🏻
In order to submit your project, just drop a comment on [this issue](https://github.com/arturbien/React95/issues/25)!
================================================
FILE: docs/Welcome.stories.mdx
================================================
import { Meta } from '@storybook/addon-docs';
# Welcome to React95
**Refreshed** Windows 95 UI components for your modern React apps. Built with
[styled-components](https://github.com/styled-components/styled-components) 💅.

### Getting Started
Check out our [getting started](?path=/story/docs-getting-started--page) docs!
### Motivation
Create modern mobile/web applications with the retro and old school Windows 95 style. Our goal is not to exactly recreate Windows95 components, but to provide a solid component library for current scenarios.
### Support
- [Become a backer or sponsor on Patreon](https://www.patreon.com/arturbien)
- [One-time donation via PayPal](https://www.paypal.me/react95)
================================================
FILE: firebase.json
================================================
{
"hosting": {
"public": "storybook",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}
================================================
FILE: jest.config.js
================================================
module.exports = {
globals: {
'ts-jest': {
diagnostics: false,
isolatedModules: true
}
},
coverageReporters: ['text', 'html'],
preset: 'ts-jest/presets/default-esm',
setupFilesAfterEnv: ['/test/setup-test.ts'],
testEnvironment: 'jsdom'
};
================================================
FILE: package.json
================================================
{
"name": "react95",
"version": "0.0.0-development",
"description": "Refreshed Windows95 UI components for modern web apps - React95",
"keywords": [
"react",
"styled-components",
"windows95",
"components",
"vaporwave"
],
"author": "Artur Bień (https://www.linkedin.com/in/arturbien/)",
"funding": [
{
"type": "paypal",
"url": "https://www.paypal.me/react95"
},
{
"type": "patreon",
"url": "https://www.patreon.com/arturbien"
}
],
"license": "MIT",
"repository": "git@github.com:arturbien/React95.git",
"homepage": "https://react95.io",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"/dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"start": "cross-env STORYBOOK_DISPLAY_WARNING=true DISPLAY_WARNING=true start-storybook -p 9009 --no-open",
"build:storybook": "cross-env STORYBOOK_DISPLAY_WARNING=true DISPLAY_WARNING=true build-storybook -o ./storybook",
"build": "rm -rf dist && yarn run build:prod",
"build:dev": "cross-env NODE_ENV=development rollup -c",
"build:prod": "cross-env NODE_ENV=production rollup -c",
"test": "jest ./src",
"test:ci": "jest ./src --maxWorkers=2",
"test:watch": "jest ./src --watch",
"test:coverage": "jest ./src --coverage",
"typescript": "tsc --noEmit",
"lint": "eslint --ext .js,.ts,.tsx src",
"lint:fix": "yarn run lint --fix",
"semantic-release": "semantic-release",
"cz": "git-cz"
},
"peerDependencies": {
"react": ">= 16.8.0",
"react-dom": ">= 16.8.0",
"styled-components": ">= 5.3.3"
},
"devDependencies": {
"@babel/core": "^7.18.9",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-decorators": "^7.18.10",
"@babel/plugin-proposal-export-default-from": "^7.18.10",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
"@babel/plugin-proposal-object-rest-spread": "^7.18.9",
"@babel/plugin-proposal-optional-chaining": "^7.18.9",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.18.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-arrow-functions": "^7.18.6",
"@babel/plugin-transform-block-scoping": "^7.18.9",
"@babel/plugin-transform-classes": "^7.18.9",
"@babel/plugin-transform-destructuring": "^7.18.9",
"@babel/plugin-transform-for-of": "^7.18.8",
"@babel/plugin-transform-parameters": "^7.18.8",
"@babel/plugin-transform-shorthand-properties": "^7.18.6",
"@babel/plugin-transform-spread": "^7.18.9",
"@babel/preset-env": "^7.18.10",
"@babel/preset-typescript": "^7.18.6",
"@rollup/plugin-typescript": "^8.3.4",
"@storybook/addon-docs": "6.5.10",
"@storybook/addon-storysource": "6.5.10",
"@storybook/builder-webpack5": "^6.5.10",
"@storybook/manager-webpack5": "^6.5.10",
"@storybook/react": "6.5.10",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@types/jest": "^28.1.6",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/styled-components": "^5.1.25",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-polyfill-corejs3": "^0.5.3",
"commitizen": "^4.2.5",
"cross-env": "^7.0.3",
"cz-conventional-changelog": "^3.3.0",
"esbuild": "^0.14.53",
"eslint": "^8.21.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.4.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"firebase-tools": "^11.4.2",
"husky": "^8.0.1",
"jest": "^28.1.3",
"jest-environment-jsdom": "^28.1.3",
"jest-styled-components": "^7.0.8",
"lint-staged": "^13.0.3",
"prettier": "^2.7.1",
"react": "^17.0.2",
"react-docgen-typescript": "^2.2.2",
"react-dom": "^17.0.2",
"rimraf": "^3.0.2",
"rollup": "^2.77.2",
"rollup-plugin-copy": "^3.4.0",
"rollup-plugin-esbuild": "^4.9.1",
"rollup-plugin-replace": "^2.2.0",
"semantic-release": "^19.0.3",
"styled-components": "^5.3.5",
"ts-jest": "^28.0.7",
"typescript": "^4.7.4",
"webpack": "5"
},
"dependencies": {},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.js": [
"eslint --fix",
"prettier --write",
"git add"
]
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}
================================================
FILE: rollup.config.js
================================================
import typescript from '@rollup/plugin-typescript';
import copy from 'rollup-plugin-copy';
import esbuild from 'rollup-plugin-esbuild';
import replace from 'rollup-plugin-replace';
const NODE_ENV = process.env.NODE_ENV || 'development';
const baseBundle = {
external: id => !/^[./]/.test(id),
plugins: [
replace({
'process.env.NODE_ENV': JSON.stringify(NODE_ENV)
}),
esbuild()
]
};
export default [
{
...baseBundle,
input: ['./src/index.ts', './src/types.ts'],
output: [
{
dir: 'dist',
entryFileNames: '[name].js',
exports: 'auto',
format: 'cjs',
preserveModules: true,
preserveModulesRoot: 'src'
},
{
dir: 'dist',
entryFileNames: '[name].mjs',
exports: 'auto',
format: 'es',
preserveModules: true,
preserveModulesRoot: 'src'
}
],
plugins: [
...baseBundle.plugins,
typescript({
tsconfig: './tsconfig.build.index.json',
declaration: true,
declarationDir: 'dist'
})
]
},
{
...baseBundle,
input: './src/common/themes/index.ts',
output: {
dir: 'dist/themes',
exports: 'default',
format: 'cjs',
preserveModules: true,
preserveModulesRoot: 'src/common/themes'
},
plugins: [
...baseBundle.plugins,
copy({
targets: [
{ src: './src/assets/fonts/dist/*', dest: './dist/fonts' },
{ src: './src/assets/images/*', dest: './dist/images' }
]
}),
typescript({
tsconfig: './tsconfig.build.themes.json',
declaration: true,
declarationDir: 'dist/themes'
})
]
}
];
================================================
FILE: src/Anchor/Anchor.spec.tsx
================================================
import React from 'react';
import { render } from '@testing-library/react';
import { Anchor } from './Anchor';
const defaultProps = {
children: '',
href: ''
};
describe(' ', () => {
it('should render href', () => {
const { container } = render(
);
const anchorEl = container.firstChild;
expect(anchorEl).toHaveAttribute('href', 'http://yoda.com');
});
it('should render children', () => {
const { container } = render(
You shall pass
);
const anchorEl = container.firstChild;
expect(anchorEl).toHaveTextContent('You shall pass');
});
it('should render custom style', () => {
const { container } = render(
);
const anchorEl = container.firstChild;
expect(anchorEl).toHaveAttribute('style', 'color: papayawhip;');
});
it('should render custom props', () => {
const customProps = { target: '_blank' };
const { container } = render( );
const anchorEl = container.firstChild;
expect(anchorEl).toHaveAttribute('target', '_blank');
});
});
================================================
FILE: src/Anchor/Anchor.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { Anchor } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.material};
`;
export default {
title: 'Typography/Anchor',
component: Anchor,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
Everybody likes{' '}
https://expensive.toys
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Anchor/Anchor.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { CommonStyledProps } from '../types';
type AnchorProps = {
children: React.ReactNode;
underline?: boolean;
} & React.AnchorHTMLAttributes &
CommonStyledProps;
const StyledAnchor = styled.a<{ underline: boolean }>`
color: ${({ theme }) => theme.anchor};
font-size: inherit;
text-decoration: ${({ underline }) => (underline ? 'underline' : 'none')};
&:visited {
color: ${({ theme }) => theme.anchorVisited};
}
`;
const Anchor = forwardRef(
({ children, underline = true, ...otherProps }: AnchorProps, ref) => {
return (
{children}
);
}
);
Anchor.displayName = 'Anchor';
export { Anchor, AnchorProps };
================================================
FILE: src/AppBar/AppBar.spec.tsx
================================================
import { render } from '@testing-library/react';
import React from 'react';
import { AppBar } from './AppBar';
const defaultProps = { children: '' };
describe(' ', () => {
it('should render header', () => {
const { container } = render( );
const headerEl = container.firstElementChild;
expect(headerEl && headerEl.tagName).toBe('HEADER');
});
it('should render children', () => {
const { container } = render(A nice app bar );
const headerEl = container.firstElementChild;
expect(headerEl).toHaveTextContent('A nice app bar');
});
it('should render fixed prop properly', () => {
const { container, rerender } = render( );
const headerEl = container.firstElementChild;
expect(headerEl).toHaveStyleRule('position', 'fixed');
rerender( );
expect(headerEl).toHaveStyleRule('position', 'absolute');
});
it('should render position prop properly', () => {
const { container } = render(
);
const headerEl = container.firstElementChild;
expect(headerEl).toHaveStyleRule('position', 'sticky');
});
it('should custom style', () => {
const { container } = render(
);
const headerEl = container.firstElementChild;
expect(headerEl).toHaveAttribute('style', 'background-color: papayawhip;');
});
it('should render custom props', () => {
const customProps = { title: 'cool-header' };
const { container } = render( );
const headerEl = container.firstElementChild;
expect(headerEl).toHaveAttribute('title', 'cool-header');
});
});
================================================
FILE: src/AppBar/AppBar.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
import {
AppBar,
Button,
MenuList,
MenuListItem,
Separator,
TextInput,
Toolbar
} from 'react95';
import styled from 'styled-components';
import logoIMG from '../assets/images/logo.png';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Environment/AppBar',
component: AppBar,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
const [open, setOpen] = useState(false);
return (
setOpen(!open)}
active={open}
style={{ fontWeight: 'bold' }}
>
Start
{open && (
setOpen(false)}
>
👨💻
Profile
📁
My account
🔙
Logout
)}
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/AppBar/AppBar.tsx
================================================
import React, { forwardRef } from 'react';
import styled, { CSSProperties } from 'styled-components';
import { createBorderStyles, createBoxStyles } from '../common';
import { CommonStyledProps } from '../types';
type AppBarProps = {
children: React.ReactNode;
/** @deprecated Use `position` instead */
fixed?: boolean;
position?: CSSProperties['position'];
} & React.HTMLAttributes &
CommonStyledProps;
const StyledAppBar = styled.header`
${createBorderStyles()};
${createBoxStyles()};
position: ${props => props.position ?? (props.fixed ? 'fixed' : 'absolute')};
top: 0;
right: 0;
left: auto;
display: flex;
flex-direction: column;
width: 100%;
`;
const AppBar = forwardRef(
({ children, fixed = true, position = 'fixed', ...otherProps }, ref) => {
return (
{children}
);
}
);
AppBar.displayName = 'AppBar';
export { AppBar, AppBarProps };
================================================
FILE: src/Avatar/Avatar.spec.tsx
================================================
import { render } from '@testing-library/react';
import React from 'react';
import { renderWithTheme, theme } from '../../test/utils';
import { Avatar } from './Avatar';
describe(' ', () => {
it('should render component', () => {
const { container } = render( );
expect(container).toBeInTheDocument();
});
it('should render children', () => {
const { container } = render(Avatar children );
const avatarEl = container.firstElementChild;
expect(avatarEl && avatarEl.innerHTML).toBe('Avatar children');
});
it('should handle border properly', () => {
const { container, rerender } = renderWithTheme(
);
const avatarEl = container.firstElementChild;
expect(avatarEl).toHaveStyleRule(
'border-top',
`2px solid ${theme.borderDark}`
);
rerender( );
expect(avatarEl).not.toHaveStyleRule('border-top', '');
});
it('should handle square properly', () => {
const { container, rerender } = render( );
const avatarEl = container.firstElementChild;
expect(avatarEl).toHaveStyleRule('border-radius', '0');
rerender( );
expect(avatarEl).toHaveStyleRule('border-radius', '50%');
});
it('should render with source', async () => {
const catGif = 'https://cdn2.thecatapi.com/images/1ac.gif';
const { findByAltText } = render( );
const imageEl = (await findByAltText('cat avatar')) as HTMLImageElement;
expect(imageEl && imageEl.src).toBe(catGif);
});
it('should render source with priority over children', async () => {
const catGif = 'https://cdn2.thecatapi.com/images/1ac.gif';
const { queryByText } = render(
Cats are cool
);
const content = await queryByText(/cats are cool/i);
expect(content).toBeNull();
});
describe('prop: size', () => {
it('should set proper size', () => {
const { container } = renderWithTheme( );
const avatarEl = container.firstElementChild;
expect(avatarEl).toHaveStyleRule('width', '85%');
expect(avatarEl).toHaveStyleRule('height', '85%');
});
it('when passed a number, sets size in px', () => {
const { container } = renderWithTheme( );
const avatarEl = container.firstElementChild;
expect(avatarEl).toHaveStyleRule('width', '25px');
expect(avatarEl).toHaveStyleRule('height', '25px');
});
});
});
================================================
FILE: src/Avatar/Avatar.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { Avatar } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.material};
& > div > * {
margin-right: 1rem;
}
`;
export default {
title: 'Other/Avatar',
component: Avatar,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Avatar/Avatar.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { getSize } from '../common/utils';
import { CommonStyledProps } from '../types';
type AvatarProps = {
alt?: string;
children?: React.ReactNode;
noBorder?: boolean;
size?: string | number;
square?: boolean;
src?: string;
} & React.HTMLAttributes &
CommonStyledProps;
const StyledAvatar = styled.div<
Pick & { size?: string }
>`
display: inline-block;
box-sizing: border-box;
object-fit: contain;
${({ size }) =>
`
height: ${size};
width: ${size};
`}
border-radius: ${({ square }) => (square ? 0 : '50%')};
overflow: hidden;
${({ noBorder, theme }) =>
!noBorder &&
`
border-top: 2px solid ${theme.borderDark};
border-left: 2px solid ${theme.borderDark};
border-bottom: 2px solid ${theme.borderLightest};
border-right: 2px solid ${theme.borderLightest};
background: ${theme.material};
`}
${({ src }) =>
!src &&
`
display: flex;
align-items: center;
justify-content: space-around;
font-weight: bold;
font-size: 1rem;
`}
`;
const StyledAvatarImg = styled.img`
display: block;
object-fit: contain;
width: 100%;
height: 100%;
`;
const Avatar = forwardRef(
(
{
alt = '',
children,
noBorder = false,
size = 35,
square = false,
src,
...otherProps
},
ref
) => {
return (
{src ? : children}
);
}
);
Avatar.displayName = 'Avatar';
export { Avatar, AvatarProps };
================================================
FILE: src/Button/Button.spec.tsx
================================================
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { renderWithTheme, theme } from '../../test/utils';
import { blockSizes } from '../common/system';
import { Button } from './Button';
const defaultProps = {
children: 'click me'
};
describe(' ', () => {
it('should render button', () => {
const { getByRole } = render( );
const button = getByRole('button');
expect(button).toBeInTheDocument();
expect(button.tagName).toBe('BUTTON');
});
it('should handle different types', () => {
const { getByRole } = render( );
const button = getByRole('button');
expect(button).toHaveAttribute('type', 'submit');
});
it('should handle click properly', () => {
const onButtonClick = jest.fn();
const { getByRole } = render(
);
const button = getByRole('button');
fireEvent.click(button);
expect(onButtonClick).toHaveBeenCalled();
});
it('should handle disabled for all variants', () => {
const { getByRole, rerender } = renderWithTheme(
);
const button = getByRole('button');
const disabledTextShadow = `1px 1px ${theme.materialTextDisabledShadow}`;
expect(button).toHaveStyleRule('color', theme.materialTextDisabled);
expect(button).toHaveStyleRule('text-shadow', disabledTextShadow);
rerender( );
expect(button).toHaveStyleRule('color', theme.materialTextDisabled);
expect(button).toHaveStyleRule('text-shadow', disabledTextShadow);
rerender( );
expect(button).toHaveStyleRule('color', theme.materialTextDisabled);
expect(button).toHaveStyleRule('text-shadow', disabledTextShadow);
rerender( );
expect(button).toHaveStyleRule('color', theme.materialTextDisabled);
expect(button).toHaveStyleRule('text-shadow', disabledTextShadow);
});
it('should handle fullWidth prop', () => {
const { getByRole, rerender } = render(
);
const button = getByRole('button');
expect(button).toHaveStyleRule('width', '100%');
rerender( );
expect(button).toHaveStyleRule('width', 'auto');
});
it('should handle button sizes properly', () => {
const { getByRole, rerender } = render(
);
const button = getByRole('button');
expect(button).toHaveStyleRule('height', blockSizes.sm);
rerender( );
expect(button).toHaveStyleRule('height', blockSizes.lg);
});
it('should handle square prop', () => {
const { getByRole } = render( );
const button = getByRole('button');
expect(button).toHaveStyleRule('padding', '0');
expect(button).toHaveStyleRule('width', blockSizes.md);
});
it('should render children', () => {
const { getByRole } = render( );
const button = getByRole('button');
expect(button.innerHTML).toBe('click me');
});
describe('prop: disabled', () => {
it('should render disabled', () => {
const { getByRole } = render( );
const button = getByRole('button');
expect(button).toHaveAttribute('disabled');
});
it('should not fire click when disabled', () => {
const onButtonClick = jest.fn();
const { getByRole } = render( );
const button = getByRole('button');
fireEvent.click(button);
expect(onButtonClick).not.toHaveBeenCalled();
});
});
});
================================================
FILE: src/Button/Button.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
import {
Button,
MenuList,
MenuListItem,
ScrollView,
Separator,
Toolbar,
Window,
WindowContent,
WindowHeader
} from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.material};
#default-buttons button {
margin-bottom: 1rem;
margin-right: 1rem;
}
#cutout {
background: ${({ theme }) => theme.canvas};
padding: 1rem;
width: 300px;
}
`;
export default {
title: 'Controls/Button',
component: Button,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
Default
Primary
Disabled
Active
♻︎
Full width
Size small
Size large
);
}
Default.story = {
name: 'default'
};
export function Raised() {
return (
Default
Primary
Disabled
Active
♻︎
Full width
Size small
Size large
);
}
Raised.story = {
name: 'raised'
};
export function Flat() {
return (
When you want to use Buttons on a light background (like scrollable
content), just use the flat variant:
Primary
Regular
Disabled
);
}
Flat.story = {
name: 'flat'
};
const imageSrc =
'https://image.freepik.com/foto-gratuito/la-frutta-fresca-del-kiwi-tagliata-a-meta-con-la-decorazione-completa-del-pezzo-e-bella-sulla-tavola-di-legno_47436-1.jpg';
export function Thin() {
const [open, setOpen] = useState(false);
return (
🥝
Kiwi.app
Upload
Save
setOpen(!open)}
size='sm'
active={open}
>
Share
{open && (
setOpen(false)}
>
Copy link
Facebook
Twitter
Instagram
MySpace
)}
);
}
Thin.story = {
name: 'thin'
};
================================================
FILE: src/Button/Button.tsx
================================================
import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import {
createBorderStyles,
createBoxStyles,
createDisabledTextStyles,
createFlatBoxStyles,
createHatchedBackground,
focusOutline
} from '../common';
import { blockSizes } from '../common/system';
import { noOp } from '../common/utils';
import { CommonStyledProps, Sizes } from '../types';
type ButtonProps = {
active?: boolean;
children?: React.ReactNode;
disabled?: boolean;
fullWidth?: boolean;
onClick?: React.ButtonHTMLAttributes['onClick'];
onTouchStart?: React.ButtonHTMLAttributes['onTouchStart'];
primary?: boolean;
size?: Sizes;
square?: boolean;
type?: string;
} & (
| {
variant?: 'default' | 'raised' | 'flat' | 'thin';
}
| {
/** @deprecated Use `thin` */
variant?: 'menu';
}
) &
Omit<
React.ButtonHTMLAttributes,
'disabled' | 'onClick' | 'onTouchStart' | 'type'
> &
CommonStyledProps;
type StyledButtonProps = Pick<
ButtonProps,
| 'active'
| 'disabled'
| 'fullWidth'
| 'primary'
| 'size'
| 'square'
| 'variant'
>;
const commonButtonStyles = css`
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: ${({ size = 'md' }) => blockSizes[size]};
width: ${({ fullWidth, size = 'md', square }) =>
fullWidth ? '100%' : square ? blockSizes[size] : 'auto'};
padding: ${({ square }) => (square ? 0 : `0 10px`)};
font-size: 1rem;
user-select: none;
&:active {
padding-top: ${({ disabled }) => !disabled && '2px'};
}
padding-top: ${({ active, disabled }) => active && !disabled && '2px'};
&:after {
content: '';
position: absolute;
display: block;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
&:not(:disabled) {
cursor: pointer;
}
font-family: inherit;
`;
export const StyledButton = styled.button`
${({ active, disabled, primary, theme, variant }) =>
variant === 'flat'
? css`
${createFlatBoxStyles()}
${primary
? `
border: 2px solid ${theme.checkmark};
outline: 2px solid ${theme.flatDark};
outline-offset: -4px;
`
: `
border: 2px solid ${theme.flatDark};
outline: 2px solid transparent;
outline-offset: -4px;
`}
&:focus:after, &:active:after {
${!active && !disabled && focusOutline}
outline-offset: -4px;
}
`
: variant === 'menu' || variant === 'thin'
? css`
${createBoxStyles()};
border: 2px solid transparent;
&:hover,
&:focus {
${!disabled &&
!active &&
createBorderStyles({ style: 'buttonThin' })}
}
&:active {
${!disabled && createBorderStyles({ style: 'buttonThinPressed' })}
}
${active && createBorderStyles({ style: 'buttonThinPressed' })}
${disabled && createDisabledTextStyles()}
`
: css`
${createBoxStyles()};
border: none;
${disabled && createDisabledTextStyles()}
${active
? createHatchedBackground({
mainColor: theme.material,
secondaryColor: theme.borderLightest
})
: ''}
&:before {
box-sizing: border-box;
content: '';
position: absolute;
${primary
? css`
left: 2px;
top: 2px;
width: calc(100% - 4px);
height: calc(100% - 4px);
outline: 2px solid ${theme.borderDarkest};
`
: css`
left: 0;
top: 0;
width: 100%;
height: 100%;
`}
${active
? createBorderStyles({
style: variant === 'raised' ? 'window' : 'button',
invert: true
})
: createBorderStyles({
style: variant === 'raised' ? 'window' : 'button',
invert: false
})}
}
&:active:before {
${!disabled &&
createBorderStyles({
style: variant === 'raised' ? 'window' : 'button',
invert: true
})}
}
&:focus:after,
&:active:after {
${!disabled && focusOutline}
outline-offset: -8px;
}
&:active:focus:after,
&:active:after {
top: ${active ? '0' : '1px'};
}
`}
${commonButtonStyles}
`;
const Button = forwardRef(
(
{
onClick,
disabled = false,
children,
type = 'button',
fullWidth = false,
size = 'md',
square = false,
active = false,
onTouchStart = noOp,
primary = false,
variant = 'default',
...otherProps
},
ref
) => {
return (
{children}
);
}
);
Button.displayName = 'Button';
export { Button, ButtonProps };
================================================
FILE: src/Checkbox/Checkbox.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { Checkbox } from './Checkbox';
describe(' ', () => {
describe('label', () => {
it('renders', () => {
const labelText = 'Swag';
const { getByLabelText } = renderWithTheme(
);
expect(getByLabelText(labelText)).toBeInTheDocument();
});
});
describe('prop: onChange', () => {
it('should call onChange when uncontrolled', () => {
const handleChange = jest.fn(event => event.target.checked);
const { getByRole } = renderWithTheme(
);
getByRole('checkbox').click();
expect(handleChange).toHaveBeenCalledTimes(1);
// event.target.check is true
expect(handleChange.mock.results[0].value).toBe(true);
});
it('should call onChange when controlled', () => {
const checked = true;
const handleChange = jest.fn(event => event.target.checked);
const { getByRole } = renderWithTheme(
);
getByRole('checkbox').click();
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange.mock.results[0].value).toBe(!checked);
});
});
describe('prop: disabled', () => {
it('should disable checkbox', () => {
const handleChange = jest.fn();
const { getByRole } = renderWithTheme(
);
const checkbox = getByRole('checkbox');
expect(checkbox).toHaveAttribute('disabled');
checkbox.click();
expect(handleChange).not.toHaveBeenCalled();
});
it('should be overridden by props', () => {
const { getByRole, rerender } = renderWithTheme( );
rerender( );
const checkbox = getByRole('checkbox');
expect(checkbox).not.toHaveAttribute('disabled');
});
});
describe('prop: indeterminate', () => {
it('renders indeterminate state', () => {
const { getByRole } = renderWithTheme( );
const checkbox = getByRole('checkbox');
// don't set native 'indeterminate' attribute because
// different browsers treat it differently
// instead we're setting 'data-indeterminate' attribute
expect(checkbox).toHaveAttribute('data-indeterminate');
expect(checkbox).not.toHaveAttribute('indeterminate');
expect(getByRole('presentation').firstChild).toHaveAttribute(
'data-testid',
'indeterminateIcon'
);
});
it('replaces checked icon', () => {
const { getByRole, rerender } = renderWithTheme( );
expect(getByRole('checkbox')).toHaveAttribute(
'data-indeterminate',
'false'
);
rerender( );
expect(getByRole('checkbox')).toHaveAttribute(
'data-indeterminate',
'true'
);
expect(getByRole('presentation').firstChild).toHaveAttribute(
'data-testid',
'indeterminateIcon'
);
});
});
describe('controlled', () => {
it('should check the checkbox', () => {
const { getByRole, rerender } = renderWithTheme(
);
rerender( );
const checkbox = getByRole('checkbox') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
expect(getByRole('checkbox')).toHaveAttribute('checked');
expect(getByRole('presentation').firstChild).toHaveAttribute(
'data-testid',
'checkmarkIcon'
);
});
it('should uncheck the checkbox', () => {
const { getByRole, rerender } = renderWithTheme( );
rerender( );
const checkbox = getByRole('checkbox') as HTMLInputElement;
expect(checkbox.checked).toBe(false);
expect(getByRole('checkbox')).not.toHaveAttribute('checked');
expect(getByRole('presentation').firstChild).toBeNull();
// check if proper icon was rendered
});
});
describe('uncontrolled', () => {
it('can change checked state uncontrolled starting from defaultChecked', () => {
const { getByRole } = renderWithTheme( );
const checkbox = getByRole('checkbox') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
checkbox.click();
expect(checkbox.checked).toBe(false);
checkbox.click();
expect(checkbox.checked).toBe(true);
});
});
});
================================================
FILE: src/Checkbox/Checkbox.stories.tsx
================================================
import React, { useState } from 'react';
import styled from 'styled-components';
import { ComponentMeta } from '@storybook/react';
import { Checkbox, GroupBox, ScrollView } from 'react95';
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
#cutout {
background: ${({ theme }) => theme.canvas};
padding: 1rem;
width: 250px;
display: flex;
align-items: center;
justify-content: space-around;
}
`;
export default {
title: 'Controls/Checkbox',
component: Checkbox,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
const [state, setState] = useState({
cheese: true,
bacon: false,
broccoli: false
});
const { cheese, bacon, broccoli } = state;
const ingredientsArr = Object.values(state).map(val => (val ? 1 : 0));
const possibleIngredients = Object.keys(state).length;
const chosenIngredients = ingredientsArr.reduce((a, b) => a + b, 0);
const isIndeterminate = ![0, possibleIngredients].includes(chosenIngredients);
const toggleAll = () => {
console.log(ingredientsArr);
if (isIndeterminate) {
setState({
cheese: true,
bacon: true,
broccoli: true
});
} else if (ingredientsArr[0] === 1) {
setState({
cheese: false,
bacon: false,
broccoli: false
});
} else {
setState({
cheese: true,
bacon: true,
broccoli: true
});
}
};
const toggleIngredient = (e: React.ChangeEvent) => {
const value = e.target.value as 'cheese' | 'bacon' | 'broccoli';
setState({
...state,
[value]: !state[value]
});
};
return (
);
}
Default.story = {
name: 'default'
};
export function Flat() {
const [state, setState] = useState({
cheese: true,
bacon: false,
broccoli: false
});
const { cheese, bacon, broccoli } = state;
const ingredientsArr = Object.values(state).map(val => (val ? 1 : 0));
const possibleIngredients = Object.keys(state).length;
const chosenIngredients = ingredientsArr.reduce((a, b) => a + b, 0);
const isIndeterminate = ![0, possibleIngredients].includes(chosenIngredients);
const toggleAll = () => {
console.log(ingredientsArr);
if (isIndeterminate) {
setState({
cheese: true,
bacon: true,
broccoli: true
});
} else if (ingredientsArr[0] === 1) {
setState({
cheese: false,
bacon: false,
broccoli: false
});
} else {
setState({
cheese: true,
bacon: true,
broccoli: true
});
}
};
const toggleIngredient = (e: React.ChangeEvent) => {
const value = e.target.value as 'cheese' | 'bacon' | 'broccoli';
setState({
...state,
[value]: !state[value]
});
};
return (
);
}
Flat.story = {
name: 'flat'
};
================================================
FILE: src/Checkbox/Checkbox.tsx
================================================
import React, { forwardRef, useCallback } from 'react';
import styled, { css } from 'styled-components';
import { createHatchedBackground } from '../common';
import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
import {
LabelText,
size,
StyledInput,
StyledLabel
} from '../common/SwitchBase';
import { noOp } from '../common/utils';
import { StyledScrollView } from '../ScrollView/ScrollView';
import { CommonThemeProps } from '../types';
type CheckboxProps = {
checked?: boolean;
className?: string;
defaultChecked?: boolean;
disabled?: boolean;
indeterminate?: boolean;
label?: number | string;
name?: string;
onChange?: React.ChangeEventHandler;
style?: React.CSSProperties;
value?: number | string;
variant?: 'default' | 'flat';
} & Omit<
React.InputHTMLAttributes,
| 'checked'
| 'className'
| 'defaultChecked'
| 'disabled'
| 'label'
| 'name'
| 'onChange'
| 'style'
| 'value'
>;
type CheckmarkProps = {
$disabled: boolean;
variant: 'default' | 'flat';
};
const sharedCheckboxStyles = css`
width: ${size}px;
height: ${size}px;
display: flex;
align-items: center;
justify-content: space-around;
margin-right: 0.5rem;
`;
const StyledCheckbox = styled(StyledScrollView)`
${sharedCheckboxStyles}
width: ${size}px;
height: ${size}px;
background: ${({ $disabled, theme }) =>
$disabled ? theme.material : theme.canvas};
&:before {
box-shadow: none;
}
`;
const StyledFlatCheckbox = styled.div`
position: relative;
box-sizing: border-box;
display: inline-block;
background: ${({ $disabled, theme }) =>
$disabled ? theme.flatLight : theme.canvas};
${sharedCheckboxStyles}
width: ${size - 4}px;
height: ${size - 4}px;
outline: none;
border: 2px solid ${({ theme }) => theme.flatDark};
background: ${({ $disabled, theme }) =>
$disabled ? theme.flatLight : theme.canvas};
`;
const CheckmarkIcon = styled.span.attrs(() => ({
'data-testid': 'checkmarkIcon'
}))`
display: inline-block;
position: relative;
width: 100%;
height: 100%;
&:after {
content: '';
display: block;
position: absolute;
left: 50%;
top: calc(50% - 1px);
width: 3px;
height: 7px;
border: solid
${({ $disabled, theme }) =>
$disabled ? theme.checkmarkDisabled : theme.checkmark};
border-width: 0 3px 3px 0;
transform: translate(-50%, -50%) rotate(45deg);
border-color: ${p =>
p.$disabled ? p.theme.checkmarkDisabled : p.theme.checkmark};
}
`;
const IndeterminateIcon = styled.span.attrs(() => ({
'data-testid': 'indeterminateIcon'
}))`
display: inline-block;
position: relative;
width: 100%;
height: 100%;
&:after {
content: '';
display: block;
width: 100%;
height: 100%;
${({ $disabled, theme }) =>
createHatchedBackground({
mainColor: $disabled ? theme.checkmarkDisabled : theme.checkmark
})}
background-position: 0px 0px, 2px 2px;
}
`;
const CheckboxComponents = {
flat: StyledFlatCheckbox,
default: StyledCheckbox
};
const Checkbox = forwardRef(
(
{
checked,
className = '',
defaultChecked = false,
disabled = false,
indeterminate = false,
label = '',
onChange = noOp,
style = {},
value,
variant = 'default',
...otherProps
},
ref
) => {
const [state, setState] = useControlledOrUncontrolled({
defaultValue: defaultChecked,
onChange,
readOnly: otherProps.readOnly ?? disabled,
value: checked
});
const handleChange = useCallback(
(e: React.ChangeEvent) => {
const newState = e.target.checked;
setState(newState);
onChange(e);
},
[onChange, setState]
);
const CheckboxComponent = CheckboxComponents[variant];
let Icon = null;
if (indeterminate) {
Icon = IndeterminateIcon;
} else if (state) {
Icon = CheckmarkIcon;
}
return (
{Icon && }
{label && {label} }
);
}
);
Checkbox.displayName = 'Checkbox';
export { Checkbox, CheckboxProps };
================================================
FILE: src/ColorInput/ColorInput.spec.tsx
================================================
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { ColorInput } from './ColorInput';
function rgb2hex(str: string) {
const rgb = str.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
function hex(x: string) {
return `0${parseInt(x, 10).toString(16)}`.slice(-2);
}
return rgb ? `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}` : '';
}
describe(' ', () => {
it('should call handlers', () => {
const color = '#f0f0dd';
const onChange = jest.fn();
const { container } = renderWithTheme( );
const input = container.querySelector(`[type="color"]`) as HTMLInputElement;
fireEvent.change(input, { target: { value: color } });
expect(onChange).toBeCalledTimes(1);
});
it('should properly pass value to input element', () => {
const color = '#f0f0dd';
const { container } = renderWithTheme( );
const input = container.querySelector(`[type="color"]`) as HTMLInputElement;
expect(input.value).toBe(color);
});
it('should display current color', () => {
const color = '#f0f0dd';
const { getByRole } = renderWithTheme( );
const colorPreview = getByRole('presentation');
const displayedColor = window.getComputedStyle(
colorPreview,
null
).background;
const displayedColorHex = rgb2hex(displayedColor);
expect(displayedColorHex).toBe(color);
});
describe('prop: disabled', () => {
it('should render disabled input', () => {
const { container } = renderWithTheme( );
const input = container.querySelector(`[type="color"]`);
expect(input).toHaveAttribute('disabled');
});
it('should be overridden by props', () => {
const { container, rerender } = renderWithTheme( );
rerender( );
const input = container.querySelector(`[type="color"]`);
expect(input).not.toHaveAttribute('disabled');
});
});
});
================================================
FILE: src/ColorInput/ColorInput.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import styled from 'styled-components';
import { ColorInput, ScrollView } from 'react95';
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
& > span {
margin-left: 1rem;
margin-right: 0.5rem;
}
#cutout {
background: ${({ theme }) => theme.canvas};
display: inline-block;
}
.content {
padding: 1rem;
& > div {
margin: 1rem 0;
}
& > div > span {
margin-right: 0.5rem;
}
}
`;
export default {
title: 'Controls/ColorInput',
component: ColorInput,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
<>
enabled:
disabled:
>
);
}
Default.story = {
name: 'default'
};
export function Flat() {
return (
);
}
Flat.story = {
name: 'flat'
};
================================================
FILE: src/ColorInput/ColorInput.tsx
================================================
import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import { StyledButton } from '../Button/Button';
import { focusOutline } from '../common';
import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
import { noOp } from '../common/utils';
import { Separator } from '../Separator/Separator';
import { CommonStyledProps } from '../types';
type ColorInputProps = {
defaultValue?: string;
disabled?: boolean;
onChange?: React.ChangeEventHandler;
value?: string;
variant?: 'default' | 'flat';
} & Omit<
React.InputHTMLAttributes,
'defaultValue' | 'disabled' | 'onChange' | 'value'
> &
CommonStyledProps;
const Trigger = styled(StyledButton)`
padding-left: 8px;
`;
const StyledSeparator = styled(Separator)`
height: 21px;
position: relative;
top: 0;
`;
export const StyledColorInput = styled.input`
box-sizing: border-box;
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
opacity: 0;
z-index: 1;
cursor: pointer;
&:disabled {
cursor: default;
}
`;
// TODO replace with SVG icon
const ColorPreview = styled.div<{
color: string;
$disabled: boolean;
}>`
box-sizing: border-box;
height: 19px;
display: inline-block;
width: 35px;
margin-right: 5px;
background: ${({ color }) => color};
${({ $disabled }) =>
$disabled
? css`
border: 2px solid ${({ theme }) => theme.materialTextDisabled};
filter: drop-shadow(
1px 1px 0px ${({ theme }) => theme.materialTextDisabledShadow}
);
`
: css`
border: 2px solid ${({ theme }) => theme.materialText};
`}
${StyledColorInput}:focus:not(:active) + &:after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
${focusOutline}
outline-offset: -8px;
}
`;
const ChevronIcon = styled.span<
Required> & {
$disabled: boolean;
}
>`
width: 0px;
height: 0px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
display: inline-block;
margin-left: 6px;
${({ $disabled }) =>
$disabled
? css`
border-top: 6px solid ${({ theme }) => theme.materialTextDisabled};
filter: drop-shadow(
1px 1px 0px ${({ theme }) => theme.materialTextDisabledShadow}
);
`
: css`
border-top: 6px solid ${({ theme }) => theme.materialText};
`}
&:after {
content: '';
box-sizing: border-box;
position: absolute;
top: ${({ variant }) => (variant === 'flat' ? '6px' : '8px')};
right: 8px;
width: 16px;
height: 19px;
}
`;
// TODO make sure all aria and role attributes are in place
const ColorInput = forwardRef(
(
{
value,
defaultValue,
onChange = noOp,
disabled = false,
variant = 'default',
...otherProps
},
ref
) => {
const [valueDerived, setValueState] = useControlledOrUncontrolled({
defaultValue,
onChange,
readOnly: otherProps.readOnly ?? disabled,
value
});
const handleChange = (e: React.ChangeEvent) => {
const color = e.target.value;
setValueState(color);
onChange(e);
};
return (
// we only need button styles, so we display
// it as a div and reset type attribute
{variant === 'default' && }
);
}
);
ColorInput.displayName = 'ColorInput';
export { ColorInput, ColorInputProps };
================================================
FILE: src/Counter/Counter.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { Counter } from './Counter';
describe(' ', () => {
it('should render', () => {
const { container } = renderWithTheme( );
const counter = container.firstElementChild;
expect(counter).toBeInTheDocument();
});
it('should handle custom style', () => {
const { container } = renderWithTheme(
);
const counter = container.firstElementChild;
expect(counter).toHaveAttribute('style', 'background-color: papayawhip;');
});
it('should handle custom props', () => {
const customProps: React.HTMLAttributes = {
title: 'potatoe'
};
const { container } = renderWithTheme( );
const counter = container.firstElementChild;
expect(counter).toHaveAttribute('title', 'potatoe');
});
describe('prop: minLength', () => {
it('renders correct number of digits', () => {
const { container } = renderWithTheme(
);
const counter = container.firstElementChild;
expect(counter && counter.childElementCount).toBe(7);
});
it('value length takes priority if bigger than minLength', () => {
const { container } = renderWithTheme(
);
const counter = container.firstElementChild;
expect(counter && counter.childElementCount).toBe(4);
});
});
});
================================================
FILE: src/Counter/Counter.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
import { Button, Counter, Frame } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
.counter-wrapper {
display: flex;
margin-top: 1rem;
}
.counter-wrapper button {
margin-left: 0.5rem;
height: 51px;
}
.wrapper {
padding: 1rem;
}
`;
export default {
title: 'Other/Counter',
component: Counter,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
const [count, setCount] = useState(13);
const handleClick = () => setCount(count + 1);
return (
Click!
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Counter/Counter.tsx
================================================
import React, { forwardRef, useMemo } from 'react';
import styled from 'styled-components';
import { createBorderStyles } from '../common';
import { CommonStyledProps, Sizes } from '../types';
import { Digit } from './Digit';
type CounterProps = {
minLength?: number;
size?: Sizes | 'xl';
value?: number;
} & React.HTMLAttributes &
CommonStyledProps;
const CounterWrapper = styled.div`
${createBorderStyles({ style: 'status' })}
display: inline-flex;
background: #000000;
`;
const pixelSizes = {
sm: 1,
md: 2,
lg: 3,
xl: 4
};
const Counter = forwardRef(
({ value = 0, minLength = 3, size = 'md', ...otherProps }, ref) => {
const digits = useMemo(
() => value.toString().padStart(minLength, '0').split(''),
[minLength, value]
);
return (
{digits.map((digit, i) => (
))}
);
}
);
Counter.displayName = 'Counter';
export { Counter, CounterProps };
================================================
FILE: src/Counter/Digit.tsx
================================================
import React from 'react';
import styled, { css } from 'styled-components';
import { createHatchedBackground } from '../common';
import { CommonStyledProps } from '../types';
type DigitProps = {
pixelSize?: number;
digit?: number | string;
} & React.HTMLAttributes &
CommonStyledProps;
const DigitWrapper = styled.div>>`
position: relative;
--react95-digit-primary-color: #ff0102;
--react95-digit-secondary-color: #740201;
--react95-digit-bg-color: #000000;
${({ pixelSize }) => css`
width: ${11 * pixelSize}px;
height: ${21 * pixelSize}px;
margin: ${pixelSize}px;
span,
span:before,
span:after {
box-sizing: border-box;
display: inline-block;
position: absolute;
}
span.active,
span.active:before,
span.active:after {
background: var(--react95-digit-primary-color);
}
span:not(.active),
span:not(.active):before,
span:not(.active):after {
${createHatchedBackground({
mainColor: 'var(--react95-digit-bg-color)',
secondaryColor: 'var(--react95-digit-secondary-color)',
pixelSize
})}
}
span.horizontal,
span.horizontal:before,
span.horizontal:after {
height: ${pixelSize}px;
border-left: ${pixelSize}px solid var(--react95-digit-bg-color);
border-right: ${pixelSize}px solid var(--react95-digit-bg-color);
}
span.horizontal.active,
span.horizontal.active:before,
span.horizontal.active:after {
height: ${pixelSize}px;
border-left: ${pixelSize}px solid var(--react95-digit-primary-color);
border-right: ${pixelSize}px solid var(--react95-digit-primary-color);
}
span.horizontal {
left: ${pixelSize}px;
width: ${9 * pixelSize}px;
}
span.horizontal:before {
content: '';
width: 100%;
top: ${pixelSize}px;
left: ${0}px;
}
span.horizontal:after {
content: '';
width: calc(100% - ${pixelSize * 2}px);
top: ${2 * pixelSize}px;
left: ${pixelSize}px;
}
span.horizontal.top {
top: 0;
}
span.horizontal.bottom {
bottom: 0;
transform: rotateX(180deg);
}
span.center,
span.center:before,
span.center:after {
height: ${pixelSize}px;
border-left: ${pixelSize}px solid var(--react95-digit-bg-color);
border-right: ${pixelSize}px solid var(--react95-digit-bg-color);
}
span.center.active,
span.center.active:before,
span.center.active:after {
border-left: ${pixelSize}px solid var(--react95-digit-primary-color);
border-right: ${pixelSize}px solid var(--react95-digit-primary-color);
}
span.center {
top: 50%;
transform: translateY(-50%);
left: ${pixelSize}px;
width: ${9 * pixelSize}px;
}
span.center:before,
span.center:after {
content: '';
width: 100%;
}
span.center:before {
top: ${pixelSize}px;
}
span.center:after {
bottom: ${pixelSize}px;
}
span.vertical,
span.vertical:before,
span.vertical:after {
width: ${pixelSize}px;
border-top: ${pixelSize}px solid var(--react95-digit-bg-color);
border-bottom: ${pixelSize}px solid var(--react95-digit-bg-color);
}
span.vertical {
height: ${11 * pixelSize}px;
}
span.vertical.left {
left: 0;
}
span.vertical.right {
right: 0;
transform: rotateY(180deg);
}
span.vertical.top {
top: 0px;
}
span.vertical.bottom {
bottom: 0px;
}
span.vertical:before {
content: '';
height: 100%;
top: ${0}px;
left: ${pixelSize}px;
}
span.vertical:after {
content: '';
height: calc(100% - ${pixelSize * 2}px);
top: ${pixelSize}px;
left: ${pixelSize * 2}px;
}
`}
`;
const segments = [
'horizontal top',
'center',
'horizontal bottom',
'vertical top left',
'vertical top right',
'vertical bottom left',
'vertical bottom right'
];
const digitActiveSegments = [
[1, 0, 1, 1, 1, 1, 1], // 0
[0, 0, 0, 0, 1, 0, 1], // 1
[1, 1, 1, 0, 1, 1, 0], // 2
[1, 1, 1, 0, 1, 0, 1], // 3
[0, 1, 0, 1, 1, 0, 1], // 4
[1, 1, 1, 1, 0, 0, 1], // 5
[1, 1, 1, 1, 0, 1, 1], // 6
[1, 0, 0, 0, 1, 0, 1], // 7
[1, 1, 1, 1, 1, 1, 1], // 8
[1, 1, 1, 1, 1, 0, 1] // 9
];
function Digit({ digit = 0, pixelSize = 2, ...otherProps }: DigitProps) {
const segmentClasses = digitActiveSegments[Number(digit)].map((isActive, i) =>
isActive ? `${segments[i]} active` : segments[i]
);
return (
{segmentClasses.map((className, i) => (
))}
);
}
export { Digit, DigitProps };
================================================
FILE: src/DatePicker/DatePicker.stories.tsx
================================================
/* eslint-disable camelcase, react/jsx-pascal-case */
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { DatePicker__UNSTABLE } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'DatePicker__UNSTABLE',
component: DatePicker__UNSTABLE,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return console.log(date)} />;
}
Default.story = {
name: 'default'
};
================================================
FILE: src/DatePicker/DatePicker.tsx
================================================
import React, { forwardRef, useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { Button } from '../Button/Button';
import { NumberInput } from '../NumberInput/NumberInput';
import { ScrollView } from '../ScrollView/ScrollView';
import { Select } from '../Select/Select';
import { Toolbar } from '../Toolbar/Toolbar';
import { Window, WindowContent, WindowHeader } from '../Window/Window';
type DatePickerProps = {
className?: string;
date?: string;
onAccept?: (chosenDate: string) => void;
onCancel?: React.MouseEventHandler;
shadow?: boolean;
};
const Calendar = styled(ScrollView)`
width: 234px;
margin: 1rem 0;
background: ${({ theme }) => theme.canvas};
`;
const WeekDays = styled.div`
display: flex;
background: ${({ theme }) => theme.materialDark};
color: #dfe0e3;
`;
const Dates = styled.div`
display: flex;
flex-wrap: wrap;
`;
const DateItem = styled.div`
text-align: center;
height: 1.5em;
line-height: 1.5em;
width: 14.28%;
`;
const DateItemContent = styled.span<{ active: boolean }>`
cursor: pointer;
background: ${({ active, theme }) =>
active ? theme.hoverBackground : 'transparent'};
color: ${({ active, theme }) =>
active ? theme.canvasTextInvert : theme.canvasText};
&:hover {
border: 2px dashed
${({ theme, active }) => (active ? 'none' : theme.materialDark)};
}
`;
const months = [
{ value: 0, label: 'January' },
{ value: 1, label: 'February' },
{ value: 2, label: 'March' },
{ value: 3, label: 'April' },
{ value: 4, label: 'May' },
{ value: 5, label: 'June' },
{ value: 6, label: 'July' },
{ value: 7, label: 'August' },
{ value: 8, label: 'September' },
{ value: 9, label: 'October' },
{ value: 10, label: 'November' },
{ value: 11, label: 'December' }
];
function daysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate();
}
function dayIndex(year: number, month: number, day: number) {
return new Date(year, month, day).getDay();
}
function convertDateToState(stringDate: string) {
const date = new Date(Date.parse(stringDate));
const day = date.getUTCDate();
const month = date.getUTCMonth();
const year = date.getUTCFullYear();
return { day, month, year };
}
const DatePicker = forwardRef(
(
{
className,
date: initialDate = new Date().toISOString(),
onAccept,
onCancel,
shadow = true
},
ref
) => {
const [date, setDate] = useState(() => convertDateToState(initialDate));
const { year, month, day } = date;
const handleMonthSelect = useCallback(
({ value: monthSelected }: { value: number }) => {
setDate(currentDate => ({ ...currentDate, month: monthSelected }));
},
[]
);
const handleYearSelect = useCallback((yearSelected: number) => {
setDate(currentDate => ({ ...currentDate, year: yearSelected }));
}, []);
const handleDaySelect = useCallback((daySelected: number) => {
setDate(currentDate => ({ ...currentDate, day: daySelected }));
}, []);
const handleAccept = useCallback(() => {
const chosenDate = [date.year, date.month + 1, date.day]
.map(part => String(part).padStart(2, '0'))
.join('-');
onAccept?.(chosenDate);
}, [date.day, date.month, date.year, onAccept]);
const dayPickerItems = useMemo(() => {
const items: React.ReactNode[] = Array.from({ length: 42 });
const firstDayIndex = dayIndex(year, month, 1);
let itemDay = day;
const daysNumber = daysInMonth(year, month);
itemDay = itemDay < daysNumber ? itemDay : daysNumber;
items.forEach((_, i) => {
if (i >= firstDayIndex && i < daysNumber + firstDayIndex) {
const dayNumber = i - firstDayIndex + 1;
items[i] = (
{
handleDaySelect(dayNumber);
}}
>
{dayNumber}
);
} else {
items[i] = ;
}
});
return items;
}, [day, handleDaySelect, month, year]);
return (
📆
Date
S
M
T
W
T
F
S
{dayPickerItems}
Cancel
OK
);
}
);
DatePicker.displayName = 'DatePicker';
// eslint-disable-next-line camelcase
export { DatePicker as DatePicker__UNSTABLE, DatePickerProps };
================================================
FILE: src/Frame/Frame.spec.tsx
================================================
import { render } from '@testing-library/react';
import React from 'react';
import { Frame } from './Frame';
describe(' ', () => {
it('should render frame', () => {
const { container } = render( );
const frame = container.firstElementChild;
expect(frame).toBeInTheDocument();
});
it('should render custom styles', () => {
const { container } = render(
);
const frame = container.firstElementChild;
expect(frame).toHaveAttribute('style', 'background-color: papayawhip;');
});
it('should render children', async () => {
const { findByText } = render(
Cool frame
);
const content = await findByText(/cool frame/i);
expect(content).toBeInTheDocument();
});
it('should render custom props', () => {
const customProps = { title: 'frame' };
const { container } = render( );
const frame = container.firstElementChild;
expect(frame).toHaveAttribute('title', 'frame');
});
});
================================================
FILE: src/Frame/Frame.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { Frame } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.material};
#default-buttons button {
margin-bottom: 1rem;
margin-right: 1rem;
}
#cutout {
background: ${({ theme }) => theme.canvas};
padding: 1rem;
width: 300px;
}
`;
export default {
title: 'Layout/Frame',
component: Frame,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
This is a frame of the 'window' variant, the default. Notice
the subtle difference in borders. The lightest border is not on the edge
of this frame.
This frame of the 'button' variant on the other hand has the
lightest border on the edge. Use this frame inside 'window'
frames.
A field frame variant is used to display content.
The 'status' variant of a frame is often used as a status bar
at the end of the window.
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Frame/Frame.tsx
================================================
import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import { createBorderStyles, createBoxStyles } from '../common';
import { CommonStyledProps } from '../types';
type FrameProps = {
children?: React.ReactNode;
shadow?: boolean;
} & (
| {
variant?: 'window' | 'button' | 'field' | 'status';
}
| {
/** @deprecated Use 'window', 'button' or 'status' */
variant?: 'outside' | 'inside' | 'well';
}
) &
React.HTMLAttributes &
CommonStyledProps;
const createFrameStyles = (variant: FrameProps['variant']) => {
switch (variant) {
case 'status':
case 'well':
return css`
${createBorderStyles({ style: 'status' })}
`;
case 'window':
case 'outside':
return css`
${createBorderStyles({ style: 'window' })}
`;
case 'field':
return css`
${createBorderStyles({ style: 'field' })}
`;
default:
return css`
${createBorderStyles()}
`;
}
};
const StyledFrame = styled.div>>`
position: relative;
font-size: 1rem;
${({ variant }) => createFrameStyles(variant)}
${({ variant }) =>
createBoxStyles(
variant === 'field'
? { background: 'canvas', color: 'canvasText' }
: undefined
)}
`;
const Frame = forwardRef(
({ children, shadow = false, variant = 'window', ...otherProps }, ref) => {
return (
{children}
);
}
);
Frame.displayName = 'Frame';
export { Frame, FrameProps };
================================================
FILE: src/GroupBox/GroupBox.spec.tsx
================================================
import React from 'react';
import { renderWithTheme, theme } from '../../test/utils';
import { GroupBox } from './GroupBox';
describe(' ', () => {
it('renders GroupBox', () => {
const { container } = renderWithTheme( );
const groupBox = container.firstChild as HTMLFieldSetElement;
expect(groupBox).toBeInTheDocument();
});
it('renders children', () => {
const textContent = 'Hi there!';
const { getByText } = renderWithTheme(
{textContent}
);
expect(getByText(textContent)).toBeInTheDocument();
});
describe('prop: label', () => {
it('renders Label', () => {
const labelText = 'Name:';
const { container } = renderWithTheme( );
const groupBox = container.firstChild as HTMLFieldSetElement;
const legend = groupBox.querySelector('legend');
expect(legend?.textContent).toBe(labelText);
});
it('when not provided, element is not rendered', () => {
const { container } = renderWithTheme( );
const groupBox = container.firstChild as HTMLFieldSetElement;
const legend = groupBox.querySelector('legend');
expect(legend).not.toBeInTheDocument();
});
});
describe('prop: disabled', () => {
it('renders with disabled text content', () => {
const { container } = renderWithTheme( );
const groupBox = container.firstChild as HTMLFieldSetElement;
expect(groupBox).toHaveAttribute('aria-disabled', 'true');
expect(groupBox).toHaveStyleRule('color', theme.materialTextDisabled);
expect(groupBox).toHaveStyleRule(
'text-shadow',
`1px 1px ${theme.materialTextDisabledShadow}`
);
});
});
});
================================================
FILE: src/GroupBox/GroupBox.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
import { Checkbox, GroupBox, ScrollView, Window, WindowContent } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Controls/GroupBox',
component: GroupBox,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
Some content here
😍
Some content here
😍
);
}
Default.story = {
name: 'default'
};
export function Flat() {
return (
Some content here
😍
Some content here
😍
);
}
Flat.story = {
name: 'flat'
};
export function ToggleExample() {
const [state, setState] = useState(true);
return (
setState(!state)}
/>
}
>
Some content here
😍
);
}
ToggleExample.story = {
name: 'toggle example'
};
================================================
FILE: src/GroupBox/GroupBox.tsx
================================================
import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import { createDisabledTextStyles } from '../common';
import { CommonStyledProps } from '../types';
type GroupBoxProps = {
label?: React.ReactNode;
children?: React.ReactNode;
disabled?: boolean;
variant?: 'default' | 'flat';
} & React.FieldsetHTMLAttributes &
CommonStyledProps;
const StyledFieldset = styled.fieldset<
Pick & { $disabled: boolean }
>`
position: relative;
border: 2px solid
${({ theme, variant }) =>
variant === 'flat' ? theme.flatDark : theme.borderLightest};
padding: 16px;
margin-top: 8px;
font-size: 1rem;
color: ${({ theme }) => theme.materialText};
${({ variant }) =>
variant !== 'flat' &&
css`
box-shadow: -1px -1px 0 1px ${({ theme }) => theme.borderDark},
inset -1px -1px 0 1px ${({ theme }) => theme.borderDark};
`}
${props => props.$disabled && createDisabledTextStyles()}
`;
const StyledLegend = styled.legend>`
display: flex;
position: absolute;
top: 0;
left: 8px;
transform: translateY(calc(-50% - 2px));
padding: 0 8px;
font-size: 1rem;
background: ${({ theme, variant }) =>
variant === 'flat' ? theme.canvas : theme.material};
`;
const GroupBox = forwardRef(
(
{ label, disabled = false, variant = 'default', children, ...otherProps },
ref
) => {
return (
{label && {label} }
{children}
);
}
);
GroupBox.displayName = 'GroupBox';
export { GroupBox, GroupBoxProps };
================================================
FILE: src/Handle/Handle.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { Handle } from './Handle';
describe(' ', () => {
it('should render bar', () => {
const { container } = renderWithTheme( );
const barEl = container.firstChild;
expect(barEl).toBeInTheDocument();
});
it('should handle custom style', () => {
const { container } = renderWithTheme(
);
const barEl = container.firstChild;
expect(barEl).toHaveAttribute('style', 'background-color: papayawhip;');
});
it('should handle custom props', () => {
const customProps = { title: 'potatoe' };
const { container } = renderWithTheme( );
const barEl = container.firstChild;
expect(barEl).toHaveAttribute('title', 'potatoe');
});
describe('prop: size', () => {
it('should set proper size', () => {
const { container } = renderWithTheme( );
const barEl = container.firstChild;
expect(barEl).toHaveStyleRule('height', '85%');
});
it('when passed a number, sets size in px', () => {
const { container } = renderWithTheme( );
const barEl = container.firstChild;
expect(barEl).toHaveStyleRule('height', '25px');
});
});
});
================================================
FILE: src/Handle/Handle.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { AppBar, Button, Handle, Toolbar } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Controls/Handle',
component: Handle,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
Edit
Save
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Handle/Handle.tsx
================================================
import React from 'react';
import styled from 'styled-components';
import { CommonStyledProps } from '../types';
import { getSize } from '../common/utils';
type HandleProps = {
size?: string | number;
} & React.HTMLAttributes &
CommonStyledProps;
// TODO: add horizontal variant
// TODO: allow user to specify number of bars (like 3 horizontal bars for drag handle)
const Handle = styled.div`
${({ theme, size = '100%' }) => `
display: inline-block;
box-sizing: border-box;
height: ${getSize(size)};
width: 5px;
border-top: 2px solid ${theme.borderLightest};
border-left: 2px solid ${theme.borderLightest};
border-bottom: 2px solid ${theme.borderDark};
border-right: 2px solid ${theme.borderDark};
background: ${theme.material};
`}
`;
Handle.displayName = 'Handle';
export { Handle, HandleProps };
================================================
FILE: src/Hourglass/Hourglass.spec.tsx
================================================
import { render } from '@testing-library/react';
import React from 'react';
import { Hourglass } from './Hourglass';
describe(' ', () => {
it('should render hourglass', () => {
const { container } = render( );
const hourglass = container.firstElementChild;
expect(hourglass).toBeInTheDocument();
});
it('should render correct size', () => {
const { container } = render( );
const hourglass = container.firstElementChild;
const computedStyles = hourglass
? window.getComputedStyle(hourglass)
: null;
expect(computedStyles?.width).toBe('66px');
expect(computedStyles?.height).toBe('66px');
});
it('should handle custom props', () => {
const customProps: React.HTMLAttributes = {
title: 'hourglass'
};
const { container } = render( );
const hourglass = container.firstElementChild;
expect(hourglass).toHaveAttribute('title', 'hourglass');
});
});
================================================
FILE: src/Hourglass/Hourglass.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { Hourglass } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Other/Hourglass',
component: Hourglass,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return ;
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Hourglass/Hourglass.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { getSize } from '../common/utils';
import { CommonStyledProps } from '../types';
import base64hourglass from './base64hourglass';
type HourglassProps = {
size?: string | number;
} & React.HTMLAttributes &
CommonStyledProps;
const StyledContainer = styled.div>>`
display: inline-block;
height: ${({ size }) => getSize(size)};
width: ${({ size }) => getSize(size)};
`;
const StyledHourglass = styled.span`
display: block;
background: ${base64hourglass};
background-size: cover;
width: 100%;
height: 100%;
`;
const Hourglass = forwardRef(
({ size = 30, ...otherProps }, ref) => {
return (
);
}
);
Hourglass.displayName = 'Hourglass';
export { Hourglass, HourglassProps };
================================================
FILE: src/Hourglass/base64hourglass.tsx
================================================
const base64hourglass =
"url('data:image/gif;base64,R0lGODlhPAA8APQAADc3N6+vr4+Pj05OTvn5+V1dXZ+fn29vby8vLw8PD/X19d/f37S0tJSUlLq6und3d39/f9XV1c/Pz+bm5qamphkZGWZmZsbGxr+/v+rq6tra2u/v7yIiIv///wAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQFBAAfACH+I1Jlc2l6ZWQgb24gaHR0cHM6Ly9lemdpZi5jb20vcmVzaXplACwAAAAAPAA8AAAF/+AnjmRpnmiqrmzrvnAsz3Rt37jr7Xzv/8BebhQsGn1D0XFZTH6YUGQySvU4fYKAdsvtdi1Cp3In6ZjP6HTawBMTyWbFYk6v18/snXvsKXciUApmeVZ7PH6ATIIdhHtPcB0TDQ1gQBCTBINthpBnAUEaa5tuh2mfQKFojZx9aRMSEhA7FLAbonqsfmoUOxFqmriknWm8Hr6/q8IeCAAAx2cTERG2aBTNHMGOj8a/v8WF2m/c3cSj4SQ8C92n4Ocm6evm7ui9CosdBPbs8yo8E2YO5PE74Q+gwIElCnYImA3hux3/Fh50yCciw3YUt2GQtiiDtGQO4f3al1GkGpIDeXlg0KDhXpoMLBtMVPaMnJlv/HjUtIkzHA8HEya4tLkhqICGV4bZVAMyaaul3ZpOUQoVz8wbpaoyvWojq1ZVXGt4/QoM49SnZMs6GktW6hC2X93mgKtVbtceWbzo9VIJKdYqUJwCPiJ4cJOzhg+/TWwko+PHkCNLdhgCACH5BAUEAB8ALAAAAAABAAEAAAUD4BcCACH5BAUEAB8ALBYADAAQAA0AAAVFYCeOZPmVaKqimeO+MPxFXv3d+F17Cm3nuJ1ic7lAdroapUjABZCfnQb4ef6k1OHGULtsNk3qjVKLiIFkj/mMIygU4VwIACH5BAUEAB8ALAAAAAABAAEAAAUD4BcCACH5BAUEAB8ALBkAIwAKAAcAAAUp4CdehrGI6Ed5XpSKa4teguBoGlVPAXuJBpam5/l9gh7NZrFQiDJMRQgAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkEBQQAHwAsFgAPABAAIQAABVBgJ45kaZ5oakZB67bZ+M10bd94ru987//AoHBILNYYAsGlR/F4IkwnlLeZTBQ9UlaWwzweERHjuzAKFZkMYYZWm4mOw0ETfdanO8Vms7aFAAAh+QQFBAAfACwAAAAAAQABAAAFA+AXAgAh+QQFBAAfACwZABIACgAeAAAFUGAnjmRpnij5rerqtu4Hx3Rt33iu758iZrUZa1TDCASLGsXjiSiZzmFnM5n4TNJSdmREElfL5lO8cgwGACbgrAkwPat3+x1naggKRS+f/4QAACH5BAUEAB8ALAAAAAABAAEAAAUD4BcCACH5BAUEAB8ALBYAIwAQAA0AAAVE4CeOXdmNaGqeabu27SUIC5xSnifZKK7zl8djkCsIaylGziNaakaEzcbH/Cwl0k9kuWxyPYptzrZULA7otFpNIK1eoxAAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkEBQQAHwAsAAAAAAEAAQAABQPgFwIAIfkECQQAHwAsDgAEACAANAAABTHgJ45kaZ5oqq5s675wLM90bd94ru987//AoHBILBqPyKRyyWw6n9CodEqtWq/Y7CoEACH5BAUEAB8ALAAAAAA8ADwAAAX/4CeOZGmeaKqubFt6biy3Xj3fuFjveU/vPJ/wBAQOj6RiEClUGpk9IMAJxQEdmQK1Grt2OhutkvurOb7f8JaM8qLT4iKbuDu/0erxfOS+4+NPex9mfn55coIfCAuFhoBLbDUAjI1vh4FkOxSVd5eQXB4GnI5rXAAbo6R6VTUFqKmWjzasNaKwsaVIHhAEt3cLTjBQA6++XwoHuUM1vMYdyMorwoN8wkC2t9A8s102204Wxana3DNAAQO1FjUCEDXhvuTT5nUdEwOiGxa8BBDwXxKaLTiAKoMFRvJy9CmmoFcHAgrQSEiwKwICDwU0pAMQIdmnboR8TfwWrJyMPrAiz1DkNs2aSRbe6hnr99LEvDJ9IB5DQ8Dhm36glNh5COGBAmQNHrbz+WXBFChOTqFx5+GBxwYCmL1ZcPHmMiWuvkTgECzBBUvrvH4tErbDWCcYDB2IBPbV2yJJ72SZ46TtXSB5v2RIp1ZXXbFkgWxCc68mk752E3tY/OZeIsiIaxi9o+BBokGH3SZ+4FPbZ8yiPQxNeDl0hNUeHWcKjYb1Zx20bd/GzRaV7t28gRSYELvw7pIfgVcLplwF8+bOo0Ffjmm6zerWrxvPzoe79w8hAAAh+QQJBAAfACwBAAEAOgA6AAAFRuAnjmRpnmiqrmzrvnAsz3Rt33iu73zv/8CgcEgsGo/IpHLJbDqf0Kh0Sq1ar9isdsvter/gsHhMLpvP6LR6zW673/D4MgQAIfkEBQQAHwAsAAAAADwAPAAABf/gJ45kaZ5oqq5s675wLM90bd94ru987//AoHBILBqPyJxnyTQym6nn0ilVSa9XGHY7jXKx2m/WK36Gy1CUVCBpu9+OtNqDeNslgip5Gej4/4ATcidLAICHHQF6c0x9iH+CXV6Gj36KZnsejgsREQSACp0Yg0ydEZWWi4RPjgdLG48apEuogJeDJVKtr7GzHrV/t5KrjX6uHhQMF4cKCwujTxHOwKmYjHzGTw+VEVIK1MGqJrrZTNuP3U/f4IniuazlSwMUFMugE/j47NW4JOQdx9bsoybMgxV4ALEIGAis4MFiCZkUaLPgUAYHGDF+Yucw0y5z3Lzt63hNUzwP5xCRpWOyDhxJYtgiStBQEVCGAAEM6MLp0p0/hMdgIZI17AOTntZgmowo9BBRgz9/EfQ54h8BBS39bKDXwBc9CrVejkNYKRLUSWGpivhXtt9PSpXEvmNiwYDdu3jzFB3LAa9fAxbUGkXjtmSZh4TPJM4kRgbhvVEL9xhTEongJJgza97MubPnz6BDix5NurTp0yJCAAAh+QQJBAAfACwEAA4ANAAgAAAFMeAnjmRpnmiqrmzrvnAsz3Rt33iu73zv/8CgcEgsGo/IpHLJbDqf0Kh0Sq1ar9jsKgQAIfkEBQQAHwAsAAAAADwAPAAABf/gJ45kaZ5oqq5s6bVwLHu0bN8uXeM8rP+9YOoHFBpHRN1xmSwue02A82lrFjaOKbVl3XQ6WeWWm7x+v+HdeFj2ntHaNbL9jUAI5/RLTurWOR53eXFbfh0RgB4PCm9hfCKGiDSLb18Bjx+RiR4HjG8TA3trmkSdZxuhalSkRA2VBqpPrD+ulR0Go3SHmz8CeG8bFqJMupJNHr5nCsKxQccTg4oUNA0YCYG/HQQQYsSlnmCUFLUXgm8EAsPeP6Zf2baV2+rEmTrt8PDyzS7O9uD4b5YV2VGjGw52/wB+CaYjlQcpNBAQioHwy4QMCxe4i3BKGIQN3K7AArBATz8anUDADcgQDMGCbQkknDKAh4ABNxQ0gpnoQ8eDVAUO0ADAzUNMhbZMQiG4R4mOo0gb8eTCQgeEqJVM7juCDWvWJnI4ev2aZIwHl2PfZIBIZBXKtAsLgC1kJu0GuWXNaoB7d67ZlWP75jVLw4JXwW35PNSJFPFUrmIb402smFNCW44N5kJ5+dTkx+vuAfus+VHF0X4xzeHsObXq1ZY7ZN76mt0C0rRf1zuWW/du175PHAu+YjhxFcCPm6CsHHnv5kig6w4BACH5BAkEAB8ALAEAAQA6ADoAAAVG4CeOZGmeaKqubOu+cCzPdG3feK7vfO//wKBwSCwaj8ikcslsOp/QqHRKrVqv2Kx2y+16v+CweEwum8/otHrNbrvf8PgyBAAh+QQFBAAfACwAAAAAPAA8AAAF/+AnjmRpnmiqrmzrvnAsz3Rt37jr7Xzv/8BebhQsGn1D0XFZTH6YUGQySvU4fYKAdsvtdi1Cp3In6ZjP6HTawBMTyWbFYk6v18/snXvsKXciUApmeVZ7PH6ATIIdhHtPcB0TDQ1gQBCTBINthpBnAUEaa5tuh2mfQKFojZx9aRMSEhA7FLAbonqsfmoUOxFqmriknWm8Hr6/q8IeCAAAx2cTERG2aBTNHMGOj8a/v8WF2m/c3cSj4SQ8C92n4Ocm6evm7ui9CosdBPbs8yo8E2YO5PE74Q+gwIElCnYImA3hux3/Fh50yCciw3YUt2GQtiiDtGQO4f3al1GkGpIDeXlg0KDhXpoMLBtMVPaMnJlv/HjUtIkzHA8HEya4tLkhqICGV4bZVAMyaaul3ZpOUQoVz8wbpaoyvWojq1ZVXGt4/QoM49SnZMs6GktW6hC2X93mgKtVbtceWbzo9VIJKdYqUJwCPiJ4cJOzhg+/TWwko+PHkCNLdhgCACH5BAUEAB8ALAAAAAABAAEAAAUD4BcCADs=')";
export default base64hourglass;
================================================
FILE: src/MenuList/MenuList.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { MenuList } from './MenuList';
describe(' ', () => {
it('renders MenuList', () => {
const { container } = renderWithTheme( );
const menuList = container.firstElementChild;
expect(menuList).toBeInTheDocument();
});
it('is an ul', () => {
const { container } = renderWithTheme( );
const menuList = container.firstElementChild;
expect(menuList?.tagName).toBe('UL');
});
it('renders children', () => {
const textContent = 'Hi there!';
const { getByText } = renderWithTheme(
{textContent}
);
expect(getByText(textContent)).toBeInTheDocument();
});
describe('prop: inline', () => {
it('renders inline', () => {
const { container } = renderWithTheme( );
const menuList = container.firstElementChild;
expect(menuList).toHaveStyleRule('display', 'inline-flex');
expect(menuList).toHaveStyleRule('align-items', 'center');
});
});
describe('prop: fullWidth', () => {
it('has 100% width', () => {
const { container } = renderWithTheme( );
const menuList = container.firstElementChild;
expect(menuList).toHaveStyleRule('width', '100%');
});
});
});
================================================
FILE: src/MenuList/MenuList.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { Handle, MenuList, MenuListItem, Separator } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
display: flex;
align-items: center;
& > * {
margin-right: 1rem;
}
`;
export default {
title: 'Controls/MenuList',
component: MenuList,
subcomponents: { MenuListItem },
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
<>
Photos
Link
Other
🌿
Tackle
Growl
Razor Leaf
View
Paste
Paste Shortcut
Undo Copy
Properties
😎
🤖
🎁
>
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/MenuList/MenuList.tsx
================================================
import React from 'react';
import styled from 'styled-components';
import { createBorderStyles, createBoxStyles } from '../common';
import { CommonStyledProps } from '../types';
type MenuListProps = React.HTMLAttributes & {
fullWidth?: boolean;
shadow?: boolean;
inline?: boolean;
} & CommonStyledProps;
// TODO keyboard controls
const MenuList = styled.ul.attrs(() => ({
role: 'menu'
}))`
box-sizing: border-box;
width: ${props => (props.fullWidth ? '100%' : 'auto')};
padding: 4px;
${createBorderStyles({ style: 'window' })}
${createBoxStyles()}
${props =>
props.inline &&
`
display: inline-flex;
align-items: center;
`}
list-style: none;
position: relative;
`;
MenuList.displayName = 'MenuList';
export * from './MenuListItem';
export { MenuList, MenuListProps };
================================================
FILE: src/MenuList/MenuListItem.spec.tsx
================================================
import React from 'react';
import { renderWithTheme, theme } from '../../test/utils';
import { blockSizes } from '../common/system';
import { MenuListItem } from './MenuListItem';
const defaultSize = 'lg';
describe(' ', () => {
it('renders MenuListItem', () => {
const { getByRole } = renderWithTheme( );
const menuListItem = getByRole('menuitem');
expect(menuListItem).toBeInTheDocument();
expect(menuListItem).not.toHaveAttribute('aria-disabled');
});
it('renders children', () => {
const textContent = 'Hi there!';
const { getByText } = renderWithTheme(
{textContent}
);
expect(getByText(textContent)).toBeInTheDocument();
});
it('should have a default role of menuitem', () => {
const { getByRole } = renderWithTheme( );
const menuListItem = getByRole('menuitem');
expect(menuListItem).toHaveAttribute('role', 'menuitem');
});
it('should render with custom role', () => {
const { getByRole } = renderWithTheme( );
const menuListItem = getByRole('option');
expect(menuListItem).toHaveAttribute('role', 'option');
});
// it('should have a tabIndex of -1 by default', () => {
// const { getByRole } = renderWithTheme( );
// const menuListItem = getByRole('menuitem');
// expect(menuListItem).toHaveAttribute('tabIndex', '-1');
// });
describe('prop: disabled', () => {
it('should not trigger onClick callback', () => {
const clickHandler = jest.fn();
const { getByRole } = renderWithTheme(
);
const menuListItem = getByRole('menuitem') as HTMLElement;
menuListItem.click();
expect(clickHandler).not.toBeCalled();
expect(menuListItem).toHaveAttribute('aria-disabled', 'true');
});
it('renders with disabled styles ', () => {
const { getByRole } = renderWithTheme( );
const menuListItem = getByRole('menuitem');
expect(menuListItem).toHaveStyleRule('pointer-events', 'none');
expect(menuListItem).toHaveStyleRule('color', theme.materialTextDisabled);
expect(menuListItem).toHaveStyleRule(
'text-shadow',
`1px 1px ${theme.materialTextDisabledShadow}`
);
});
});
describe('prop: onClick', () => {
it('should be called when clicked', () => {
const clickHandler = jest.fn();
const { getByRole } = renderWithTheme(
);
const menuListItem = getByRole('menuitem') as HTMLElement;
menuListItem.click();
expect(clickHandler).toHaveBeenCalledTimes(1);
});
});
describe('prop: square', () => {
it('should render square MenuListItem', () => {
const { getByRole } = renderWithTheme( );
const menuListItem = getByRole('menuitem');
expect(menuListItem).toHaveStyleRule('width', blockSizes[defaultSize]);
expect(menuListItem).toHaveStyleRule('height', blockSizes[defaultSize]);
});
});
describe('prop: size', () => {
it('should define MenuListItem height', () => {
const size = 'sm';
const { getByRole } = renderWithTheme( );
const menuListItem = getByRole('menuitem');
expect(menuListItem).toHaveStyleRule('height', blockSizes[size]);
});
});
});
================================================
FILE: src/MenuList/MenuListItem.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { createDisabledTextStyles } from '../common';
import { blockSizes } from '../common/system';
import { CommonStyledProps, Sizes } from '../types';
type MenuListItemProps = {
disabled?: boolean;
square?: boolean;
primary?: boolean;
size?: Sizes;
} & React.HTMLAttributes &
CommonStyledProps;
export const StyledMenuListItem = styled.li<{
$disabled?: boolean;
square?: boolean;
primary?: boolean;
size: Sizes;
}>`
box-sizing: border-box;
display: flex;
align-items: center;
position: relative;
height: ${props => blockSizes[props.size]};
width: ${props => (props.square ? blockSizes[props.size] : 'auto')};
padding: 0 8px;
font-size: 1rem;
white-space: nowrap;
justify-content: ${props =>
props.square ? 'space-around' : 'space-between'};
text-align: center;
line-height: ${props => blockSizes[props.size]};
color: ${({ theme }) => theme.materialText};
pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')};
font-weight: ${({ primary }) => (primary ? 'bold' : 'normal')};
&:hover {
${({ theme, $disabled }) =>
!$disabled &&
`
color: ${theme.materialTextInvert};
background: ${theme.hoverBackground};
`}
cursor: default;
}
${props => props.$disabled && createDisabledTextStyles()}
`;
const MenuListItem = forwardRef(
(
{
size = 'lg',
disabled,
// tabIndex: tabIndexProp,
square,
children,
onClick,
primary,
...otherProps
},
ref
) => {
// let tabIndex;
// if (!disabled) {
// tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
// }
return (
{children}
);
}
);
MenuListItem.displayName = 'MenuListItem';
export { MenuListItem, MenuListItemProps };
================================================
FILE: src/Monitor/Monitor.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { Monitor } from './Monitor';
describe(' ', () => {
it('should render', () => {
const { container } = renderWithTheme( );
const monitorElement = container.firstElementChild;
expect(monitorElement).toBeInTheDocument();
});
it('should handle custom props', () => {
const customProps: React.HTMLAttributes = {
title: 'potatoe'
};
const { container } = renderWithTheme( );
const monitorElement = container.firstElementChild;
expect(monitorElement).toHaveAttribute('title', 'potatoe');
});
describe('prop: backgroundStyles', () => {
it('should forward styles to background element', () => {
const { getByTestId } = renderWithTheme(
);
const backgroundElement = getByTestId('background');
expect(backgroundElement).toHaveAttribute(
'style',
'background-color: papayawhip;'
);
});
});
describe('prop: children', () => {
it('children should be rendered in background element', () => {
const { getByTestId } = renderWithTheme(Hi! );
const backgroundElement = getByTestId('background');
expect(backgroundElement.innerHTML).toBe('Hi!');
});
});
});
================================================
FILE: src/Monitor/Monitor.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { Monitor } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Other/Monitor',
component: Monitor,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return ;
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Monitor/Monitor.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { StyledScrollView } from '../ScrollView/ScrollView';
type MonitorProps = {
backgroundStyles?: React.CSSProperties;
children?: React.ReactNode;
};
const Wrapper = styled.div`
position: relative;
display: inline-block;
padding-bottom: 26px;
`;
const Inner = styled.div`
position: relative;
`;
const MonitorBody = styled.div`
position: relative;
z-index: 1;
box-sizing: border-box;
width: 195px;
height: 155px;
padding: 12px;
background: ${({ theme }) => theme.material};
border-top: 4px solid ${({ theme }) => theme.borderLightest};
border-left: 4px solid ${({ theme }) => theme.borderLightest};
border-bottom: 4px solid ${({ theme }) => theme.borderDark};
border-right: 4px solid ${({ theme }) => theme.borderDark};
outline: 1px dotted ${({ theme }) => theme.material};
outline-offset: -3px;
&:before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
outline: 1px dotted ${({ theme }) => theme.material};
}
box-shadow: 1px 1px 0 1px ${({ theme }) => theme.borderDarkest};
&:after {
content: '';
display: inline-block;
position: absolute;
bottom: 4px;
right: 12px;
width: 10px;
border-top: 2px solid #4d9046;
border-bottom: 2px solid #07ff00;
}
`;
const Background = styled(StyledScrollView).attrs(() => ({
'data-testid': 'background'
}))`
width: 100%;
height: 100%;
`;
const Stand = styled.div`
box-sizing: border-box;
position: absolute;
top: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
height: 10px;
width: 50%;
background: ${({ theme }) => theme.material};
border-left: 2px solid ${({ theme }) => theme.borderLightest};
border-bottom: 2px solid ${({ theme }) => theme.borderDarkest};
border-right: 2px solid ${({ theme }) => theme.borderDarkest};
box-shadow: inset 0px 0px 0px 2px ${({ theme }) => theme.borderDark};
&:before {
content: '';
position: absolute;
top: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
width: 80%;
height: 8px;
background: ${({ theme }) => theme.material};
border-left: 2px solid ${({ theme }) => theme.borderLightest};
border-right: 2px solid ${({ theme }) => theme.borderDarkest};
box-shadow: inset 0px 0px 0px 2px ${({ theme }) => theme.borderDark};
}
&:after {
content: '';
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: 150%;
height: 4px;
background: ${({ theme }) => theme.material};
border: 2px solid ${({ theme }) => theme.borderDark};
border-bottom: none;
box-shadow: inset 1px 1px 0px 1px ${({ theme }) => theme.borderLightest},
1px 1px 0 1px ${({ theme }) => theme.borderDarkest};
}
`;
const Monitor = forwardRef(
({ backgroundStyles, children, ...otherProps }, ref) => {
return (
{children}
);
}
);
Monitor.displayName = 'Monitor';
export { Monitor, MonitorProps };
================================================
FILE: src/NumberInput/NumberInput.spec.tsx
================================================
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { NumberInput } from './NumberInput';
// TODO: should we pass number or string to callbacks?
describe(' ', () => {
it('should call onChange on spin buttons click', () => {
const handleChange = jest.fn();
const { getByTestId } = renderWithTheme(
);
const spinButton = getByTestId('increment');
spinButton.click();
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(3);
});
it('should call onChange on blur after keyboard input', () => {
const handleChange = jest.fn();
const { container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
input.focus();
fireEvent.change(input, { target: { value: '777' } });
expect(handleChange).toHaveBeenCalledTimes(0);
input.blur();
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(777);
});
// TODO: this test passes even tho it fails in real-life
it('should not call onChange on blur, when clicked element is one of the spin buttons', () => {
const handleChange = jest.fn();
const { getByTestId, container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
input.focus();
fireEvent.keyDown(document.activeElement as HTMLInputElement, {
key: '2'
});
incrementButton.click();
expect(handleChange).toHaveBeenCalledTimes(1);
});
it('should give correct result after user changes input value and then clicks increment button', () => {
const handleChange = jest.fn();
const { container, getByTestId } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
fireEvent.change(input, { target: { value: '2' } });
incrementButton.click();
expect(handleChange).toHaveBeenCalledWith(3);
});
it('should reach max value', () => {
const { getByTestId, container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
incrementButton.click();
expect(input.value).toBe('100');
});
it('should reach min value', () => {
const { getByTestId, container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
const decrementButton = getByTestId('decrement');
decrementButton.click();
expect(input.value).toBe('0');
});
describe('prop: step', () => {
it('should be 1 by default', () => {
const { getByTestId, container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
incrementButton.click();
expect(input.value).toBe('1');
});
it('should change value by specified step', () => {
const { getByTestId, container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
const decrementButton = getByTestId('decrement');
decrementButton.click();
expect(input.value).toBe('7');
});
it('should handle decimal step', () => {
const { getByTestId, container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
const decrementButton = getByTestId('decrement');
decrementButton.click();
expect(input.value).toBe('9.7');
});
});
describe('prop: disabled', () => {
it('should render disabled', () => {
const { getByTestId, container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
const decrementButton = getByTestId('decrement');
expect(input).toHaveAttribute('disabled');
expect(incrementButton).toHaveAttribute('disabled');
expect(decrementButton).toHaveAttribute('disabled');
});
it('should not react to button clicks', () => {
const { getByTestId, container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
const decrementButton = getByTestId('decrement');
incrementButton.click();
expect(input.value).toBe('10');
decrementButton.click();
expect(input.value).toBe('10');
});
});
describe('prop: width', () => {
it('should render component of specified width', () => {
const { container } = renderWithTheme(
);
expect(
getComputedStyle(container.firstElementChild as HTMLInputElement).width
).toBe('93px');
});
it('should handle %', () => {
const { container } = renderWithTheme(
);
expect(
getComputedStyle(container.firstElementChild as HTMLInputElement).width
).toBe('93%');
});
});
});
================================================
FILE: src/NumberInput/NumberInput.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { ScrollView, NumberInput } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
& > * {
margin-bottom: 1rem;
}
#cutout {
background: ${({ theme }) => theme.canvas};
padding: 2rem;
width: 300px;
& > div > * {
margin-bottom: 1rem;
}
}
`;
export default {
title: 'Controls/NumberInput',
component: NumberInput,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
<>
>
);
}
Default.story = {
name: 'default'
};
export function Flat() {
return (
When you want to use NumberInput on a light background (like scrollable
content), just use the flat variant:
);
}
Flat.story = {
name: 'flat'
};
================================================
FILE: src/NumberInput/NumberInput.tsx
================================================
import React, { forwardRef, useCallback } from 'react';
import styled, { css } from 'styled-components';
import { Button } from '../Button/Button';
import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
import { blockSizes } from '../common/system';
import { clamp, getSize } from '../common/utils';
import { TextInput } from '../TextInput/TextInput';
import { CommonStyledProps } from '../types';
type NumberInputProps = {
className?: string;
defaultValue?: number;
disabled?: boolean;
max?: number;
min?: number;
readOnly?: boolean;
step?: number;
onChange?: (value: number) => void;
style?: React.CSSProperties;
value?: number;
variant?: 'default' | 'flat';
width?: string | number;
} & CommonStyledProps;
const StyledNumberInputWrapper = styled.div`
display: inline-flex;
align-items: center;
`;
const StyledButton = styled(Button)`
width: 30px;
padding: 0;
flex-shrink: 0;
${({ variant }) =>
variant === 'flat'
? css`
height: calc(50% - 1px);
`
: css`
height: 50%;
`}
`;
const StyledButtonWrapper = styled.div>`
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: space-between;
${({ variant }) =>
variant === 'flat'
? css`
height: calc(${blockSizes.md} - 4px);
`
: css`
height: ${blockSizes.md};
margin-left: 2px;
`}
`;
const StyledButtonIcon = styled.span<{ invert?: boolean }>`
width: 0px;
height: 0px;
display: inline-block;
${({ invert }) =>
invert
? css`
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid ${({ theme }) => theme.materialText};
`
: css`
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid ${({ theme }) => theme.materialText};
`}
${StyledButton}:disabled & {
filter: drop-shadow(
1px 1px 0px ${({ theme }) => theme.materialTextDisabledShadow}
);
${({ invert }) =>
invert
? css`
border-bottom-color: ${({ theme }) => theme.materialTextDisabled};
`
: css`
border-top-color: ${({ theme }) => theme.materialTextDisabled};
`}
}
`;
const NumberInput = forwardRef(
(
{
className,
defaultValue,
disabled = false,
max,
min,
onChange,
readOnly,
step = 1,
style,
value,
variant = 'default',
width,
...otherProps
},
ref
) => {
const [valueDerived, setValueState] = useControlledOrUncontrolled({
defaultValue,
onChange,
readOnly,
value
});
const handleInputChange = useCallback(
(e: React.ChangeEvent) => {
const newValue = parseFloat(e.target.value);
setValueState(newValue);
},
[setValueState]
);
const handleClick = useCallback(
(delta: number) => {
const newValue = clamp(
parseFloat(((valueDerived ?? 0) + delta).toFixed(2)),
min ?? null,
max ?? null
);
setValueState(newValue);
onChange?.(newValue);
},
[max, min, onChange, setValueState, valueDerived]
);
const onBlur = useCallback(() => {
if (valueDerived !== undefined) {
onChange?.(valueDerived);
}
}, [onChange, valueDerived]);
const stepUp = useCallback(() => {
handleClick(step);
}, [handleClick, step]);
const stepDown = useCallback(() => {
handleClick(-step);
}, [handleClick, step]);
const buttonVariant = variant === 'flat' ? 'flat' : 'raised';
return (
);
}
);
NumberInput.displayName = 'NumberInput';
export { NumberInput, NumberInputProps };
================================================
FILE: src/ProgressBar/ProgressBar.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { ProgressBar } from './ProgressBar';
describe(' ', () => {
it('renders ProgressBar', () => {
const value = 32;
const { getByRole } = renderWithTheme( );
const progressBar = getByRole('progressbar');
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveAttribute('aria-valuenow', value.toString());
});
describe('prop: variant', () => {
describe('variant: "default"', () => {
it('displays current percentage value', () => {
const value = 32;
const { queryByTestId } = renderWithTheme(
);
expect(queryByTestId('defaultProgress1')?.textContent).toBe(
`${value}%`
);
expect(queryByTestId('defaultProgress2')?.textContent).toBe(
`${value}%`
);
expect(queryByTestId('defaultProgress2')).toHaveStyleRule(
'clip-path',
`polygon( 0 0, ${value}% 0, ${value}% 100%, 0 100% )`
);
expect(queryByTestId('indeterminateProgress')).not.toBeInTheDocument();
});
});
describe('variant: "tile"', () => {
it('Renders "tile" progress', () => {
const { queryByTestId } = renderWithTheme(
);
expect(queryByTestId('defaultProgress1')).not.toBeInTheDocument();
expect(queryByTestId('defaultProgress2')).not.toBeInTheDocument();
expect(queryByTestId('tileProgress')).toBeInTheDocument();
});
// it('Renders correct number of tiles', () => {
// const value = 34;
// const { queryByTestId } = renderWithTheme(
//
// );
// const tileProgress = queryByTestId('tileProgress');
// const tileProgressWidth = tileProgress.getBoundingClientRect().width;
// const tile = tileProgress.firstChild;
// const tileWidth = tile.getBoundingClientRect().width;
// const targetTileNumber = Math.floor(
// ((value / 100) * tileProgressWidth) / tileWidth
// );
// expect(tileProgress.childElementCount).toBe(targetTileNumber);
// });
});
});
describe('prop: hideValue', () => {
it('renders progress bars, but does not show value', () => {
const value = 32;
const { queryByTestId } = renderWithTheme(
);
expect(queryByTestId('defaultProgress1')).toBeInTheDocument();
expect(queryByTestId('defaultProgress2')).toBeInTheDocument();
expect(queryByTestId('defaultProgress1')).toBeEmptyDOMElement();
expect(queryByTestId('defaultProgress2')).toBeEmptyDOMElement();
});
});
});
================================================
FILE: src/ProgressBar/ProgressBar.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React, { useEffect, useState } from 'react';
import { ProgressBar } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
`;
export default {
title: 'Controls/ProgressBar',
component: ProgressBar,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
const [percent, setPercent] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setPercent(previousPercent => {
if (previousPercent === 100) {
return 0;
}
const diff = Math.random() * 10;
return Math.min(previousPercent + diff, 100);
});
}, 500);
return () => {
clearInterval(timer);
};
}, []);
return ;
}
Default.story = {
name: 'default'
};
export function Tile() {
const [percent, setPercent] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setPercent(previousPercent => {
if (previousPercent === 100) {
return 0;
}
const diff = Math.random() * 10;
return Math.min(previousPercent + diff, 100);
});
}, 500);
return () => {
clearInterval(timer);
};
}, []);
return ;
}
Tile.story = {
name: 'tile'
};
export function HideValue() {
const [percent, setPercent] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setPercent(previousPercent => {
if (previousPercent === 100) {
return 0;
}
const diff = Math.random() * 10;
return Math.min(previousPercent + diff, 100);
});
}, 500);
return () => {
clearInterval(timer);
};
}, []);
return ;
}
HideValue.story = {
name: 'hide value'
};
================================================
FILE: src/ProgressBar/ProgressBar.tsx
================================================
import React, {
forwardRef,
useCallback,
useEffect,
useRef,
useState
} from 'react';
import styled, { css } from 'styled-components';
import { blockSizes } from '../common/system';
import { StyledScrollView } from '../ScrollView/ScrollView';
import { CommonStyledProps } from '../types';
type ProgressBarProps = {
hideValue?: boolean;
shadow?: boolean;
value?: number;
variant?: 'default' | 'tile';
} & React.HTMLAttributes &
CommonStyledProps;
const Wrapper = styled.div>>`
display: inline-block;
height: ${blockSizes.md};
width: 100%;
`;
const ProgressCutout = styled(StyledScrollView)<
Required>
>`
width: 100%;
height: 100%;
position: relative;
text-align: center;
padding: 0;
overflow: hidden;
&:before {
z-index: 1;
}
`;
const commonBarStyles = css`
width: calc(100% - 4px);
height: calc(100% - 4px);
display: flex;
align-items: center;
justify-content: space-around;
`;
const WhiteBar = styled.div`
position: relative;
top: 4px;
${commonBarStyles}
background: ${({ theme }) => theme.canvas};
color: #000;
margin-left: 2px;
margin-top: -2px;
color: ${({ theme }) => theme.materialText};
`;
const BlueBar = styled.div>`
position: absolute;
top: 2px;
left: 2px;
${commonBarStyles}
color: ${({ theme }) => theme.materialTextInvert};
background: ${({ theme }) => theme.progress};
clip-path: polygon(
0 0,
${({ value = 0 }) => value}% 0,
${({ value = 0 }) => value}% 100%,
0 100%
);
transition: 0.4s linear clip-path;
`;
const TilesWrapper = styled.div`
width: calc(100% - 6px);
height: calc(100% - 8px);
position: absolute;
left: 3px;
top: 4px;
box-sizing: border-box;
display: inline-flex;
`;
const tileWidth = 17;
const Tile = styled.span`
display: inline-block;
width: ${tileWidth}px;
box-sizing: border-box;
height: 100%;
background: ${({ theme }) => theme.progress};
border-color: ${({ theme }) => theme.material};
border-width: 0px 1px;
border-style: solid;
`;
const ProgressBar = forwardRef(
(
{
hideValue = false,
shadow = true,
value,
variant = 'default',
...otherProps
},
ref
) => {
const displayValue = hideValue ? null : `${value}%`;
const tilesWrapperRef = useRef(null);
const [tiles, setTiles] = useState([]);
// TODO debounce this function
const updateTilesNumber = useCallback(() => {
if (!tilesWrapperRef.current || value === undefined) {
return;
}
const progressWidth =
tilesWrapperRef.current.getBoundingClientRect().width;
const newTilesNumber = Math.round(
((value / 100) * progressWidth) / tileWidth
);
setTiles(Array.from({ length: newTilesNumber }));
}, [value]);
useEffect(() => {
updateTilesNumber();
window.addEventListener('resize', updateTilesNumber);
return () => window.removeEventListener('resize', updateTilesNumber);
}, [updateTilesNumber]);
return (
{variant === 'default' ? (
<>
{displayValue}
{displayValue}
>
) : (
{tiles.map((_, index) => (
))}
)}
);
}
);
ProgressBar.displayName = 'ProgressBar';
export { ProgressBar, ProgressBarProps };
================================================
FILE: src/Radio/Radio.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { Radio } from './Radio';
describe(' ', () => {
describe('label', () => {
it('renders', () => {
const labelText = 'Swag';
const { getByLabelText } = renderWithTheme( );
expect(getByLabelText(labelText)).toBeInTheDocument();
});
});
describe('prop: onChange', () => {
it('should be called when Radio is clicked', () => {
const handleChange = jest.fn(event => event.target.checked);
const { getByRole } = renderWithTheme(
);
getByRole('radio').click();
expect(handleChange).toHaveBeenCalledTimes(1);
});
});
describe('prop: disabled', () => {
it('should disable radio', () => {
const handleChange = jest.fn();
const { getByRole } = renderWithTheme( );
const checkbox = getByRole('radio');
expect(checkbox).toHaveAttribute('disabled');
checkbox.click();
expect(handleChange).not.toHaveBeenCalled();
});
it('should be overridden by props', () => {
const { getByRole, rerender } = renderWithTheme( );
rerender( );
const checkbox = getByRole('radio');
expect(checkbox).not.toHaveAttribute('disabled');
});
});
describe('controlled', () => {
it('should check the radio', () => {
const { getByRole, rerender } = renderWithTheme(
);
rerender( );
const checkbox = getByRole('radio') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
expect(getByRole('radio')).toHaveAttribute('checked');
expect(getByRole('presentation').firstChild).toHaveAttribute(
'data-testid',
'checkmarkIcon'
);
});
it('should uncheck the checkbox', () => {
const { getByRole, rerender } = renderWithTheme(
);
rerender( );
const checkbox = getByRole('radio') as HTMLInputElement;
expect(checkbox.checked).toBe(false);
expect(getByRole('radio')).not.toHaveAttribute('checked');
expect(getByRole('presentation').firstChild).toBeNull();
});
});
});
================================================
FILE: src/Radio/Radio.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
import { GroupBox, Radio, ScrollView, Window, WindowContent } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
#cutout {
background: ${({ theme }) => theme.canvas};
color: ${({ theme }) => theme.materialText};
padding: 1rem;
width: 300px;
& p {
margin-bottom: 2rem;
}
}
`;
export default {
title: 'Controls/Radio',
component: Radio,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
const [state, setState] = useState('Pear');
const handleChange = (e: React.ChangeEvent) =>
setState(e.target.value);
return (
);
}
Default.story = {
name: 'default'
};
export function Flat() {
const [state, setState] = useState('Pear');
const handleChange = (e: React.ChangeEvent) =>
setState(e.target.value);
return (
When you want to use radio buttons on a light background (like
scrollable content), just use the flat variant:
);
}
Flat.story = {
name: 'flat'
};
================================================
FILE: src/Radio/Radio.tsx
================================================
import React, { forwardRef } from 'react';
import styled, { css, CSSProperties } from 'styled-components';
import { createFlatBoxStyles } from '../common';
import {
LabelText,
size,
StyledInput,
StyledLabel
} from '../common/SwitchBase';
import { StyledScrollView } from '../ScrollView/ScrollView';
import { CommonStyledProps } from '../types';
type RadioVariant = 'default' | 'flat';
type RadioProps = {
checked?: boolean;
className?: string;
disabled?: boolean;
label?: string | number;
name?: string;
onChange?: React.ChangeEventHandler;
style?: CSSProperties;
value?: string | number | boolean;
variant?: RadioVariant;
} & Omit<
React.InputHTMLAttributes,
'checked' | 'className' | 'disabled' | 'name' | 'onChange' | 'style' | 'value'
> &
CommonStyledProps;
const sharedCheckboxStyles = css`
width: ${size}px;
height: ${size}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: space-around;
margin-right: 0.5rem;
`;
type StyledCheckboxProps = {
$disabled: boolean;
};
const StyledCheckbox = styled(StyledScrollView)`
${sharedCheckboxStyles}
background: ${({ $disabled, theme }) =>
$disabled ? theme.material : theme.canvas};
&:before {
content: '';
position: absolute;
left: 0px;
top: 0px;
width: calc(100% - 4px);
height: calc(100% - 4px);
border-radius: 50%;
box-shadow: none;
}
`;
const StyledFlatCheckbox = styled.div`
${createFlatBoxStyles()}
${sharedCheckboxStyles}
outline: none;
background: ${({ $disabled, theme }) =>
$disabled ? theme.flatLight : theme.canvas};
&:before {
content: '';
display: inline-block;
position: absolute;
top: 0;
left: 0;
width: calc(100% - 4px);
height: calc(100% - 4px);
border: 2px solid ${({ theme }) => theme.flatDark};
border-radius: 50%;
}
`;
type IconProps = {
'data-testid': 'checkmarkIcon';
$disabled: boolean;
variant: RadioVariant;
};
const Icon = styled.span.attrs(() => ({
'data-testid': 'checkmarkIcon'
}))`
position: absolute;
content: '';
display: inline-block;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: ${p =>
p.$disabled ? p.theme.checkmarkDisabled : p.theme.checkmark};
`;
const CheckboxComponents = {
flat: StyledFlatCheckbox,
default: StyledCheckbox
};
const Radio = forwardRef(
(
{
checked,
className = '',
disabled = false,
label = '',
onChange,
style = {},
variant = 'default',
...otherProps
},
ref
) => {
const CheckboxComponent = CheckboxComponents[variant];
return (
{checked && }
{label && {label} }
);
}
);
Radio.displayName = 'Radio';
export { Radio, RadioProps };
================================================
FILE: src/ScrollView/ScrollView.spec.tsx
================================================
import { render } from '@testing-library/react';
import React from 'react';
import { ScrollView } from './ScrollView';
describe(' ', () => {
it('should render scrollview', () => {
const { container } = render( );
const scrollView = container.firstElementChild;
expect(scrollView).toBeInTheDocument();
});
it('should render custom styles', () => {
const { container } = render(
);
const scrollView = container.firstElementChild;
expect(scrollView).toHaveAttribute(
'style',
'background-color: papayawhip;'
);
});
it('should render children', async () => {
const { findByText } = render(
Cool ScrollView
);
const content = await findByText(/cool scrollview/i);
expect(content).toBeInTheDocument();
});
it('should render custom props', () => {
const customProps = { title: 'scrollview' };
const { container } = render( );
const scrollView = container.firstElementChild;
expect(scrollView).toHaveAttribute('title', 'scrollview');
});
});
================================================
FILE: src/ScrollView/ScrollView.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { ScrollView, Window, WindowContent } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Layout/ScrollView',
component: ScrollView,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
React95 is the best UI library ever created
React95 is the best UI library ever created
React95 is the best UI library ever created
React95 is the best UI library ever created
React95 is the best UI library ever created
React95 is the best UI library ever created
React95 is the best UI library ever created
React95 is the best UI library ever created
React95 is the best UI library ever created
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/ScrollView/ScrollView.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { insetShadow, createScrollbars } from '../common';
import { CommonStyledProps } from '../types';
type ScrollViewProps = {
children?: React.ReactNode;
shadow?: boolean;
} & React.HTMLAttributes &
CommonStyledProps;
export const StyledScrollView = styled.div>`
position: relative;
box-sizing: border-box;
padding: 2px;
font-size: 1rem;
border-style: solid;
border-width: 2px;
border-left-color: ${({ theme }) => theme.borderDark};
border-top-color: ${({ theme }) => theme.borderDark};
border-right-color: ${({ theme }) => theme.borderLightest};
border-bottom-color: ${({ theme }) => theme.borderLightest};
line-height: 1.5;
&:before {
position: absolute;
left: 0;
top: 0;
content: '';
width: calc(100% - 4px);
height: calc(100% - 4px);
border-style: solid;
border-width: 2px;
border-left-color: ${({ theme }) => theme.borderDarkest};
border-top-color: ${({ theme }) => theme.borderDarkest};
border-right-color: ${({ theme }) => theme.borderLight};
border-bottom-color: ${({ theme }) => theme.borderLight};
pointer-events: none;
${props => props.shadow && `box-shadow:${insetShadow};`}
}
`;
const Content = styled.div`
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 4px;
overflow: auto;
${createScrollbars()}
`;
const ScrollView = forwardRef(
({ children, shadow = true, ...otherProps }, ref) => {
return (
{children}
);
}
);
ScrollView.displayName = 'ScrollView';
export { ScrollView, ScrollViewProps };
================================================
FILE: src/Select/Select.spec.tsx
================================================
import { fireEvent, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { noOp } from '../common/utils';
import { Select } from './Select';
import { SelectOption, SelectRef } from './Select.types';
const options: SelectOption[] = [
{ label: 'ten', value: 10 },
{ label: 'twenty', value: 20 },
{ label: 'thirty', value: 30 }
];
describe(' ', () => {
it('should be able to mount the component', () => {
const { container } = renderWithTheme(
);
const input = container.querySelector('input') as HTMLInputElement;
expect(input.value).toBe('10');
});
it('renders dropdown button with icon', () => {
renderWithTheme( );
const button = screen.getByTestId('select-button');
expect(button).toBeInTheDocument();
// we render styled.button, but as='div'
// because it's used only for aesthetic purposes
expect(button.tagName).not.toBe('BUTTON');
expect(button.firstChild).toHaveAttribute('data-testid', 'select-icon');
});
it('the trigger is in tab order', () => {
renderWithTheme( );
expect(screen.getByRole('button')).toHaveProperty('tabIndex', 1);
});
it('should accept null child', () => {
renderWithTheme( );
});
it('should have an input with [type="hidden"] and string value by default', () => {
const { container } = renderWithTheme(
);
const input = container.querySelector('input');
expect(input).toHaveAttribute('type', 'hidden');
expect(input).toHaveAttribute('value', '10');
});
it('passes through the blur event when menu is closed', () => {
const handleBlur = jest.fn();
renderWithTheme(
);
const trigger = screen.getByRole('button');
fireEvent.focus(trigger);
fireEvent.blur(trigger);
expect(handleBlur).toHaveBeenCalledTimes(1);
});
it('should ignore onBlur when the menu opens', () => {
// mousedown calls focus while click opens moving the focus to an item
// this means the trigger is blurred immediately
const handleBlur = jest.fn();
renderWithTheme(
{
// simulating certain platforms that focus on mousedown
if (event.defaultPrevented === false) {
event.currentTarget.focus();
}
}}
options={[
{ label: 'ten', value: '10' },
{ label: 'none', value: '' }
]}
/>
);
const trigger = screen.getByRole('button');
fireEvent.mouseDown(trigger);
expect(handleBlur).toHaveBeenCalledTimes(0);
expect(screen.getByRole('listbox')).toBeInTheDocument();
const o = screen.getAllByRole('option');
fireEvent.mouseDown(o[0]);
o[0].click();
expect(handleBlur).toHaveBeenCalledTimes(0);
expect(screen.queryByRole('listbox', { hidden: false })).toBe(null);
});
it('options should have a data-value attribute', () => {
renderWithTheme(
);
const o = screen.getAllByRole('option');
expect(o[0]).toHaveAttribute('data-value', '10');
expect(o[1]).toHaveAttribute('data-value', '20');
});
it('should call onClose when user clicks outside of component', async () => {
const handleClose = jest.fn();
renderWithTheme(
);
expect(handleClose).toHaveBeenCalledTimes(0);
fireEvent.mouseDown(screen.getByRole('button'));
fireEvent.mouseDown(screen.getByText('swag'));
expect(handleClose).toHaveBeenCalledTimes(1);
});
it('should open and close on mouseDown', async () => {
renderWithTheme( );
fireEvent.mouseDown(screen.getByRole('button'));
expect(screen.getByRole('listbox')).toBeInTheDocument();
fireEvent.mouseDown(screen.getByRole('button'));
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
describe('prop: inputProps', () => {
it('should apply additional props to trigger element', () => {
renderWithTheme(
);
expect(screen.getByTestId('SelectInput')).toHaveProperty(
'tagName',
'INPUT'
);
});
});
describe('prop: menuMaxHeight', () => {
it('sets max-height to dropdown', () => {
renderWithTheme(
);
const listbox = screen.getByRole('listbox') as HTMLElement;
expect(
listbox.getAttribute('style')?.includes('max-height: 220px')
).toBeTruthy();
});
});
describe('prop: onClose, onFocus, onKeyDown, onOpen', () => {
it('passes event through', () => {
const handler = jest.fn();
renderWithTheme(
<>
outside
>
);
const button = screen.getByRole('button');
fireEvent.focus(button);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ type: 'focus' })
);
handler.mockClear();
fireEvent.keyDown(button);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ type: 'keydown' })
);
handler.mockClear();
fireEvent.mouseDown(button);
expect(handler).toHaveBeenCalledWith({
fromEvent: expect.objectContaining({ type: 'mousedown' })
});
handler.mockClear();
fireEvent.mouseDown(screen.getByText('outside'));
expect(handler).toHaveBeenCalledWith({
fromEvent: expect.objectContaining({ type: 'mousedown' })
});
handler.mockClear();
});
});
describe('prop: onChange', () => {
it('should get selected option from arguments', () => {
const onChange = jest.fn();
renderWithTheme(
);
fireEvent.mouseDown(screen.getByRole('button'));
const option = screen.getAllByRole('option')[1];
fireEvent.mouseEnter(option);
fireEvent.click(option);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(options[1], {
fromEvent: expect.anything()
});
});
});
describe('prop: readOnly', () => {
it('should not trigger any event with readOnly', () => {
renderWithTheme( );
screen.getByRole('button').focus();
const focusedButton = document.activeElement as HTMLElement;
fireEvent.keyDown(focusedButton, { key: 'ArrowDown' });
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
fireEvent.keyUp(focusedButton, { key: 'ArrowDown' });
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
describe('prop: value', () => {
it('should select the option based on the value', () => {
renderWithTheme(
);
const o = screen.getAllByRole('option');
expect(o[0]).not.toHaveAttribute('aria-selected');
expect(o[1]).toHaveAttribute('aria-selected', 'true');
expect(o[2]).not.toHaveAttribute('aria-selected');
});
it('should select only the option that matches the object', () => {
const obj1 = { id: 1 };
const obj2 = { id: 2 };
renderWithTheme(
);
const o = screen.getAllByRole('option');
expect(o[0]).toHaveAttribute('aria-selected', 'true');
expect(o[1]).not.toHaveAttribute('aria-selected');
});
it('should be able to use an object', () => {
const value = {};
renderWithTheme(
);
expect(screen.getByRole('button')).toHaveTextContent('object-label');
});
});
describe('prop: open (controlled)', () => {
// TODO add more tests
it('should be open when initially true', () => {
renderWithTheme( );
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('open only with the left mouse button click', () => {
// Right/middle mouse click shouldn't open the Select
renderWithTheme( );
const trigger = screen.getByRole('button');
// If clicked by the right/middle mouse button, no options list should be opened
fireEvent.mouseDown(trigger, { button: 1 });
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
fireEvent.mouseDown(trigger, { button: 2 });
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
describe('prop: formatDisplay', () => {
it('should use the prop to render the value', () => {
const formatDisplay = (x: SelectOption) =>
`0b${Number(x.value).toString(2)}`;
renderWithTheme(
);
expect(screen.getByRole('button')).toHaveTextContent('0b10');
});
});
describe('prop: ref', () => {
it('should be able to return the input node via a ref object', () => {
const ref = React.createRef();
renderWithTheme( );
expect(ref.current?.node).toHaveProperty('tagName', 'INPUT');
});
it('should be able focus the trigger imperatively', () => {
const ref = React.createRef();
renderWithTheme( );
ref.current?.focus();
expect(screen.getByRole('button')).toHaveFocus();
});
});
describe('spread props', () => {
it('should apply additional props to trigger element', () => {
renderWithTheme(
);
expect(screen.getByRole('button')).toHaveAttribute(
'data-test',
'SelectDisplay'
);
});
});
describe('keyboard', () => {
it.each(['Space', 'ArrowUp', 'ArrowDown', 'Home', 'End'])(
`should open menu when pressed %s key on select`,
code => {
renderWithTheme(
);
screen.getByRole('button').focus();
const focusedButton = document.activeElement as HTMLButtonElement;
fireEvent.keyDown(focusedButton, { code });
expect(
screen.getByRole('listbox', { hidden: false })
).toBeInTheDocument();
fireEvent.keyUp(focusedButton, { code });
expect(
screen.getByRole('listbox', { hidden: false })
).toBeInTheDocument();
}
);
it('closes menu when pressing Escape', async () => {
const onClose = jest.fn();
renderWithTheme(
);
const button = screen.getByRole('button');
fireEvent.mouseDown(button);
const listbox = screen.getByRole('listbox');
expect(screen.getByRole('option', { name: 'ten' })).toBeInTheDocument();
fireEvent.keyDown(listbox, { code: 'ArrowDown' });
expect(screen.getByRole('option', { name: 'twenty' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'Escape' });
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
expect(listbox).not.toBeInTheDocument();
expect(button).toHaveFocus();
});
it.each(['Enter', 'Space', 'Tab'])(
'selects the active option by pressing %s, closes menu and maintains focus',
async keyCode => {
const onClose = jest.fn();
const onKeyDown = jest.fn();
renderWithTheme(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
);
const button = screen.getByRole('button');
fireEvent.mouseDown(button);
const listbox = screen.getByRole('listbox');
expect(screen.getByRole('option', { name: 'ten' })).toBeInTheDocument();
fireEvent.keyDown(listbox, { code: 'ArrowDown' });
expect(screen.getByRole('option', { name: 'twenty' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: keyCode });
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
expect(listbox).not.toBeInTheDocument();
expect(button).toHaveFocus();
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({ defaultPrevented: true })
);
}
);
it('passes through Enter, Escape, Tab and Shift + Tab when closed', () => {
const onKeyDown = jest.fn();
renderWithTheme(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
);
const button = screen.getByRole('button');
const eventOptions = [
{ code: 'Enter' },
{ code: 'Escape' },
{ code: 'Tab' },
{ code: 'Tab', shiftKey: true }
];
eventOptions.forEach(eventOption => {
fireEvent.keyDown(button, eventOption);
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({ defaultPrevented: false })
);
});
});
it('passes through keyDown events when modifier keys are pressed', () => {
const onKeyDown = jest.fn();
renderWithTheme(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
);
const button = screen.getByRole('button');
button.focus();
const eventOptions = [
{ altKey: true },
{ ctrlKey: true },
{ metaKey: true },
{ shiftKey: true }
];
eventOptions.forEach(eventOption => {
fireEvent.keyDown(button, { ...eventOption, code: 'KeyT' });
expect(button).toHaveTextContent('ten');
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({ defaultPrevented: false })
);
});
});
it('moves options using ArrowUp, ArrowDown, Home and End', async () => {
renderWithTheme( );
const button = screen.getByRole('button');
fireEvent.mouseDown(button);
const listbox = screen.getByRole('listbox');
expect(screen.getByRole('option', { name: 'ten' })).toBeInTheDocument();
fireEvent.keyDown(listbox, { code: 'ArrowDown' });
expect(screen.getByRole('option', { name: 'twenty' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'ArrowUp' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'End' });
expect(screen.getByRole('option', { name: 'thirty' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'Home' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
});
it('cycles through options when pressing the same key (open menu)', async () => {
renderWithTheme( );
const button = screen.getByRole('button');
fireEvent.mouseDown(button);
const listbox = screen.getByRole('listbox');
expect(screen.getByRole('option', { name: 'ten' })).toBeInTheDocument();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'twenty' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'thirty' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
});
it('cycles through options when pressing the same key (closed menu)', async () => {
renderWithTheme( );
const button = screen.getByRole('button');
fireEvent.focus(button);
fireEvent.keyDown(button, { code: 'KeyT' });
fireEvent.keyDown(button, { code: 'KeyT' });
expect(button).toHaveTextContent('twenty');
fireEvent.keyDown(button, { code: 'KeyT' });
expect(button).toHaveTextContent('thirty');
fireEvent.keyDown(button, { code: 'KeyT' });
expect(button).toHaveTextContent('ten');
});
it('switches to search after cycling', async () => {
renderWithTheme( );
const button = screen.getByRole('button');
fireEvent.mouseDown(button);
const listbox = screen.getByRole('listbox');
expect(screen.getByRole('option', { name: 'ten' })).toBeInTheDocument();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'twenty' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'KeyE' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
});
it('switches to cycling after search', async () => {
renderWithTheme( );
const button = screen.getByRole('button');
fireEvent.mouseDown(button);
const listbox = screen.getByRole('listbox');
expect(screen.getByRole('option', { name: 'ten' })).toBeInTheDocument();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'KeyH' });
expect(screen.getByRole('option', { name: 'thirty' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'twenty' })).toHaveFocus();
});
it('moves to specific option when typing', async () => {
renderWithTheme( );
const button = screen.getByRole('button');
fireEvent.mouseDown(button);
const listbox = screen.getByRole('listbox');
expect(screen.getByRole('option', { name: 'ten' })).toBeInTheDocument();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
fireEvent.keyDown(listbox, { code: 'KeyH' });
fireEvent.keyDown(listbox, { code: 'KeyI' });
fireEvent.keyDown(listbox, { code: 'KeyR' });
expect(screen.getByRole('option', { name: 'thirty' })).toHaveFocus();
});
it('resets typing after timeout', async () => {
jest.useFakeTimers();
renderWithTheme( );
const button = screen.getByRole('button');
fireEvent.mouseDown(button);
const listbox = screen.getByRole('listbox');
expect(screen.getByRole('option', { name: 'ten' })).toBeInTheDocument();
fireEvent.keyDown(listbox, { code: 'KeyT' });
fireEvent.keyDown(listbox, { code: 'KeyH' });
fireEvent.keyDown(listbox, { code: 'KeyI' });
fireEvent.keyDown(listbox, { code: 'KeyR' });
expect(screen.getByRole('option', { name: 'thirty' })).toHaveFocus();
jest.runAllTimers();
fireEvent.keyDown(listbox, { code: 'KeyT' });
expect(screen.getByRole('option', { name: 'ten' })).toHaveFocus();
jest.useRealTimers();
});
});
describe('accessibility', () => {
it('sets aria-expanded="true" when the listbox is displayed', () => {
// since we make the rest of the UI inaccessible when open this doesn't
// technically matter. This is only here in case we keep the rest accessible
renderWithTheme( );
expect(screen.getByRole('button', { hidden: true })).toHaveAttribute(
'aria-expanded',
'true'
);
});
it("aria-expanded is false if the listbox isn't displayed", () => {
renderWithTheme( );
expect(screen.getByRole('button')).toHaveAttribute(
'aria-expanded',
'false'
);
});
it('indicates that activating the button displays a listbox', () => {
renderWithTheme( );
expect(screen.getByRole('button')).toHaveAttribute(
'aria-haspopup',
'listbox'
);
});
it('renders an element with listbox behavior', () => {
renderWithTheme( );
expect(screen.getByRole('listbox')).toBeVisible();
});
it('the listbox is focusable', () => {
renderWithTheme( );
const listbox = screen.getByRole('listbox');
listbox.focus();
expect(listbox).toHaveFocus();
});
it('identifies each selectable element containing an option', () => {
renderWithTheme( );
const o = screen.getAllByRole('option');
expect(o[0]).toHaveTextContent('ten');
expect(o[1]).toHaveTextContent('twenty');
});
it('indicates the selected option', () => {
renderWithTheme(
);
expect(screen.getAllByRole('option')[1]).toHaveAttribute(
'aria-selected',
'true'
);
});
it('it will fallback to its content for the accessible name when it has no name', () => {
renderWithTheme( );
expect(screen.getByRole('button')).not.toHaveAttribute('aria-labelledby');
});
it('is labelled by itself when it has an id which is preferred over name', () => {
renderWithTheme(
<>
Chose first option:
Chose second option:
>
);
const triggers = screen.getAllByRole('button');
expect(triggers[0]).toHaveAttribute('aria-labelledby', 'select-1-label');
expect(triggers[1]).toHaveAttribute('aria-labelledby', 'select-2-label');
});
});
});
================================================
FILE: src/Select/Select.stories.data.ts
================================================
export const PokemonOptions = [
'Bulbasaur',
'Ivysaur',
'Venusaur',
'Charmander',
'Charmeleon',
'Charizard',
'Squirtle',
'Wartortle',
'Blastoise',
'Caterpie',
'Metapod',
'Butterfree',
'Weedle',
'Kakuna',
'Beedrill',
'Pidgey',
'Pidgeotto',
'Pidgeot',
'Rattata',
'Raticate',
'Spearow',
'Fearow',
'Ekans',
'Arbok',
'Pikachu',
'Raichu',
'Sandshrew',
'Sandslash',
'Nidoran♀',
'Nidorina',
'Nidoqueen',
'Nidoran♂',
'Nidorino',
'Nidoking',
'Clefairy',
'Clefable',
'Vulpix',
'Ninetales',
'Jigglypuff',
'Wigglytuff',
'Zubat',
'Golbat',
'Oddish',
'Gloom',
'Vileplume',
'Paras',
'Parasect',
'Venonat',
'Venomoth',
'Diglett',
'Dugtrio',
'Meowth',
'Persian',
'Psyduck',
'Golduck',
'Mankey',
'Primeape',
'Growlithe',
'Arcanine',
'Poliwag',
'Poliwhirl',
'Poliwrath',
'Abra',
'Kadabra',
'Alakazam',
'Machop',
'Machoke',
'Machamp',
'Bellsprout',
'Weepinbell',
'Victreebel',
'Tentacool',
'Tentacruel',
'Geodude',
'Graveler',
'Golem',
'Ponyta',
'Rapidash',
'Slowpoke',
'Slowbro',
'Magnemite',
'Magneton',
'Farfetch’d',
'Doduo',
'Dodrio',
'Seel',
'Dewgong',
'Grimer',
'Muk',
'Shellder',
'Cloyster',
'Gastly',
'Haunter',
'Gengar',
'Onix',
'Drowzee',
'Hypno',
'Krabby',
'Kingler',
'Voltorb',
'Electrode',
'Exeggcute',
'Exeggutor',
'Cubone',
'Marowak',
'Hitmonlee',
'Hitmonchan',
'Lickitung',
'Koffing',
'Weezing',
'Rhyhorn',
'Rhydon',
'Chansey',
'Tangela',
'Kangaskhan',
'Horsea',
'Seadra',
'Goldeen',
'Seaking',
'Staryu',
'Starmie',
'Mr. Mime',
'Scyther',
'Jynx',
'Electabuzz',
'Magmar',
'Pinsir',
'Tauros',
'Magikarp',
'Gyarados',
'Lapras',
'Ditto',
'Eevee',
'Vaporeon',
'Jolteon',
'Flareon',
'Porygon',
'Omanyte',
'Omastar',
'Kabuto',
'Kabutops',
'Aerodactyl',
'Snorlax',
'Articuno',
'Zapdos',
'Moltres',
'Dratini',
'Dragonair',
'Dragonite',
'Mewtwo',
'Mew'
].map((label, index) => ({ value: index + 1, label }));
================================================
FILE: src/Select/Select.stories.tsx
================================================
/* eslint-disable no-console */
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import {
GroupBox,
ScrollView,
Select,
SelectNative,
Window,
WindowContent
} from 'react95';
import styled from 'styled-components';
import { PokemonOptions } from './Select.stories.data';
import { SelectOption } from './Select.types';
const options = PokemonOptions;
const nativeOptions = options.map(option => ({
...option,
value: String(option.value)
}));
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
fieldset,
fieldset {
margin-bottom: 2rem;
}
legend + * {
margin-bottom: 1rem;
}
#default-selects {
width: 200px;
}
#cutout > div {
width: 250px;
padding: 1rem;
background: ${({ theme }) => theme.canvas};
& > p {
margin-bottom: 2rem;
}
}
`;
const onChange = (
selectedOption: SelectOption,
changeOptions: { fromEvent: React.SyntheticEvent | Event }
) => console.log(selectedOption, changeOptions.fromEvent);
export default {
title: 'Controls/Select',
component: Select,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
console.log('open', e)}
onClose={e => console.log('close', e)}
onBlur={e => console.log('blur', e)}
onFocus={e => console.log('focus', e)}
/>
console.log('native blur')}
onFocus={() => console.log('native focus')}
/>
);
}
Default.story = {
name: 'default'
};
export function Flat() {
return (
When you want to use Select on a light background (like scrollable
content), just use the flat variant:
);
}
Flat.story = {
name: 'flat'
};
export function CustomDisplayFormatting() {
return (
`${opt.label?.toUpperCase()} 👍 👍`}
onChange={onChange}
options={options}
width={220}
/>
);
}
CustomDisplayFormatting.story = {
name: 'custom display formatting'
};
================================================
FILE: src/Select/Select.styles.tsx
================================================
import styled, { css } from 'styled-components';
import { StyledButton as Button } from '../Button/Button';
import {
createDisabledTextStyles,
createFlatBoxStyles,
createScrollbars,
shadow as commonShadow
} from '../common';
import { blockSizes } from '../common/system';
import { StyledScrollView } from '../ScrollView/ScrollView';
import { CommonThemeProps } from '../types';
import { SelectVariants } from './Select.types';
type CommonSelectStyleProps = {
$disabled?: boolean;
native?: boolean;
variant?: SelectVariants;
} & CommonThemeProps;
const sharedInputContentStyles = css`
box-sizing: border-box;
padding-left: 4px;
overflow: hidden;
white-space: nowrap;
user-select: none;
line-height: 100%;
`;
const sharedHoverStyles = css`
background: ${({ theme }) => theme.hoverBackground};
color: ${({ theme }) => theme.canvasTextInvert};
`;
export const StyledInner = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
width: 100%;
&:focus {
outline: none;
}
`;
export const StyledSelectContent = styled.div`
${sharedInputContentStyles}
padding-right: 8px;
align-items: center;
display: flex;
height: calc(100% - 4px);
width: calc(100% - 4px);
margin: 0 2px;
border: 2px solid transparent;
${StyledInner}:focus & {
${sharedHoverStyles}
border: 2px dotted ${({ theme }) => theme.focusSecondary};
}
`;
const sharedWrapperStyles = css`
height: ${blockSizes.md};
display: inline-block;
color: ${({ $disabled = false, theme }) =>
$disabled ? createDisabledTextStyles() : theme.canvasText};
font-size: 1rem;
cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
`;
export const StyledSelectWrapper = styled(
StyledScrollView
)`
${sharedWrapperStyles}
background: ${({ $disabled = false, theme }) =>
$disabled ? theme.material : theme.canvas};
&:focus {
outline: 0;
}
`;
export const StyledFlatSelectWrapper = styled.div`
${createFlatBoxStyles()}
${sharedWrapperStyles}
background: ${({ $disabled = false, theme }) =>
$disabled ? theme.flatLight : theme.canvas};
`;
export const StyledNativeSelect = styled.select`
-moz-appearance: none;
-webkit-appearance: none;
display: block;
width: 100%;
height: 100%;
color: inherit;
font-size: 1rem;
border: 0;
margin: 0;
background: none;
-webkit-tap-highlight-color: transparent;
border-radius: 0;
padding-right: 30px;
${sharedInputContentStyles}
cursor: pointer;
&:disabled {
${createDisabledTextStyles()};
background: ${({ theme }) => theme.material};
cursor: default;
}
`;
export const StyledDropdownButton = styled(Button).attrs(() => ({
'aria-hidden': 'true'
}))>`
width: 30px;
padding: 0;
flex-shrink: 0;
${({ variant = 'default' }) =>
variant === 'flat'
? css`
height: 100%;
margin-right: 0;
`
: css`
height: 100%;
`}
${({ native = false, variant = 'default' }) =>
native &&
(variant === 'flat'
? `
position: absolute;
right: 0;
height: 100%;
`
: `
position: absolute;
top: 2px;
right: 2px;
height: calc(100% - 4px);
`)}
pointer-events: ${({ $disabled = false, native = false }) =>
$disabled || native ? 'none' : 'auto'}
`;
export const StyledDropdownIcon = styled.span`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
display: inline-block;
border-top: 6px solid
${({ $disabled = false, theme }) =>
$disabled ? theme.materialTextDisabled : theme.materialText};
${({ $disabled = false, theme }) =>
$disabled &&
`
filter: drop-shadow(1px 1px 0px ${theme.materialTextDisabledShadow});
border-top-color: ${theme.materialTextDisabled};
`}
${StyledDropdownButton}:active & {
margin-top: 2px;
}
`;
export const StyledDropdownMenu = styled.ul`
box-sizing: border-box;
font-size: 1rem;
position: absolute;
transform: translateY(100%);
left: 0;
background: ${({ theme }) => theme.canvas};
padding: 2px;
border-top: none;
cursor: default;
z-index: 1;
cursor: pointer;
box-shadow: ${commonShadow};
${({ variant = 'default' }) =>
variant === 'flat'
? css`
bottom: 2px;
width: 100%;
border: 2px solid ${({ theme }) => theme.flatDark};
`
: css`
bottom: -2px;
width: calc(100% - 2px);
border: 2px solid ${({ theme }) => theme.borderDarkest};
`}
${({ variant = 'default' }) => createScrollbars(variant)}
`;
export const StyledDropdownMenuItem = styled.li<{ active: boolean }>`
box-sizing: border-box;
width: 100%;
padding-left: 8px;
height: calc(${blockSizes.md} - 4px);
line-height: calc(${blockSizes.md} - 4px);
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: ${({ theme }) => theme.canvasText};
&:focus {
outline: 0;
}
${({ active }) => (active ? sharedHoverStyles : '')}
user-select: none;
`;
================================================
FILE: src/Select/Select.tsx
================================================
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef
} from 'react';
import { useId } from '../common/hooks/useId';
import { CommonStyledProps } from '../types';
import {
StyledDropdownMenu,
StyledDropdownMenuItem,
StyledInner,
StyledSelectContent
} from './Select.styles';
import { SelectOption, SelectInnerProps, SelectRef } from './Select.types';
import { useSelectCommon } from './useSelectCommon';
import { useSelectState } from './useSelectState';
type SelectProps = SelectInnerProps &
Omit<
React.HTMLAttributes,
'defaultValue' | 'name' | 'onChange' | 'onFocus' | 'style' | 'value'
> &
CommonStyledProps;
function SelectInnerOption({
activateOptionIndex,
active,
index,
onClick,
option,
selected,
setRef
}: {
activateOptionIndex: (optionIndex: number) => void;
active: boolean;
index: number;
onClick: React.MouseEventHandler;
option: SelectOption;
selected: boolean;
setRef: (ref: HTMLLIElement | null, optionIndex: number) => void;
}) {
const handleOnMouseEnter = useCallback(() => {
activateOptionIndex(index);
}, [activateOptionIndex, index]);
const handleSetRef = useCallback(
(ref: HTMLLIElement | null) => {
setRef(ref, index);
},
[index, setRef]
);
const id = useId();
return (
{option.label}
);
}
function SelectInner(
{
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
className,
defaultValue,
disabled = false,
formatDisplay,
inputProps,
labelId,
menuMaxHeight,
name,
onBlur,
onChange,
onClose,
onFocus,
onKeyDown,
onMouseDown,
onOpen,
open: openProp,
options: optionsProp,
readOnly,
shadow = true,
style,
variant = 'default',
value: valueProp,
width = 'auto',
...otherProps
}: SelectProps,
ref: React.ForwardedRef
) {
const {
isEnabled,
options,
setValue,
value,
wrapperProps,
DropdownButton,
Wrapper
} = useSelectCommon({
className,
defaultValue,
disabled,
native: false,
onChange,
options: optionsProp,
style,
readOnly,
value: valueProp,
variant,
width
});
const inputRef = useRef(null);
const selectRef = useRef(null);
const wrapperRef = useRef(null);
const {
activeOption,
handleActivateOptionIndex,
handleBlur,
handleButtonKeyDown,
handleDropdownKeyDown,
handleFocus,
handleMouseDown,
handleOptionClick,
handleSetDropdownRef,
handleSetOptionRef,
open,
selectedOption
} = useSelectState({
onBlur,
onChange,
onClose,
onFocus,
onKeyDown,
onMouseDown,
onOpen,
open: openProp,
options,
value,
selectRef,
setValue,
wrapperRef
});
// to hijack native focus. when somebody passes ref
// and triggers focus, we focus displayNode instead of input
useImperativeHandle(
ref,
() => ({
focus: focusOptions => {
selectRef.current?.focus(focusOptions);
},
node: inputRef.current,
value: String(value)
}),
[value]
);
const displayLabel = useMemo(
() =>
!selectedOption
? ''
: typeof formatDisplay === 'function'
? formatDisplay(selectedOption)
: selectedOption.label,
[formatDisplay, selectedOption]
);
const tabIndex = isEnabled ? 1 : undefined;
const dropdownMenuStyle = useMemo(
() =>
menuMaxHeight
? { overflow: 'auto', maxHeight: menuMaxHeight }
: undefined,
[menuMaxHeight]
);
const dropdownMenuId = useId();
const optionsContent = useMemo(
() =>
options.map((option, index) => {
const key = `${value}-${index}`;
const active = option === activeOption;
const selected = option === selectedOption;
return (
);
}),
[
activeOption,
handleActivateOptionIndex,
handleOptionClick,
handleSetOptionRef,
options,
selectedOption,
value
]
);
return (
{displayLabel}
{DropdownButton}
{isEnabled && open && (
)}
);
}
/* eslint-disable no-use-before-define */
const Select = forwardRef(SelectInner) as (
props: SelectProps & { ref?: React.ForwardedRef }
) => ReturnType>;
/* eslint-enable no-use-before-define */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Select.displayName = 'Select';
export * from './SelectNative';
export { Select, SelectProps };
================================================
FILE: src/Select/Select.types.ts
================================================
import React from 'react';
import { HTMLDataAttributes } from '../types';
type SelectChangeEventTargetValue = { value: T; name: string | undefined };
export type SelectChangeEvent =
| (Omit, 'target'> & {
target: Omit<
React.ChangeEvent['target'],
'name' | 'value'
> &
SelectChangeEventTargetValue;
})
| (Omit & {
target: Omit &
SelectChangeEventTargetValue;
});
export type SelectOption = {
label?: string;
value: T;
};
export type SelectRef = Pick & {
node: HTMLInputElement | null;
};
export type SelectVariants = 'default' | 'flat';
export type SelectFormatDisplayCallback = (
option: SelectOption
) => string;
export type SelectCommonProps = {
'aria-label'?: string;
'aria-labelledby'?: string;
className?: string;
defaultValue?: T;
disabled?: boolean;
name?: string;
onChange?: (
selectedOption: SelectOption,
options: {
fromEvent: Event | React.SyntheticEvent;
}
) => void;
options?: (SelectOption | null | undefined)[];
readOnly?: boolean;
shadow?: boolean;
style?: React.CSSProperties;
value?: T;
variant?: SelectVariants;
width?: React.CSSProperties['width'];
};
export type SelectInnerProps = {
formatDisplay?: SelectFormatDisplayCallback;
inputProps?: React.HTMLAttributes & HTMLDataAttributes;
/** @deprecated Use `aria-labelledby` instead */
labelId?: string;
menuMaxHeight?: string | number;
onClose?: (options: { fromEvent: Event | React.SyntheticEvent }) => void;
onOpen?: (options: { fromEvent: Event | React.SyntheticEvent }) => void;
open?: boolean;
} & Pick<
React.HTMLAttributes,
'onBlur' | 'onFocus' | 'onKeyDown' | 'onMouseDown'
> &
SelectCommonProps;
================================================
FILE: src/Select/SelectNative.spec.tsx
================================================
// Bsased on https://github.com/mui-org/material-ui
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { SelectOption } from './Select.types';
import { SelectNative } from './SelectNative';
const options: SelectOption[] = [
{ label: 'ten', value: '10' },
{ label: 'twenty', value: '20' },
{ label: 'thirty', value: '30' }
];
describe(' ', () => {
describe('prop: native', () => {
it('renders a ', () => {
const { container } = renderWithTheme( );
expect(container.querySelector('select')).toBeInTheDocument();
});
it('renders uses values for labels', () => {
const optionsWithoutLabels = options.map(({ value }) => ({ value }));
renderWithTheme(
);
expect(screen.getByTestId('select')).toHaveTextContent('10');
});
it('calls onChange if not disabled or readOnly', () => {
const handleChange = jest.fn();
renderWithTheme(
<>
>
);
fireEvent.change(screen.getByTestId('selectEnabled'));
fireEvent.change(screen.getByTestId('selectDisabled'));
fireEvent.change(screen.getByTestId('selectReadOnly'));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(options[0], {
fromEvent: expect.objectContaining({ type: 'change' })
});
});
it('can be labelled with a ', () => {
renderWithTheme(
<>
A select
>
);
expect(screen.getByLabelText('A select')).toHaveProperty(
'tagName',
'SELECT'
);
});
});
});
================================================
FILE: src/Select/SelectNative.tsx
================================================
import React, { forwardRef, useCallback } from 'react';
import { noOp } from '../common/utils';
import { StyledInner, StyledNativeSelect } from './Select.styles';
import { SelectCommonProps } from './Select.types';
import { useSelectCommon } from './useSelectCommon';
type SelectNativeProps = SelectCommonProps &
Omit<
React.SelectHTMLAttributes,
'defaultValue' | 'name' | 'onChange' | 'style' | 'value'
>;
const SelectNative = forwardRef(
(
{
className,
defaultValue,
disabled,
onChange,
options: optionsProp,
readOnly,
style,
value: valueProp,
variant,
width,
...otherProps
},
ref
) => {
const { isEnabled, options, setValue, value, DropdownButton, Wrapper } =
useSelectCommon({
defaultValue,
disabled,
native: true,
onChange,
options: optionsProp,
readOnly,
value: valueProp,
variant
});
const handleChange = useCallback(
(event: React.ChangeEvent) => {
const selectedOption = options.find(
option => option.value === event.target.value
);
if (!selectedOption) {
return;
}
setValue(selectedOption.value);
onChange?.(selectedOption, { fromEvent: event });
},
[onChange, options, setValue]
);
return (
{options.map((option, index) => (
{option.label ?? option.value}
))}
{DropdownButton}
);
}
);
SelectNative.displayName = 'SelectNative';
export { SelectNative, SelectNativeProps };
================================================
FILE: src/Select/useSelectCommon.tsx
================================================
import React, { useMemo } from 'react';
import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
import {
StyledDropdownButton,
StyledDropdownIcon,
StyledFlatSelectWrapper,
StyledSelectWrapper
} from './Select.styles';
import { SelectCommonProps, SelectOption } from './Select.types';
const emptyArray: [] = [];
export const useSelectCommon = ({
className,
defaultValue,
disabled,
native,
onChange,
options: optionsProp = emptyArray,
readOnly,
style,
value: valueProp,
variant,
width
}: { native: boolean } & SelectCommonProps) => {
const options = useMemo(
() => optionsProp.filter(Boolean) as SelectOption[],
[optionsProp]
);
const [value, setValue] = useControlledOrUncontrolled({
defaultValue: defaultValue ?? options?.[0]?.value,
onChange,
readOnly,
value: valueProp
});
const isEnabled = !(disabled || readOnly);
const wrapperProps: React.HTMLAttributes = useMemo(
() => ({
className,
style: { ...style, width }
}),
[className, style, width]
);
const DropdownButton = useMemo(
() => (
),
[disabled, native, variant]
);
const Wrapper = useMemo(
() => (variant === 'flat' ? StyledFlatSelectWrapper : StyledSelectWrapper),
[variant]
);
return useMemo(
() => ({
isEnabled,
options,
value,
setValue,
wrapperProps,
DropdownButton,
Wrapper
}),
[DropdownButton, Wrapper, isEnabled, options, setValue, value, wrapperProps]
);
};
================================================
FILE: src/Select/useSelectState.ts
================================================
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { KEYBOARD_KEY_CODES } from '../common/constants';
import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
import { clamp } from '../common/utils';
import { SelectOption, SelectInnerProps } from './Select.types';
const TYPING_RESET_DELAY = 1000;
export const useSelectState = ({
onBlur,
onChange,
onClose,
onFocus,
onKeyDown,
onMouseDown,
onOpen,
open: openProp,
options,
readOnly,
value,
selectRef,
setValue,
wrapperRef
}: Omit, 'options' | 'value'> & {
options: SelectOption[];
selectRef: React.MutableRefObject;
setValue: (newValue: React.SetStateAction) => void;
value: T;
wrapperRef: React.MutableRefObject;
}) => {
// Element references for scrolling to the active option
const dropdownRef = useRef(null);
const optionRefs = useRef<(HTMLLIElement | null)[]>([]);
// State references so callbacks are not reset on every change
const selectedIndex = useRef(0);
const activeIndex = useRef(0);
// Buffer to focus option after it is rendered and the reference becomes known
const focusIndexWhenSet = useRef();
// Typing state references so callbacks are not reset on every change
const typingMode = useRef<'search' | 'cycleFirstLetter'>('search');
const typedString = useRef('');
const typingTimer = useRef>();
// Open state
const [open, setOpen] = useControlledOrUncontrolled({
defaultValue: false,
onChange: onOpen,
onChangePropName: 'onOpen',
readOnly,
value: openProp,
valuePropName: 'open'
});
// Exposed selected option
const selectedOption = useMemo(() => {
const index = options.findIndex(option => option.value === value);
selectedIndex.current = clamp(index, 0, null);
return options[index];
}, [options, value]);
// Exposed active option
const [activeOption, setActiveOption] = useState(options[0]);
// Focuses and scrolls to the option, pinning it to the top or bottom of the
// scroll area. The default focus behavior scrolls inconsistently.
const focusOption = useCallback(
(index: number) => {
const dropdownEl = dropdownRef.current;
const optionEl = optionRefs.current[index];
if (!optionEl || !dropdownEl) {
focusIndexWhenSet.current = index;
return;
}
focusIndexWhenSet.current = undefined;
const dropdownHeight = dropdownEl.clientHeight;
const dropdownScrollTop = dropdownEl.scrollTop;
const dropdownScrollEnd = dropdownEl.scrollTop + dropdownHeight;
const optionTop = optionEl.offsetTop;
const optionHeight = optionEl.offsetHeight;
const optionBottom = optionEl.offsetTop + optionEl.offsetHeight;
if (optionTop < dropdownScrollTop) {
dropdownEl.scrollTo(0, optionTop);
}
if (optionBottom > dropdownScrollEnd) {
dropdownEl.scrollTo(0, optionTop - dropdownHeight + optionHeight);
}
optionEl.focus({ preventScroll: true });
},
[dropdownRef]
);
// Activates an option relatively or absolutely
const activateOption = useCallback(
(
indexOrOption:
| number
| 'first'
| 'last'
| 'next'
| 'previous'
| 'selected',
{ scroll }: { scroll?: boolean } = {}
) => {
const lastIndex = options.length - 1;
let index;
switch (indexOrOption) {
case 'first': {
index = 0;
break;
}
case 'last': {
index = lastIndex;
break;
}
case 'next': {
index = clamp(activeIndex.current + 1, 0, lastIndex);
break;
}
case 'previous': {
index = clamp(activeIndex.current - 1, 0, lastIndex);
break;
}
case 'selected': {
index = clamp(selectedIndex.current ?? 0, 0, lastIndex);
break;
}
default:
index = indexOrOption;
}
activeIndex.current = index;
setActiveOption(options[index]);
if (scroll) {
focusOption(index);
}
},
[activeIndex, options, focusOption]
);
// Opens the dropdown and activates the selected option
const openDropdown = useCallback(
({ fromEvent }: { fromEvent: React.SyntheticEvent }) => {
setOpen(true);
activateOption('selected', { scroll: true });
onOpen?.({ fromEvent });
},
[activateOption, onOpen, setOpen]
);
// Resets the typing states and clears timers
const clearSearchFromTyping = useCallback(() => {
typingMode.current = 'search';
typedString.current = '';
clearTimeout(typingTimer.current);
}, []);
// Closes the dropdown and resets its state
const closeDropdown = useCallback(
({
focusSelect,
fromEvent
}: {
focusSelect: boolean;
fromEvent: Event | React.SyntheticEvent;
}) => {
onClose?.({ fromEvent });
setOpen(false);
setActiveOption(options[0]);
clearSearchFromTyping();
focusIndexWhenSet.current = undefined;
if (focusSelect) {
selectRef.current?.focus();
}
},
[clearSearchFromTyping, onClose, options, selectRef, setOpen]
);
// Toggles the dropdown open state
const toggleDropdown = useCallback(
({ fromEvent }: { fromEvent: React.SyntheticEvent }) => {
if (open) {
closeDropdown({ focusSelect: false, fromEvent });
} else {
openDropdown({ fromEvent });
}
},
[closeDropdown, openDropdown, open]
);
// Selects an option and updates the exposed state
const selectOptionIndex = useCallback(
(
optionIndex: number,
{ fromEvent }: { fromEvent: Event | React.SyntheticEvent }
) => {
if (selectedIndex.current === optionIndex) {
return;
}
selectedIndex.current = optionIndex;
setValue(options[optionIndex].value);
onChange?.(options[optionIndex], { fromEvent });
},
[onChange, options, setValue]
);
// Selects the active option and close the dropdown
const selectActiveOptionAndClose = useCallback(
({
focusSelect,
fromEvent
}: {
focusSelect: boolean;
fromEvent: Event | React.SyntheticEvent;
}) => {
selectOptionIndex(activeIndex.current, { fromEvent });
closeDropdown({ focusSelect, fromEvent });
},
[closeDropdown, selectOptionIndex]
);
// Searches options for the typed letter and activates it (if open) or selects
// it (if closed)
const searchFromTyping = useCallback(
(
letter: string,
{
fromEvent,
select
}: { fromEvent: React.SyntheticEvent; select: boolean }
) => {
if (
typingMode.current === 'cycleFirstLetter' &&
letter !== typedString.current
) {
typingMode.current = 'search';
}
if (letter === typedString.current) {
typingMode.current = 'cycleFirstLetter';
} else {
typedString.current += letter;
}
switch (typingMode.current) {
case 'search': {
let foundOptionIndex = options.findIndex(
option =>
option.label?.toLocaleUpperCase().indexOf(typedString.current) ===
0
);
if (foundOptionIndex < 0) {
foundOptionIndex = options.findIndex(
option => option.label?.toLocaleUpperCase().indexOf(letter) === 0
);
typedString.current = letter;
}
if (foundOptionIndex >= 0) {
if (select) {
selectOptionIndex(foundOptionIndex, { fromEvent });
} else {
activateOption(foundOptionIndex, { scroll: true });
}
}
break;
}
case 'cycleFirstLetter': {
const currentOptionIndex = select
? selectedIndex.current ?? -1
: activeIndex.current;
let foundOptionIndex = options.findIndex(
(option, index) =>
index > currentOptionIndex &&
option.label?.toLocaleUpperCase().indexOf(letter) === 0
);
if (foundOptionIndex < 0) {
foundOptionIndex = options.findIndex(
option => option.label?.toLocaleUpperCase().indexOf(letter) === 0
);
}
if (foundOptionIndex >= 0) {
if (select) {
selectOptionIndex(foundOptionIndex, { fromEvent });
} else {
activateOption(foundOptionIndex, { scroll: true });
}
}
break;
}
default:
}
clearTimeout(typingTimer.current);
typingTimer.current = setTimeout(() => {
if (typingMode.current === 'search') {
typedString.current = '';
}
}, TYPING_RESET_DELAY);
},
[activateOption, options, selectOptionIndex]
);
// MouseDown handler for the select button
const handleMouseDown = useCallback(
(event: React.MouseEvent) => {
// ignore everything but left-click
if (event.button !== 0) {
return;
}
// hijack the default focus behavior.
event.preventDefault();
selectRef.current?.focus();
toggleDropdown({ fromEvent: event });
onMouseDown?.(event);
},
[onMouseDown, selectRef, toggleDropdown]
);
// Click handler for every option
const handleOptionClick = useCallback(
(event: React.MouseEvent) => {
selectActiveOptionAndClose({ focusSelect: true, fromEvent: event });
},
[selectActiveOptionAndClose]
);
// KeyDown handler for select button and dropdown menu, implementing
// recommended keyboard interactions from [ARIA's document][1] as well as some
// common practices for listboxes on Windows and macOS.
// [1]: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/#keyboard-interaction-11
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const { altKey, code, ctrlKey, metaKey, shiftKey } = event;
const { ARROW_DOWN, ARROW_UP, END, ENTER, ESC, HOME, SPACE, TAB } =
KEYBOARD_KEY_CODES;
const modifierKey = altKey || ctrlKey || metaKey || shiftKey;
const modifierKeyButShift = altKey || ctrlKey || metaKey;
// Skips handling if any modifier key is set, but allows shift + tab to select the
if (
(code === TAB && modifierKeyButShift) ||
(code !== TAB && modifierKey)
) {
return;
}
switch (code) {
case ARROW_DOWN: {
event.preventDefault();
if (!open) {
openDropdown({ fromEvent: event });
return;
}
activateOption('next', { scroll: true });
break;
}
case ARROW_UP: {
event.preventDefault();
if (!open) {
openDropdown({ fromEvent: event });
return;
}
activateOption('previous', { scroll: true });
break;
}
case END: {
event.preventDefault();
if (!open) {
openDropdown({ fromEvent: event });
return;
}
activateOption('last', { scroll: true });
break;
}
case ENTER: {
if (!open) {
return;
}
event.preventDefault();
selectActiveOptionAndClose({ focusSelect: true, fromEvent: event });
break;
}
case ESC: {
if (!open) {
return;
}
event.preventDefault();
closeDropdown({ focusSelect: true, fromEvent: event });
break;
}
case HOME: {
event.preventDefault();
if (!open) {
openDropdown({ fromEvent: event });
return;
}
activateOption('first', { scroll: true });
break;
}
case SPACE: {
event.preventDefault();
if (open) {
selectActiveOptionAndClose({ focusSelect: true, fromEvent: event });
} else {
openDropdown({ fromEvent: event });
}
break;
}
case TAB: {
if (!open) {
return;
}
if (!shiftKey) {
event.preventDefault();
}
selectActiveOptionAndClose({
focusSelect: !shiftKey,
fromEvent: event
});
break;
}
default:
if (!modifierKey && code.match(/^Key/)) {
event.preventDefault();
event.stopPropagation();
searchFromTyping(code.replace(/^Key/, ''), {
select: !open,
fromEvent: event
});
}
}
},
[
activateOption,
closeDropdown,
open,
openDropdown,
searchFromTyping,
selectActiveOptionAndClose
]
);
// KeyDown handler for the select button
const handleButtonKeyDown = useCallback(
(event: React.KeyboardEvent) => {
handleKeyDown(event);
onKeyDown?.(event);
},
[handleKeyDown, onKeyDown]
);
// Activate handler when MouseEntering an option
const handleActivateOptionIndex = useCallback(
(index: number) => {
activateOption(index);
},
[activateOption]
);
// Blur handler for the select button
const handleBlur = useCallback(
(event: React.FocusEvent) => {
// Trigger onBlur only when dropdown is closed, otherwise it would be
// triggered when switching focus to the menu.
if (open) {
return;
}
clearSearchFromTyping();
onBlur?.(event);
},
[clearSearchFromTyping, onBlur, open]
);
// Focus handler for the select button
const handleFocus = useCallback(
(event: React.FocusEvent) => {
clearSearchFromTyping();
onFocus?.(event);
},
[clearSearchFromTyping, onFocus]
);
// Handles setting the dropdown ref and focusing the active option
const handleSetDropdownRef = useCallback(
(ref: HTMLUListElement | null) => {
dropdownRef.current = ref;
if (focusIndexWhenSet.current !== undefined) {
focusOption(focusIndexWhenSet.current);
}
},
[focusOption]
);
// Handles setting each option ref and focusing the active option
const handleSetOptionRef = useCallback(
(optionRef: HTMLLIElement | null, index: number) => {
optionRefs.current[index] = optionRef;
if (focusIndexWhenSet.current === index) {
focusOption(focusIndexWhenSet.current);
}
},
[focusOption]
);
// Listen to mousedown outside of the element to close the dropdown
useEffect(() => {
if (!open) {
return () => {};
}
const outsideMouseDown = (event: MouseEvent) => {
const target = event.target as Node;
if (!wrapperRef.current?.contains(target)) {
event.preventDefault();
closeDropdown({ focusSelect: false, fromEvent: event });
}
};
document.addEventListener('mousedown', outsideMouseDown);
return () => {
document.removeEventListener('mousedown', outsideMouseDown);
};
}, [closeDropdown, open, wrapperRef]);
return useMemo(
() => ({
activeOption,
handleActivateOptionIndex,
handleBlur,
handleButtonKeyDown,
handleDropdownKeyDown: handleKeyDown,
handleFocus,
handleMouseDown,
handleOptionClick,
handleSetDropdownRef,
handleSetOptionRef,
open,
selectedOption
}),
[
activeOption,
handleActivateOptionIndex,
handleBlur,
handleButtonKeyDown,
handleFocus,
handleKeyDown,
handleMouseDown,
handleOptionClick,
handleSetDropdownRef,
handleSetOptionRef,
open,
selectedOption
]
);
};
================================================
FILE: src/Separator/Separator.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { Separator } from './Separator';
describe(' ', () => {
it('should render Separator', () => {
const { container } = renderWithTheme( );
const separator = container.firstElementChild;
expect(separator).toBeInTheDocument();
});
describe('prop: size', () => {
it('defaults to 100%', () => {
const { container } = renderWithTheme( );
const separator = container.firstElementChild;
expect(separator).toHaveStyleRule('width', '100%');
});
it('sets size passed correctly', () => {
const size = '53px';
const { container } = renderWithTheme( );
const separator = container.firstElementChild;
expect(separator).toHaveStyleRule('width', size);
});
});
describe('prop: orientation', () => {
it('renders horizontal line by default', () => {
const size = '53px';
const { container } = renderWithTheme( );
const separator = container.firstElementChild;
expect(separator).toHaveStyleRule('width', size);
});
it('renders vertical line when orientation="vertical"', () => {
const size = '53px';
const { container } = renderWithTheme(
);
const separator = container.firstElementChild;
expect(separator).toHaveStyleRule('height', size);
});
});
describe('prop: size', () => {
it('should set proper size', () => {
const { container } = renderWithTheme( );
const separator = container.firstElementChild;
expect(separator).toHaveStyleRule('width', '85%');
});
it('when passed a number, sets size in px', () => {
const { container } = renderWithTheme( );
const separator = container.firstElementChild;
expect(separator).toHaveStyleRule('width', '25px');
});
it('should set height when vertical', () => {
const { container } = renderWithTheme(
);
const separator = container.firstElementChild;
expect(separator).toHaveStyleRule('height', '25px');
});
});
});
================================================
FILE: src/Separator/Separator.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import styled from 'styled-components';
import { MenuList, MenuListItem, Separator } from 'react95';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Layout/Separator',
component: Separator,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
<>
Item 1
Item 2
Item 3
Item 1
Item 2
Item 3
>
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Separator/Separator.tsx
================================================
import styled from 'styled-components';
import { getSize } from '../common/utils';
import { Orientation } from '../types';
type SeparatorProps = {
size?: string | number;
orientation?: Orientation;
};
const Separator = styled.div`
${({ orientation, theme, size = '100%' }) =>
orientation === 'vertical'
? `
height: ${getSize(size)};
border-left: 2px solid ${theme.borderDark};
border-right: 2px solid ${theme.borderLightest};
margin: 0;
`
: `
width: ${getSize(size)};
border-bottom: 2px solid ${theme.borderLightest};
border-top: 2px solid ${theme.borderDark};
margin: 0;
`}
`;
Separator.displayName = 'Separator';
export { Separator, SeparatorProps };
================================================
FILE: src/Slider/Slider.spec.tsx
================================================
// Pretty much straight out copied from https://github.com/mui-org/material-ui 😂
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { renderWithTheme, Touch } from '../../test/utils';
import { Slider } from './Slider';
function createTouches(
touches: { identifier: number; clientX?: number; clientY?: number }[]
) {
return {
changedTouches: touches.map(touch => new Touch(touch))
};
}
describe(' ', () => {
beforeAll(() => {
jest
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(
() =>
({
width: 100,
height: 20,
bottom: 20,
left: 0
} as DOMRect)
);
});
it('should call handlers', () => {
const handleChange = jest.fn();
const handleChangeCommitted = jest.fn();
const { container, getByRole } = renderWithTheme(
);
const slider = container.firstElementChild as HTMLElement;
fireEvent.mouseDown(slider);
fireEvent.mouseUp(document.body);
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChangeCommitted).toHaveBeenCalledTimes(1);
getByRole('slider').focus();
const focusedSlider = document.activeElement as HTMLElement;
fireEvent.keyDown(focusedSlider, {
key: 'Home'
});
expect(handleChange).toHaveBeenCalledTimes(2);
expect(handleChangeCommitted).toHaveBeenCalledTimes(2);
});
it('should only listen to changes from the same touchpoint', () => {
const handleChange = jest.fn();
const handleChangeCommitted = jest.fn();
const { container } = renderWithTheme(
);
const slider = container.firstElementChild as HTMLElement;
fireEvent.touchStart(slider, createTouches([{ identifier: 1 }]));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChangeCommitted).not.toHaveBeenCalled();
fireEvent.touchEnd(document.body, createTouches([{ identifier: 2 }]));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChangeCommitted).not.toHaveBeenCalled();
fireEvent.touchMove(document.body, createTouches([{ identifier: 1 }]));
expect(handleChange).toHaveBeenCalledTimes(2);
expect(handleChangeCommitted).toHaveBeenCalledTimes(0);
fireEvent.touchMove(document.body, createTouches([{ identifier: 2 }]));
expect(handleChange).toHaveBeenCalledTimes(2);
expect(handleChangeCommitted).toHaveBeenCalledTimes(0);
fireEvent.touchEnd(document.body, createTouches([{ identifier: 1 }]));
expect(handleChange).toHaveBeenCalledTimes(2);
expect(handleChangeCommitted).toHaveBeenCalledTimes(1);
});
it('defaults to horizontal orientation', () => {
const { getByRole } = renderWithTheme( );
expect(getByRole('slider')).toHaveAttribute(
'aria-orientation',
'horizontal'
);
});
it('should forward mouseDown', () => {
const handleMouseDown = jest.fn();
const { container } = renderWithTheme(
);
const slider = container.firstElementChild as HTMLElement;
fireEvent.mouseDown(slider);
expect(handleMouseDown).toHaveBeenCalledTimes(1);
});
describe('prop: step', () => {
it('should handle a null step', () => {
const { getByRole, container } = renderWithTheme(
);
const slider = container.firstElementChild as HTMLElement;
// mocking containers size
const thumb = getByRole('slider');
fireEvent.touchStart(
slider,
createTouches([{ identifier: 1, clientX: 22, clientY: 0 }])
);
expect(thumb).toHaveAttribute('aria-valuenow', '20');
thumb.focus();
fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'ArrowUp'
});
expect(thumb).toHaveAttribute('aria-valuenow', '30');
fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'ArrowDown'
});
expect(thumb).toHaveAttribute('aria-valuenow', '20');
});
});
describe('prop: disabled', () => {
it('should render disabled slider', () => {
const { getByRole, container } = renderWithTheme(
);
const slider = container.firstElementChild as HTMLElement;
const thumb = getByRole('slider');
expect(
window.getComputedStyle(slider, null).getPropertyValue('pointer-events')
).toBe('none');
expect(thumb).toHaveAttribute('aria-disabled', 'true');
});
});
describe('keyboard', () => {
it('should handle all the keys', () => {
const { getByRole } = renderWithTheme( );
const thumb = getByRole('slider');
thumb.focus();
fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'Home'
});
expect(thumb).toHaveAttribute('aria-valuenow', '0');
fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'End'
});
expect(thumb).toHaveAttribute('aria-valuenow', '100');
fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'PageDown'
});
expect(thumb).toHaveAttribute('aria-valuenow', '90');
fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'Escape'
});
expect(thumb).toHaveAttribute('aria-valuenow', '90');
fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'PageUp'
});
expect(thumb).toHaveAttribute('aria-valuenow', '100');
});
const moveLeftEvent = {
key: 'ArrowLeft'
};
const moveRightEvent = {
key: 'ArrowRight'
};
it('should use min as the step origin', () => {
const { getByRole } = renderWithTheme(
);
const thumb = getByRole('slider');
thumb.focus();
fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '6');
fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '4');
expect(thumb.style.left).toBe('20%');
});
it('should reach right edge value', () => {
const { getByRole } = renderWithTheme(
);
const thumb = getByRole('slider');
thumb.focus();
fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '96');
fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '106');
fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '108');
fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '96');
fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '86');
});
it('should reach left edge value', () => {
const { getByRole } = renderWithTheme(
);
const thumb = getByRole('slider');
thumb.focus();
fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '6');
fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '16');
fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '26');
});
it('should round value to step precision', () => {
const { getByRole } = renderWithTheme(
);
const thumb = getByRole('slider');
thumb.focus();
fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '0.3');
});
it('should not fail to round value to step precision when step is very small', () => {
const { getByRole } = renderWithTheme(
);
const thumb = getByRole('slider');
thumb.focus();
fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '3e-8');
});
it('should not fail to round value to step precision when step is very small and negative', () => {
const { getByRole } = renderWithTheme(
);
const thumb = getByRole('slider');
thumb.focus();
fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '-3e-8');
});
});
describe('prop: orientation', () => {
it('when vertical, should render with aria-orientation attribute set to "vertical" ', () => {
const { getByRole } = renderWithTheme(
);
expect(getByRole('slider')).toHaveAttribute(
'aria-orientation',
'vertical'
);
});
it('should report the right position', () => {
const handleChange = jest.fn();
const { container } = renderWithTheme(
);
const slider = container.firstElementChild as HTMLElement;
// mocking containers size
jest
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(
() =>
({
width: 20,
height: 100,
bottom: 100,
left: 0
} as DOMRect)
);
fireEvent.touchStart(
slider,
createTouches([{ identifier: 1, clientX: 0, clientY: 20 }])
);
fireEvent.touchMove(
document.body,
createTouches([{ identifier: 1, clientX: 0, clientY: 22 }])
);
expect(handleChange).toHaveBeenCalledTimes(2);
expect(handleChange.mock.calls[0][0]).toBe(80);
expect(handleChange.mock.calls[1][0]).toBe(78);
});
});
describe('prop: marks', () => {
it('displays only ticks when marks is set to "true"', () => {
const { queryAllByTestId } = renderWithTheme(
);
const ticks = queryAllByTestId('tick');
const marks = queryAllByTestId('mark');
expect(ticks.length).toBe(7);
expect(marks.length).toBe(0);
});
it('displays marks passed as prop', () => {
const { queryAllByTestId } = renderWithTheme(
);
const marks = queryAllByTestId('mark');
expect(marks[0].textContent).toBe('zero');
expect(marks[1].textContent).toBe('twenty');
expect(marks[2].textContent).toBe('thirty');
});
});
});
================================================
FILE: src/Slider/Slider.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { ScrollView, Slider, SliderOnChangeHandler } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
.col {
display: flex;
flex-direction: column;
justify-content: space-around;
}
.row {
display: flex;
& > *:first-child {
margin-right: 5rem;
}
}
#cutout {
width: 400px;
}
#cutout > div {
background: ${({ theme }) => theme.canvas};
padding: 1rem;
& > * {
margin-bottom: 2rem;
}
display: flex;
flex-direction: column;
align-items: center;
}
`;
export default {
title: 'Controls/Slider',
component: Slider,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
const [state, setState] = React.useState(0);
const onChange: SliderOnChangeHandler = newValue => setState(newValue);
return (
);
}
Default.story = {
name: 'default'
};
export function Flat() {
return (
When you want to add input field on a light background (like scrollable
content), just use the flat variant:
);
}
Flat.story = {
name: 'flat'
};
================================================
FILE: src/Slider/Slider.tsx
================================================
// helper functions and event handling basically copied from Material UI (https://github.com/mui-org/material-ui) Slider component
import React, {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import styled, { css } from 'styled-components';
import {
createBorderStyles,
createBoxStyles,
createDisabledTextStyles,
createFlatBoxStyles,
createHatchedBackground
} from '../common';
import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
import useEventCallback from '../common/hooks/useEventCallback';
import useForkRef from '../common/hooks/useForkRef';
import { useIsFocusVisible } from '../common/hooks/useIsFocusVisible';
import { clamp, getSize, roundValueToStep } from '../common/utils';
import { StyledScrollView } from '../ScrollView/ScrollView';
import { CommonStyledProps } from '../types';
export type SliderOnChangeHandler = (value: number) => void;
type SliderProps = {
defaultValue?: number;
disabled?: boolean;
marks?: boolean | { label?: string; value: number }[];
max?: number;
min?: number;
name?: string;
onChange?: SliderOnChangeHandler;
onChangeCommitted?: SliderOnChangeHandler;
onMouseDown?: (event: React.MouseEvent) => void;
orientation?: 'horizontal' | 'vertical';
size?: string | number;
step?: number | null;
value?: number;
variant?: 'default' | 'flat';
} & Omit<
React.HTMLAttributes,
'defaultValue' | 'onChange' | 'onMouseDown'
> &
CommonStyledProps;
function percentToValue(percent: number, min: number, max: number) {
return (max - min) * percent + min;
}
function trackFinger(
event: MouseEvent | React.MouseEvent | TouchEvent,
touchId: number | undefined
) {
if (touchId !== undefined && 'changedTouches' in event) {
for (let i = 0; i < event.changedTouches.length; i += 1) {
const touch = event.changedTouches[i];
if (touch.identifier === touchId) {
return {
x: touch.clientX,
y: touch.clientY
};
}
}
return false;
}
if ('clientX' in event) {
return {
x: event.clientX,
y: event.clientY
};
}
return false;
}
function ownerDocument(node?: Element) {
return (node && node.ownerDocument) || document;
}
function findClosest(values: number[], currentValue: number) {
const { index: closestIndex } =
values.reduce<{
distance: number;
index: number;
} | null>((acc, value, index) => {
const distance = Math.abs(currentValue - value);
if (
acc === null ||
distance < acc.distance ||
distance === acc.distance
) {
return {
distance,
index
};
}
return acc;
}, null) ?? {};
return closestIndex ?? -1;
}
type StyledSliderProps = Pick<
SliderProps,
'orientation' | 'size' | 'variant'
> & {
$disabled?: boolean;
hasMarks?: boolean;
isFocused?: boolean;
};
const Wrapper = styled.div`
display: inline-block;
position: relative;
touch-action: none;
&:before {
content: '';
display: inline-block;
position: absolute;
top: -2px;
left: -15px;
width: calc(100% + 30px);
height: ${({ hasMarks }) => (hasMarks ? '41px' : '39px')};
${({ isFocused, theme }) =>
isFocused &&
`
outline: 2px dotted ${theme.materialText};
`}
}
${({ orientation, size }) =>
orientation === 'vertical'
? css`
height: ${size};
margin-right: 1.5rem;
&:before {
left: -6px;
top: -15px;
height: calc(100% + 30px);
width: ${({ hasMarks }) => (hasMarks ? '41px' : '39px')};
}
`
: css`
width: ${size};
margin-bottom: 1.5rem;
&:before {
top: -2px;
left: -15px;
width: calc(100% + 30px);
height: ${({ hasMarks }) => (hasMarks ? '41px' : '39px')};
}
`}
pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')};
`;
const sharedGrooveStyles = () => css`
position: absolute;
${({ orientation }) =>
orientation === 'vertical'
? css`
bottom: 0;
left: 50%;
transform: translateX(-50%);
height: 100%;
width: 8px;
`
: css`
left: 0;
top: 50%;
transform: translateY(-50%);
height: 8px;
width: 100%;
`}
`;
const StyledGroove = styled(StyledScrollView)`
${sharedGrooveStyles()}
`;
const StyledFlatGroove = styled(StyledScrollView)`
${sharedGrooveStyles()}
border-left-color: ${({ theme }) => theme.flatLight};
border-top-color: ${({ theme }) => theme.flatLight};
border-right-color: ${({ theme }) => theme.canvas};
border-bottom-color: ${({ theme }) => theme.canvas};
&:before {
border-left-color: ${({ theme }) => theme.flatDark};
border-top-color: ${({ theme }) => theme.flatDark};
border-right-color: ${({ theme }) => theme.flatLight};
border-bottom-color: ${({ theme }) => theme.flatLight};
}
`;
const Thumb = styled.span`
position: relative;
${({ orientation }) =>
orientation === 'vertical'
? css`
width: 32px;
height: 18px;
right: 2px;
transform: translateY(-50%);
`
: css`
height: 32px;
width: 18px;
top: 2px;
transform: translateX(-50%);
`}
${({ variant }) =>
variant === 'flat'
? css`
${createFlatBoxStyles()}
outline: 2px solid ${({ theme }) => theme.flatDark};
background: ${({ theme }) => theme.flatLight};
`
: css`
${createBoxStyles()}
${createBorderStyles()}
&:focus {
outline: none;
}
`}
${({ $disabled, theme }) =>
$disabled &&
createHatchedBackground({
mainColor: theme.material,
secondaryColor: theme.borderLightest
})}
`;
const tickHeight = 6;
const Tick = styled.span`
display: inline-block;
position: absolute;
${({ orientation }) =>
orientation === 'vertical'
? css`
right: ${-tickHeight - 2}px;
bottom: 0px;
transform: translateY(1px);
width: ${tickHeight}px;
border-bottom: 2px solid ${({ theme }) => theme.materialText};
`
: css`
bottom: ${-tickHeight}px;
height: ${tickHeight}px;
transform: translateX(-1px);
border-left: 1px solid ${({ theme }) => theme.materialText};
border-right: 1px solid ${({ theme }) => theme.materialText};
`}
color: ${({ theme }) => theme.materialText};
${({ $disabled, theme }) =>
$disabled &&
css`
${createDisabledTextStyles()}
box-shadow: 1px 1px 0px ${theme.materialTextDisabledShadow};
border-color: ${theme.materialTextDisabled};
`}
`;
const Mark = styled.div`
position: absolute;
bottom: 0;
left: 0;
line-height: 1;
font-size: 0.875rem;
${({ orientation }) =>
orientation === 'vertical'
? css`
transform: translate(${tickHeight + 2}px, ${tickHeight + 1}px);
`
: css`
transform: translate(-0.5ch, calc(100% + 2px));
`}
`;
const Slider = forwardRef(
(
{
defaultValue,
disabled = false,
marks: marksProp = false,
max = 100,
min = 0,
name,
onChange,
onChangeCommitted,
onMouseDown,
orientation = 'horizontal',
size = '100%',
step = 1,
value,
variant = 'default',
...otherProps
},
ref
) => {
const Groove = variant === 'flat' ? StyledFlatGroove : StyledGroove;
const vertical = orientation === 'vertical';
const [valueDerived = min, setValueState] = useControlledOrUncontrolled({
defaultValue,
onChange: onChange ?? onChangeCommitted,
value
});
const {
isFocusVisible,
onBlurVisible,
ref: focusVisibleRef
} = useIsFocusVisible();
const [focusVisible, setFocusVisible] = useState(false);
const sliderRef = useRef();
const thumbRef = useRef(null);
const handleFocusRef = useForkRef(focusVisibleRef, sliderRef);
const handleRef = useForkRef(ref, handleFocusRef);
const handleFocus = useEventCallback(
(event: React.FocusEvent) => {
if (isFocusVisible(event)) {
setFocusVisible(true);
}
}
);
const handleBlur = useEventCallback(() => {
if (focusVisible !== false) {
setFocusVisible(false);
onBlurVisible();
}
});
const touchId = useRef();
const marks = useMemo(
() =>
marksProp === true && Number.isFinite(step)
? [...Array(Math.round((max - min) / (step as number)) + 1)].map(
(_, index) => ({
label: undefined,
value: min + (step as number) * index
})
)
: Array.isArray(marksProp)
? marksProp
: [],
[marksProp, max, min, step]
);
const handleKeyDown = useEventCallback(
(event: React.KeyboardEvent) => {
const tenPercents = (max - min) / 10;
const marksValues = marks.map(mark => mark.value);
const marksIndex = marksValues.indexOf(valueDerived);
let newValue = 0;
switch (event.key) {
case 'Home':
newValue = min;
break;
case 'End':
newValue = max;
break;
case 'PageUp':
if (step) {
newValue = valueDerived + tenPercents;
}
break;
case 'PageDown':
if (step) {
newValue = valueDerived - tenPercents;
}
break;
case 'ArrowRight':
case 'ArrowUp':
if (step) {
newValue = valueDerived + step;
} else {
newValue =
marksValues[marksIndex + 1] ||
marksValues[marksValues.length - 1];
}
break;
case 'ArrowLeft':
case 'ArrowDown':
if (step) {
newValue = valueDerived - step;
} else {
newValue = marksValues[marksIndex - 1] || marksValues[0];
}
break;
default:
return;
}
// Prevent scroll of the page
event.preventDefault();
if (step) {
newValue = roundValueToStep(newValue, step, min);
}
newValue = clamp(newValue, min, max);
setValueState(newValue);
setFocusVisible(true);
onChange?.(newValue);
onChangeCommitted?.(newValue);
}
);
const getNewValue = useCallback(
(finger: { x: number; y: number }) => {
if (!sliderRef.current) {
return 0;
}
const rect = sliderRef.current.getBoundingClientRect();
let percent;
if (vertical) {
percent = (rect.bottom - finger.y) / rect.height;
} else {
percent = (finger.x - rect.left) / rect.width;
}
let newValue;
newValue = percentToValue(percent, min, max);
if (step) {
newValue = roundValueToStep(newValue, step, min);
} else {
const marksValues = marks.map(mark => mark.value);
const closestIndex = findClosest(marksValues, newValue);
newValue = marksValues[closestIndex];
}
newValue = clamp(newValue, min, max);
return newValue;
},
[marks, max, min, step, vertical]
);
const handleTouchMove = useEventCallback(
(event: MouseEvent | TouchEvent) => {
const finger = trackFinger(event, touchId.current);
if (!finger) {
return;
}
const newValue = getNewValue(finger);
thumbRef.current?.focus();
setValueState(newValue);
setFocusVisible(true);
onChange?.(newValue);
}
);
const handleTouchEnd = useEventCallback(
(event: MouseEvent | TouchEvent) => {
const finger = trackFinger(event, touchId.current);
if (!finger) {
return;
}
const newValue = getNewValue(finger);
onChangeCommitted?.(newValue);
touchId.current = undefined;
const doc = ownerDocument(sliderRef.current);
doc.removeEventListener('mousemove', handleTouchMove);
doc.removeEventListener('mouseup', handleTouchEnd);
doc.removeEventListener('touchmove', handleTouchMove);
doc.removeEventListener('touchend', handleTouchEnd);
}
);
const handleMouseDown = useEventCallback(
(event: React.MouseEvent) => {
// TODO should we also pass event together with new value to callbacks? (same thing with other input components)
onMouseDown?.(event);
event.preventDefault();
thumbRef.current?.focus();
setFocusVisible(true);
const finger = trackFinger(event, touchId.current);
if (finger) {
const newValue = getNewValue(finger);
setValueState(newValue);
onChange?.(newValue);
}
const doc = ownerDocument(sliderRef.current);
doc.addEventListener('mousemove', handleTouchMove);
doc.addEventListener('mouseup', handleTouchEnd);
}
);
const handleTouchStart = useEventCallback((event: TouchEvent) => {
// Workaround as Safari has partial support for touchAction: 'none'.
event.preventDefault();
const touch = event.changedTouches[0];
if (touch != null) {
// A number that uniquely identifies the current finger in the touch session.
touchId.current = touch.identifier;
}
thumbRef.current?.focus();
setFocusVisible(true);
const finger = trackFinger(event, touchId.current);
if (finger) {
const newValue = getNewValue(finger);
setValueState(newValue);
onChange?.(newValue);
}
const doc = ownerDocument(sliderRef.current);
doc.addEventListener('touchmove', handleTouchMove);
doc.addEventListener('touchend', handleTouchEnd);
});
useEffect(() => {
const { current: slider } = sliderRef;
slider?.addEventListener('touchstart', handleTouchStart);
const doc = ownerDocument(slider);
return () => {
slider?.removeEventListener('touchstart', handleTouchStart);
doc.removeEventListener('mousemove', handleTouchMove);
doc.removeEventListener('mouseup', handleTouchEnd);
doc.removeEventListener('touchmove', handleTouchMove);
doc.removeEventListener('touchend', handleTouchEnd);
};
}, [handleTouchEnd, handleTouchMove, handleTouchStart]);
return (
{/* should we keep the hidden input ? */}
{marks &&
marks.map(m => (
{m.label && (
{m.label}
)}
))}
);
}
);
Slider.displayName = 'Slider';
export { Slider, SliderProps };
================================================
FILE: src/Table/Table.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { Table } from './Table';
describe('', () => {
it('renders Table', () => {
const { container } = renderWithTheme();
const table = container.firstElementChild;
expect(table).toBeInTheDocument();
});
it('renders table element', () => {
const { getByRole } = renderWithTheme();
expect(getByRole('table')).toBeInTheDocument();
});
it('renders children', () => {
const { getByTestId } = renderWithTheme(
);
expect(getByTestId('children')).toBeInTheDocument();
});
});
================================================
FILE: src/Table/Table.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import {
Table,
TableBody,
TableDataCell,
TableHead,
TableHeadCell,
TableRow,
Window,
WindowContent,
WindowHeader
} from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Controls/Table',
component: Table,
subcomponents: {
Table,
TableBody,
TableHead,
TableRow,
TableHeadCell,
TableDataCell
},
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
Pokedex.exe
Type
Name
Level
🌿
Bulbasaur
64
🔥
Charizard
209
⚡
Pikachu
82
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Table/Table.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { StyledScrollView } from '../ScrollView/ScrollView';
import { CommonStyledProps } from '../types';
type TableProps = {
children?: React.ReactNode;
} & React.TableHTMLAttributes &
CommonStyledProps;
const StyledTable = styled.table`
display: table;
width: 100%;
border-collapse: collapse;
border-spacing: 0;
font-size: 1rem;
`;
const Wrapper = styled(StyledScrollView)`
&:before {
box-shadow: none;
}
`;
const Table = forwardRef(
({ children, ...otherProps }, ref) => {
return (
{children}
);
}
);
Table.displayName = 'Table';
export * from './TableBody';
export * from './TableDataCell';
export * from './TableHead';
export * from './TableHeadCell';
export * from './TableRow';
export { Table, TableProps };
================================================
FILE: src/Table/TableBody.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { TableBody } from './TableBody';
describe(' ', () => {
function mountInTable(node: React.ReactNode) {
const { container, getByTestId } = renderWithTheme();
return {
tbody: container.querySelector('table')?.firstElementChild,
getByTestId
};
}
it('renders TableBody', () => {
const { tbody } = mountInTable( );
expect(tbody).toBeInTheDocument();
expect(tbody?.tagName).toBe('TBODY');
});
it('renders children', () => {
const children = ;
const { getByTestId } = mountInTable({children} );
expect(getByTestId('tr')).toBeInTheDocument();
});
});
================================================
FILE: src/Table/TableBody.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { insetShadow } from '../common';
import { CommonStyledProps } from '../types';
type TableBodyProps = {
children?: React.ReactNode;
} & React.HTMLAttributes &
CommonStyledProps;
const StyledTableBody = styled.tbody`
background: ${({ theme }) => theme.canvas};
display: table-row-group;
box-shadow: ${insetShadow};
overflow-y: auto;
`;
const TableBody = forwardRef(
function TableBody({ children, ...otherProps }, ref) {
return (
{children}
);
}
);
TableBody.displayName = 'TableBody';
export { TableBody, TableBodyProps };
================================================
FILE: src/Table/TableDataCell.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { TableDataCell } from './TableDataCell';
describe(' ', () => {
function mountInTable(node: React.ReactNode) {
const { container, getByText } = renderWithTheme(
);
return {
td: container.querySelector('tr')?.firstElementChild,
getByText
};
}
it('renders TableDataCell', () => {
const { td } = mountInTable( );
expect(td?.tagName).toBe('TD');
});
it('renders children', () => {
const { getByText } = mountInTable(children );
expect(getByText('children')).toBeInTheDocument();
});
});
================================================
FILE: src/Table/TableDataCell.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { CommonStyledProps } from '../types';
type TableDataCellProps = {
children?: React.ReactNode;
} & React.HTMLAttributes &
CommonStyledProps;
const StyledTd = styled.td`
padding: 0 8px;
`;
const TableDataCell = forwardRef(
function TableDataCell({ children, ...otherProps }, ref) {
return (
{children}
);
}
);
TableDataCell.displayName = 'TableDataCell';
export { TableDataCell, TableDataCellProps };
================================================
FILE: src/Table/TableHead.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { TableHead } from './TableHead';
describe(' ', () => {
function mountInTable(node: React.ReactNode) {
const { container, getByTestId } = renderWithTheme();
return {
tbody: container.querySelector('table')?.firstElementChild,
getByTestId
};
}
it('renders TableHead', () => {
const { tbody } = mountInTable( );
expect(tbody).toBeInTheDocument();
expect(tbody?.tagName).toBe('THEAD');
});
it('renders children', () => {
const children = ;
const { getByTestId } = mountInTable({children} );
expect(getByTestId('tr')).toBeInTheDocument();
});
});
================================================
FILE: src/Table/TableHead.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { CommonStyledProps } from '../types';
type TableHeadProps = {
children?: React.ReactNode;
} & React.HTMLAttributes &
CommonStyledProps;
const StyledTableHead = styled.thead`
display: table-header-group;
`;
const TableHead = forwardRef(
function TableHead({ children, ...otherProps }, ref) {
return (
{children}
);
}
);
TableHead.displayName = 'TableHead';
export { TableHead, TableHeadProps };
================================================
FILE: src/Table/TableHeadCell.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { TableHeadCell } from './TableHeadCell';
describe(' ', () => {
function mountInTable(node: React.ReactNode) {
const { container, getByText } = renderWithTheme(
);
return {
th: container.querySelector('tr')?.firstElementChild as HTMLElement,
getByText
};
}
it('renders TableHeadCell', () => {
const { th } = mountInTable( );
expect(th?.tagName).toBe('TH');
});
it('renders children', () => {
const { getByText } = mountInTable(children );
expect(getByText('children')).toBeInTheDocument();
});
describe('prop: sort', () => {
it('should render without aria-sort attribute by default', () => {
const { th } = mountInTable( );
expect(th).not.toHaveAttribute('aria-sort');
});
it('should render aria-sort="ascending" when prop sort="asc" provided', () => {
const { th } = mountInTable( );
expect(th).toHaveAttribute('aria-sort', 'ascending');
});
it('should render aria-sort="descending" when prop sort="desc" provided', () => {
const { th } = mountInTable( );
expect(th).toHaveAttribute('aria-sort', 'descending');
});
});
describe('prop: disabled', () => {
it('should disable th element', () => {
const handleChange = jest.fn();
const { th } = mountInTable(
);
expect(th).toHaveAttribute('aria-disabled', 'true');
th?.click?.();
expect(handleChange).not.toHaveBeenCalled();
});
});
});
================================================
FILE: src/Table/TableHeadCell.tsx
================================================
import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import { createBorderStyles, createDisabledTextStyles } from '../common';
import { noOp } from '../common/utils';
import { CommonStyledProps } from '../types';
type TableHeadCellProps = {
children?: React.ReactNode;
disabled?: boolean;
sort?: 'asc' | 'desc' | null;
} & React.TdHTMLAttributes &
CommonStyledProps;
const StyledHeadCell = styled.th<{ $disabled: boolean }>`
position: relative;
padding: 0 8px;
display: table-cell;
vertical-align: inherit;
background: ${({ theme }) => theme.material};
cursor: default;
user-select: none;
&:before {
box-sizing: border-box;
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
${createBorderStyles()}
border-left: none;
border-top: none;
}
${({ $disabled }) =>
!$disabled &&
css`
&:active {
&:before {
${createBorderStyles({ invert: true, style: 'window' })}
border-left: none;
border-top: none;
padding-top: 2px;
}
& > div {
position: relative;
top: 2px;
}
}
`}
color: ${({ theme }) => theme.materialText};
${({ $disabled }) => $disabled && createDisabledTextStyles()}
&:hover {
color: ${({ theme }) => theme.materialText};
${({ $disabled }) => $disabled && createDisabledTextStyles()}
}
`;
const TableHeadCell = forwardRef(
function TableHeadCell(
{
disabled = false,
children,
onClick,
onTouchStart = noOp,
sort,
...otherProps
},
ref
) {
const ariaSort =
sort === 'asc' ? 'ascending' : sort === 'desc' ? 'descending' : undefined;
return (
{children}
);
}
);
TableHeadCell.displayName = 'TableHeadCell';
export { TableHeadCell, TableHeadCellProps };
================================================
FILE: src/Table/TableRow.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { TableRow } from './TableRow';
describe(' ', () => {
function mountInTable(node: React.ReactNode) {
const { container, getByTestId } = renderWithTheme(
);
return {
tr: container.querySelector('tbody')?.firstElementChild,
getByTestId
};
}
it('renders TableRow', () => {
const { tr } = mountInTable( );
expect(tr?.tagName).toBe('TR');
});
it('renders children', () => {
const children = ;
const { getByTestId } = mountInTable({children} );
expect(getByTestId('td')).toBeInTheDocument();
});
});
================================================
FILE: src/Table/TableRow.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { blockSizes } from '../common/system';
type TableRowProps = {
children?: React.ReactNode;
} & React.HTMLAttributes;
const StyledTr = styled.tr`
color: inherit;
display: table-row;
height: calc(${blockSizes.md} - 2px);
line-height: calc(${blockSizes.md} - 2px);
vertical-align: middle;
outline: none;
color: ${({ theme }) => theme.canvasText};
&:hover {
background: ${({ theme }) => theme.hoverBackground};
color: ${({ theme }) => theme.canvasTextInvert};
}
`;
const TableRow = forwardRef(
function TableRow({ children, ...otherProps }, ref) {
return (
{children}
);
}
);
TableRow.displayName = 'TableRow';
export { TableRow, TableRowProps };
================================================
FILE: src/Tabs/Tab.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { Tab } from './Tab';
describe(' ', () => {
describe('prop: children', () => {
it('should render passed child', () => {
const child = 'Hey there!';
const { getByText } = renderWithTheme({child} );
expect(getByText(child)).toBeInTheDocument();
});
});
describe('prop: selected', () => {
it('should render with correct aria attribute', () => {
const { getByRole } = renderWithTheme( );
expect(getByRole('tab')).toHaveAttribute('aria-selected', 'true');
});
});
describe('prop: onClick', () => {
it('should be called when a click is triggered', () => {
const handleClick = jest.fn();
const { getByRole } = renderWithTheme( );
getByRole('tab').click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
});
================================================
FILE: src/Tabs/Tab.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { createBorderStyles, createBoxStyles, focusOutline } from '../common';
import { blockSizes } from '../common/system';
import { CommonStyledProps } from '../types';
type TabProps = {
children?: React.ReactNode;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick?: (value: any, event: React.MouseEvent) => void;
selected?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value?: any;
} & Omit, 'onClick' | 'value'> &
CommonStyledProps;
const StyledTab = styled.button`
${createBoxStyles()}
${createBorderStyles()}
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
height: ${blockSizes.md};
line-height: ${blockSizes.md};
padding: 0 8px;
border-bottom: none;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
margin: 0 0 -2px 0;
cursor: default;
color: ${({ theme }) => theme.materialText};
user-select: none;
font-family: inherit;
&:focus:after,
&:active:after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
${focusOutline}
outline-offset: -6px;
}
${props =>
props.selected &&
`
z-index: 1;
height: calc(${blockSizes.md} + 4px);
top: -4px;
margin-bottom: -6px;
padding: 0 16px;
margin-left: -8px;
&:not(:last-child) {
margin-right: -8px;
}
`}
&:before {
content: '';
position: absolute;
width: calc(100% - 4px);
height: 6px;
background: ${({ theme }) => theme.material};
bottom: -4px;
left: 2px;
}
`;
const Tab = forwardRef(
({ value, onClick, selected = false, children, ...otherProps }, ref) => {
return (
) =>
onClick?.(value, e)
}
ref={ref}
role='tab'
{...otherProps}
>
{children}
);
}
);
Tab.displayName = 'Tab';
export { Tab, TabProps };
================================================
FILE: src/Tabs/TabBody.spec.tsx
================================================
import React from 'react';
import { renderWithTheme } from '../../test/utils';
import { TabBody } from './TabBody';
describe(' ', () => {
describe('prop: children', () => {
it('should render passed child', () => {
const child = 'Hey there!';
const { getByText } = renderWithTheme({child} );
expect(getByText(child)).toBeInTheDocument();
});
});
});
================================================
FILE: src/Tabs/TabBody.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { createBorderStyles, createBoxStyles } from '../common';
import { CommonStyledProps } from '../types';
type TabBodyProps = {
children: React.ReactNode;
} & React.HTMLAttributes &
CommonStyledProps;
const StyledTabBody = styled.div`
${createBoxStyles()}
${createBorderStyles()}
position: relative;
display: block;
height: 100%;
padding: 16px;
font-size: 1rem;
`;
const TabBody = forwardRef(
({ children, ...otherProps }, ref) => {
return (
{children}
);
}
);
TabBody.displayName = 'TabBody';
export { TabBody, TabBodyProps };
================================================
FILE: src/Tabs/Tabs.spec.tsx
================================================
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { Tab } from '..';
import { renderWithTheme } from '../../test/utils';
import { Tabs } from './Tabs';
describe(' ', () => {
describe('prop: children', () => {
it('should accept a null child', () => {
const { getAllByRole } = renderWithTheme(
{null}
);
expect(getAllByRole('tab').length).toBe(1);
});
});
describe('prop: value', () => {
const tabs = (
);
it('should pass selected prop to children', () => {
const { getAllByRole } = renderWithTheme(tabs);
const tabElements = getAllByRole('tab');
expect(tabElements[0]).toHaveAttribute('aria-selected', 'false');
expect(tabElements[1]).toHaveAttribute('aria-selected', 'true');
});
it('should accept any value as selected tab value', () => {
const tab0 = {};
const tab1 = {};
expect(tab0).not.toBe(tab1);
const { getAllByRole } = renderWithTheme(
);
const tabElements = getAllByRole('tab');
expect(tabElements[0]).toHaveAttribute('aria-selected', 'true');
expect(tabElements[1]).toHaveAttribute('aria-selected', 'false');
});
});
describe('prop: onChange', () => {
it('should call onChange when clicking', () => {
const handleChange = jest.fn();
const { getAllByRole } = renderWithTheme(
);
fireEvent.click(getAllByRole('tab')[1]);
expect(handleChange).toBeCalledTimes(1);
expect(handleChange.mock.calls[0][0]).toBe(1);
});
});
describe('prop: rows', () => {
it('should render specified number of rows', () => {
const tabs = (
{/* row 1 */}
{/* row 2 */}
{/* row 3 */}
{/* row 4 */}
);
const { getAllByTestId } = renderWithTheme(tabs);
const rowElements = getAllByTestId('tab-row');
expect(rowElements.length).toBe(4);
});
it('row containing currently selected tab should be at the bottom (last row)', () => {
const tabs = (
);
const { container, getAllByTestId } = renderWithTheme(tabs);
const rowElements = getAllByTestId('tab-row');
const selectedTab = container.querySelector('[aria-selected=true]');
expect(rowElements?.pop()?.contains(selectedTab)).toBe(true);
});
});
});
================================================
FILE: src/Tabs/Tabs.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
import {
Anchor,
Checkbox,
GroupBox,
NumberInput,
Tab,
TabBody,
Tabs,
Window,
WindowContent,
WindowHeader
} from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Controls/Tabs',
component: Tabs,
subcomponents: { Tab, TabBody },
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
const [state, setState] = useState({
activeTab: 0
});
const handleChange = (
value: number,
event: React.MouseEvent
) => {
console.log({ value, event });
setState({ activeTab: value });
};
const { activeTab } = state;
return (
store.exe
Shoes
Accesories
Clothing
{activeTab === 0 && (
Amount:
null}
defaultChecked
/>
)}
{activeTab === 1 && (
)}
{activeTab === 2 && (
)}
);
}
Default.story = {
name: 'default'
};
export function MultiRow() {
const [state, setState] = useState({
activeTab: 'Shoes'
});
const handleChange = (value: string) => setState({ activeTab: value });
const { activeTab } = state;
return (
store.exe
Shoes
Accesories
Clothing
Cars
Electronics
Art
Perfumes
Games
Food
Currently active tab: {activeTab}
Keep in mind that multi row tabs are{' '}
REALLY bad UX
. We've added them just because it was a thing back in the day,
but there are better ways to handle navigation with many options.
);
}
MultiRow.story = {
name: 'multi row'
};
================================================
FILE: src/Tabs/Tabs.tsx
================================================
import React, { forwardRef, useMemo } from 'react';
import styled from 'styled-components';
import { noOp } from '../common/utils';
import { CommonStyledProps } from '../types';
import { TabProps } from './Tab';
type TabsProps = {
value?: TabProps['value'];
onChange?: TabProps['onClick'];
children?: React.ReactNode;
rows?: number;
} & Omit, 'onChange' | 'value'> &
CommonStyledProps;
const StyledTabs = styled.div<{ isMultiRow: boolean }>`
position: relative;
${({ isMultiRow, theme }) =>
isMultiRow &&
`
button {
flex-grow: 1;
}
button:last-child:before {
border-right: 2px solid ${theme.borderDark};
}
`}
`;
const Row = styled.div.attrs(() => ({
'data-testid': 'tab-row'
}))`
position: relative;
display: flex;
flex-wrap: no-wrap;
text-align: left;
left: 8px;
width: calc(100% - 8px);
&:not(:first-child):before {
content: '';
position: absolute;
right: 0;
left: 0;
height: 100%;
border-right: 2px solid ${({ theme }) => theme.borderDarkest};
border-left: 2px solid ${({ theme }) => theme.borderLightest};
}
`;
function splitToChunks(array: T[], parts: number) {
const result = [];
for (let i = parts; i > 0; i -= 1) {
result.push(array.splice(0, Math.ceil(array.length / i)));
}
return result;
}
const Tabs = forwardRef(
({ value, onChange = noOp, children, rows = 1, ...otherProps }, ref) => {
// split tabs into equal rows and assign key to each row
const tabRowsToRender = useMemo(() => {
const childrenWithProps =
React.Children.map(children, child => {
if (!React.isValidElement(child)) {
return null;
}
const tabProps = {
selected: child.props.value === value,
onClick: onChange
};
return React.cloneElement(child, tabProps);
}) ?? [];
const tabRows = splitToChunks(childrenWithProps, rows).map((tabs, i) => ({
key: i,
tabs
}));
// move row containing currently selected tab to the bottom
const currentlySelectedRowIndex = tabRows.findIndex(tabRow =>
tabRow.tabs.some(tab => tab.props.selected)
);
tabRows.push(tabRows.splice(currentlySelectedRowIndex, 1)[0]);
return tabRows;
}, [children, onChange, rows, value]);
return (
1}
role='tablist'
ref={ref}
>
{tabRowsToRender.map(row => (
{row.tabs}
))}
);
}
);
Tabs.displayName = 'Tabs';
export * from './Tab';
export * from './TabBody';
export { Tabs, TabsProps };
================================================
FILE: src/TextInput/TextInput.spec.tsx
================================================
// Pretty much straight out copied from https://github.com/mui-org/material-ui 😂
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { renderWithTheme } from '../../test/utils';
import { TextInput } from './TextInput';
describe(' ', () => {
it('should render an inside the div', () => {
const { container } = renderWithTheme( );
const input = container.querySelector('input');
expect(input).toHaveAttribute('type', 'text');
expect(input).not.toHaveAttribute('required');
});
it('should fire event callbacks', () => {
const handleChange = jest.fn();
const handleFocus = jest.fn();
const handleBlur = jest.fn();
const handleKeyUp = jest.fn();
const handleKeyDown = jest.fn();
const { getByRole } = renderWithTheme(
);
const input = getByRole('textbox');
// simulating user input: gain focus, key input (keydown, (input), change, keyup), blur
input.focus();
expect(handleFocus).toHaveBeenCalledTimes(1);
fireEvent.keyDown(document.activeElement as HTMLInputElement, { key: 'a' });
expect(handleKeyDown).toHaveBeenCalledTimes(1);
fireEvent.change(input, { target: { value: 'a' } });
expect(handleChange).toHaveBeenCalledTimes(1);
fireEvent.keyUp(document.activeElement as HTMLInputElement, { key: 'a' });
expect(handleKeyUp).toHaveBeenCalledTimes(1);
input.blur();
expect(handleBlur).toHaveBeenCalledTimes(1);
});
it('should considered [] as controlled', () => {
const { getByRole } = renderWithTheme( );
const input = getByRole('textbox');
expect(input).toHaveProperty('value', '');
fireEvent.change(input, { target: { value: 'do not work' } });
expect(input).toHaveProperty('value', '');
});
it('should forwardRef to native input', () => {
const inputRef = React.createRef();
const { getByRole } = renderWithTheme( );
const input = getByRole('textbox');
expect(inputRef.current).toBe(input);
});
describe('multiline', () => {
it('should render textarea when passed the multiline prop', () => {
const { container } = renderWithTheme( );
const textarea = container.querySelector('textarea');
expect(textarea).not.toBe(null);
});
it('should forward rows prop', () => {
const { container } = renderWithTheme( );
const textarea = container.querySelector('textarea');
expect(textarea).toHaveAttribute('rows', '3');
});
});
describe('prop: disabled', () => {
it('should render a disabled ', () => {
const { container } = renderWithTheme( );
const input = container.querySelector('input');
expect(input).toHaveAttribute('disabled');
});
it('should be overridden by props', () => {
const { getByRole, rerender } = renderWithTheme( );
rerender( );
const input = getByRole('textbox');
expect(input).not.toHaveAttribute('disabled');
});
});
describe('prop: variant', () => {
it('should be "default" by default', () => {
const { getByTestId } = renderWithTheme( );
expect(getByTestId('variant-default')).toBeInTheDocument();
});
it('should handle "flat" variant', () => {
const { getByTestId } = renderWithTheme( );
expect(getByTestId('variant-flat')).toBeInTheDocument();
});
});
describe('prop: fullWidth', () => {
it('should make component take 100% width', () => {
const { container } = renderWithTheme( );
expect(
window.getComputedStyle(container.firstChild as HTMLInputElement).width
).toBe('100%');
});
});
});
================================================
FILE: src/TextInput/TextInput.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
import { Button, ScrollView, TextInput } from 'react95';
import styled from 'styled-components';
const loremIpsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas sollicitudin, ante vel porttitor posuere, tellus nisi interdum ipsum, non bibendum ante risus ut purus. Curabitur vel posuere odio. Vivamus rutrum, nunc et ullamcorper sagittis, tellus ligula maximus quam, id dapibus sapien metus lobortis diam. Proin luctus, dolor in finibus feugiat, lacus enim gravida sem, quis aliquet tellus leo nec enim. Morbi varius bibendum augue quis venenatis. Curabitur ut elit augue. Pellentesque posuere enim a mattis interdum. Donec sodales convallis turpis, a vulputate elit. Suspendisse potenti.`;
const onChange = (
e: React.ChangeEvent
) => console.log(e.target.value);
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
#cutout {
padding: 1rem;
width: 400px;
background: ${({ theme }) => theme.canvas};
}
`;
export default {
title: 'Controls/TextInput',
component: TextInput,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
const [state, setState] = useState({
value: ''
});
const handleChange = (e: React.ChangeEvent) =>
setState({ value: e.target.value });
const reset = () => setState({ value: '' });
return (
);
}
Default.story = {
name: 'default'
};
export function Flat() {
return (
When you want to add input field on a light background (like scrollable
content), just use the flat variant:
);
}
Flat.story = {
name: 'flat'
};
================================================
FILE: src/TextInput/TextInput.tsx
================================================
import React, { forwardRef, useMemo } from 'react';
import styled, { css } from 'styled-components';
import {
createDisabledTextStyles,
createFlatBoxStyles,
createScrollbars
} from '../common';
import { blockSizes } from '../common/system';
import { noOp } from '../common/utils';
import { StyledScrollView } from '../ScrollView/ScrollView';
import { CommonStyledProps, CommonThemeProps } from '../types';
type TextInputInputProps = {
multiline?: false | undefined;
onChange?: React.ChangeEventHandler;
/** @default text */
type?: React.HTMLInputTypeAttribute;
} & Omit<
React.InputHTMLAttributes,
'className' | 'disabled' | 'style' | 'type'
>;
type TextInputTextAreaProps = {
multiline: true;
onChange?: React.ChangeEventHandler;
} & Omit<
React.TextareaHTMLAttributes,
'className' | 'disabled' | 'style' | 'type'
>;
type TextInputProps = {
className?: string;
disabled?: boolean;
fullWidth?: boolean;
multiline?: boolean;
shadow?: boolean;
style?: React.CSSProperties;
variant?: 'default' | 'flat';
} & (TextInputInputProps | TextInputTextAreaProps) &
CommonStyledProps;
type WrapperProps = Pick &
CommonThemeProps;
const sharedWrapperStyles = css`
display: flex;
align-items: center;
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
min-height: ${blockSizes.md};
`;
const Wrapper = styled(StyledScrollView).attrs({
'data-testid': 'variant-default'
})`
${sharedWrapperStyles}
background: ${({ $disabled, theme }) =>
$disabled ? theme.material : theme.canvas};
`;
const FlatWrapper = styled.div.attrs({
'data-testid': 'variant-flat'
})`
${createFlatBoxStyles()}
${sharedWrapperStyles}
position: relative;
`;
type InputProps = Pick;
const sharedInputStyles = css`
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
outline: none;
border: none;
background: none;
font-size: 1rem;
min-height: 27px;
font-family: inherit;
color: ${({ theme }) => theme.canvasText};
${({ disabled, variant }) =>
variant !== 'flat' && disabled && createDisabledTextStyles()}
`;
const StyledTextInput = styled.input`
${sharedInputStyles}
padding: 0 8px;
`;
const StyledTextArea = styled.textarea`
${sharedInputStyles}
padding: 8px;
resize: none;
${({ variant }) => createScrollbars(variant)}
`;
const TextInput = forwardRef<
HTMLInputElement | HTMLTextAreaElement,
TextInputProps
>(
(
{
className,
disabled = false,
fullWidth,
onChange = noOp,
shadow = true,
style,
variant = 'default',
...otherProps
},
ref
) => {
const WrapperComponent = variant === 'flat' ? FlatWrapper : Wrapper;
const field = useMemo(
() =>
otherProps.multiline ? (
) : (
),
[disabled, onChange, otherProps, ref, variant]
);
return (
{field}
);
}
);
TextInput.displayName = 'TextInput';
export { TextInput, TextInputProps };
================================================
FILE: src/Toolbar/Toolbar.spec.tsx
================================================
import { render } from '@testing-library/react';
import React from 'react';
import { Toolbar } from './Toolbar';
describe(' ', () => {
it('should render', () => {
const { container } = render( );
const toolbar = container.firstChild;
expect(toolbar).toBeInTheDocument();
});
it('should render children', () => {
const { container } = render(A nice app bar );
const toolbar = container.firstChild;
expect(toolbar).toHaveTextContent('A nice app bar');
});
describe('prop: noPadding', () => {
it('should apply 0 padding', () => {
const { container } = render( );
const toolbar = container.firstChild;
expect(toolbar).toHaveStyleRule('padding', '0');
});
});
});
================================================
FILE: src/Toolbar/Toolbar.tsx
================================================
import React, { forwardRef } from 'react';
import styled from 'styled-components';
type ToolbarProps = {
children?: React.ReactNode;
noPadding?: boolean;
} & React.HTMLAttributes;
const StyledToolbar = styled.div`
position: relative;
display: flex;
align-items: center;
padding: ${props => (props.noPadding ? '0' : '4px')};
`;
const Toolbar = forwardRef(function Toolbar(
{ children, noPadding = false, ...otherProps },
ref
) {
return (
{children}
);
});
Toolbar.displayName = 'Toolbar';
export { Toolbar };
================================================
FILE: src/Tooltip/Tooltip.spec.tsx
================================================
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { Tooltip, TooltipProps } from './Tooltip';
const getProps = (
props: Partial = {}
): Omit => ({
className: props.className,
disableFocusListener: props.disableFocusListener,
disableMouseListener: props.disableMouseListener,
enterDelay: props.enterDelay !== undefined ? props.enterDelay : 0,
leaveDelay: props.leaveDelay !== undefined ? props.leaveDelay : 0,
onBlur: jest.fn(),
onClose: jest.fn(),
onFocus: jest.fn(),
onMouseEnter: jest.fn(),
onMouseLeave: jest.fn(),
onOpen: jest.fn(),
style: props.style,
text: 'I am the tooltip'
});
const renderTooltip = (props: Omit) => (
Kid
);
describe(' ', () => {
describe('render', () => {
it('should render wrapper element', () => {
const { getByTestId } = render(renderTooltip(getProps()));
const wrapper = getByTestId('tooltip-wrapper');
expect(wrapper).toBeInTheDocument();
expect(wrapper.tagName).toBe('DIV');
});
it('should render inner tooltip', () => {
const { getByTestId } = render(renderTooltip(getProps()));
const tip = getByTestId('tooltip');
expect(tip).toBeInTheDocument();
expect(tip.tagName).toBe('SPAN');
});
it('should render children', () => {
const { getByText } = render(renderTooltip(getProps()));
const children = getByText('Kid');
expect(children).toBeInTheDocument();
expect(children.tagName).toBe('DIV');
});
it('should render tooltip with provided className', () => {
const { getByTestId } = render(
renderTooltip(
getProps({
className: 'my-tip'
})
)
);
const tip = getByTestId('tooltip');
expect(tip.className.includes('my-tip')).toBeTruthy();
});
});
describe('transition delays', () => {
beforeEach(() => {
jest.useFakeTimers({
legacyFakeTimers: true
});
});
afterEach(() => {
jest.useRealTimers();
});
it('should respect enterDelay', async () => {
const { getByTestId } = render(
renderTooltip(
getProps({
enterDelay: 5
})
)
);
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.focus(wrapper);
expect(window.setTimeout).toHaveBeenCalledWith(expect.any(Function), 5);
});
it('should respect leaveDelay', async () => {
const { getByTestId } = render(
renderTooltip(
getProps({
leaveDelay: 6
})
)
);
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.blur(wrapper);
expect(window.setTimeout).toHaveBeenCalledWith(expect.any(Function), 6);
});
});
describe('event callbacks', () => {
it('should handle onFocus events, and call onOpen', async () => {
const props = getProps();
const { getByTestId } = render(renderTooltip(props));
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.focus(wrapper);
await waitFor(() => {
expect(props.onFocus).toHaveBeenCalled();
});
await waitFor(() => {
expect(props.onOpen).toHaveBeenCalled();
});
});
it('should handle onBlur events, and call onClose', async () => {
const props = getProps();
const { getByTestId } = render(renderTooltip(props));
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.focus(wrapper);
await waitFor(() => {
expect(props.onFocus).toHaveBeenCalled();
});
fireEvent.blur(wrapper);
await waitFor(() => {
expect(props.onBlur).toHaveBeenCalled();
});
await waitFor(() => {
expect(props.onClose).toHaveBeenCalled();
});
});
it('should handle onMouseEnter events, and call onOpen', async () => {
const props = getProps();
const { getByTestId } = render(renderTooltip(props));
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.mouseEnter(wrapper);
await waitFor(() => {
expect(props.onMouseEnter).toHaveBeenCalled();
});
await waitFor(() => {
expect(props.onOpen).toHaveBeenCalled();
});
});
it('should handle onMouseLeave events, and call onClose', async () => {
const props = getProps();
const { getByTestId } = render(renderTooltip(props));
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.mouseEnter(wrapper);
await waitFor(() => {
expect(props.onMouseEnter).toHaveBeenCalled();
});
fireEvent.mouseLeave(wrapper);
await waitFor(() => {
expect(props.onMouseLeave).toHaveBeenCalled();
});
await waitFor(() => {
expect(props.onClose).toHaveBeenCalled();
});
});
it('should not handle onFocus events when disableFocusListener is true', () => {
const props = getProps({ disableFocusListener: true });
const { getByTestId } = render(renderTooltip(props));
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.focus(wrapper);
expect(props.onFocus).not.toHaveBeenCalled();
});
it('should not handle onBlur events when disableFocusListener is true', () => {
const props = getProps({ disableFocusListener: true });
const { getByTestId } = render(renderTooltip(props));
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.blur(wrapper);
expect(props.onBlur).not.toHaveBeenCalled();
});
it('should not handle onMouseEnter events when disableMouseListener is true', () => {
const props = getProps({ disableMouseListener: true });
const { getByTestId } = render(renderTooltip(props));
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.mouseEnter(wrapper);
expect(props.onMouseEnter).not.toHaveBeenCalled();
});
it('should not handle onMouseLeave events when disableMouseListener is true', () => {
const props = getProps({ disableMouseListener: true });
const { getByTestId } = render(renderTooltip(props));
const wrapper = getByTestId('tooltip-wrapper');
fireEvent.mouseLeave(wrapper);
expect(props.onMouseLeave).not.toHaveBeenCalled();
});
});
});
================================================
FILE: src/Tooltip/Tooltip.stories.tsx
================================================
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { Button, Tooltip } from 'react95';
import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
`;
export default {
title: 'Controls/Tooltip',
component: Tooltip,
decorators: [story => {story()} ]
} as ComponentMeta;
export function Default() {
return (
Hover me
);
}
Default.story = {
name: 'default'
};
================================================
FILE: src/Tooltip/Tooltip.tsx
================================================
import React, { forwardRef, useState } from 'react';
import styled from 'styled-components';
import { shadow } from '../common';
import { isReactFocusEvent, isReactMouseEvent } from '../common/utils/events';
import { CommonStyledProps } from '../types';
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
type TooltipProps = {
children: React.ReactNode;
className?: string;
disableFocusListener?: boolean;
disableMouseListener?: boolean;
enterDelay?: number;
leaveDelay?: number;
onBlur?: React.FocusEventHandler;
onClose?: (
event: React.FocusEvent | React.MouseEvent
) => void;
onFocus?: React.FocusEventHandler;
onMouseEnter?: React.MouseEventHandler;
onMouseLeave?: React.MouseEventHandler