diff --git a/.babelrc b/.babelrc
index 76a102e0..47143425 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,24 +1,59 @@
{
+ "sourceType": "unambiguous",
"presets": [
[
"@babel/preset-env",
{
- "modules": false
+ "shippedProposals": true,
+ "loose": true
}
],
- "@babel/preset-react"
+ "@babel/preset-typescript"
],
"plugins": [
- "babel-plugin-styled-components",
- "@babel/plugin-proposal-class-properties",
- "@babel/plugin-transform-runtime"
- ],
- "env": {
- "test": {
- "presets": ["@babel/preset-env"],
- "plugins": [
- ["babel-plugin-styled-components", { "ssr": false, "displayName": false }]
- ]
- }
- }
+ "@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"
+ ]
}
diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json
index 696148de..75f1dc74 100644
--- a/.codesandbox/ci.json
+++ b/.codesandbox/ci.json
@@ -1,5 +1,7 @@
{
- "installCommand": "install:codesandbox",
"buildCommand": "build:prod",
- "sandboxes": ["react95-template-xkfj0"]
+ "node": "16",
+ "sandboxes": [
+ "react95-template-xkfj0"
+ ]
}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..20908cd1
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = yes
+insert_final_newline = yes
diff --git a/.eslintrc.js b/.eslintrc.js
index ebc3adc9..fa7520aa 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,6 +1,11 @@
module.exports = {
- extends: ['airbnb', 'plugin:prettier/recommended'],
- parser: '@babel/eslint-parser',
+ extends: [
+ 'plugin:@typescript-eslint/recommended',
+ 'airbnb',
+ 'plugin:prettier/recommended',
+ 'plugin:react-hooks/recommended'
+ ],
+ parser: '@typescript-eslint/parser',
plugins: ['react', 'prettier'],
env: {
browser: true,
@@ -8,23 +13,60 @@ module.exports = {
jest: true
},
rules: {
- 'import/no-unresolved': ['error', { ignore: ['react95'] }],
+ '@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': [1, { extensions: ['.js', '.jsx'] }],
+ '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)', '*.stories.@(js|jsx)'],
+ 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: {}
}
- ]
+ }
};
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2dff49f6..a2672467 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,58 @@ 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:
diff --git a/.storybook/decorators/globalStyle.js b/.storybook/decorators/withGlobalStyle.tsx
similarity index 77%
rename from .storybook/decorators/globalStyle.js
rename to .storybook/decorators/withGlobalStyle.tsx
index bfd13662..39c0d2e1 100644
--- a/.storybook/decorators/globalStyle.js
+++ b/.storybook/decorators/withGlobalStyle.tsx
@@ -1,10 +1,10 @@
+import { DecoratorFn } from '@storybook/react';
import React from 'react';
import { createGlobalStyle } from 'styled-components';
-import styleReset from '../../src/common/styleReset';
-// TODO is there a way to keep import paths consistent with what end user will get?
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}
@@ -20,14 +20,21 @@ const GlobalStyle = createGlobalStyle`
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 default storyFn => (
+export const withGlobalStyle: DecoratorFn = story => (
<>
- {storyFn()}
+ {story()}
>
);
diff --git a/.storybook/logo.png b/.storybook/logo.png
new file mode 100644
index 00000000..cbcb9d48
Binary files /dev/null and b/.storybook/logo.png differ
diff --git a/.storybook/main.js b/.storybook/main.js
deleted file mode 100644
index e0510c61..00000000
--- a/.storybook/main.js
+++ /dev/null
@@ -1,18 +0,0 @@
-module.exports = {
- stories: ['../src/**/*.stories.@(js|mdx)'],
- addons: [
- {
- name: '@storybook/addon-docs',
- options: {
- sourceLoaderOptions: {
- injectStoryParameters: false
- }
- }
- },
- '@storybook/addon-storysource',
- 'storybook-addon-styled-component-theme/dist/register'
- ],
- features: {
- postcss: false
- }
-};
diff --git a/.storybook/main.ts b/.storybook/main.ts
new file mode 100644
index 00000000..e3d6d47a
--- /dev/null
+++ b/.storybook/main.ts
@@ -0,0 +1,52 @@
+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;
diff --git a/.storybook/manager.css b/.storybook/manager.css
new file mode 100644
index 00000000..0f4d9564
--- /dev/null
+++ b/.storybook/manager.css
@@ -0,0 +1,7 @@
+/* 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;
+}
diff --git a/.storybook/manager.ts b/.storybook/manager.ts
new file mode 100644
index 00000000..85696387
--- /dev/null
+++ b/.storybook/manager.ts
@@ -0,0 +1,8 @@
+import './manager.css';
+
+import { addons } from '@storybook/addons';
+import theme from './theme';
+
+addons.setConfig({
+ theme
+});
diff --git a/.storybook/preview.js b/.storybook/preview.js
deleted file mode 100644
index 1660f33b..00000000
--- a/.storybook/preview.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { withThemesProvider } from 'storybook-addon-styled-component-theme';
-import themes from '../src/common/themes';
-import GlobalStyle from './decorators/globalStyle';
-
-const {
- original,
- rainyDay,
- vaporTeal,
- theSixtiesUSA,
- olive,
- tokyoDark,
- rose,
- plum,
- matrix,
- travel,
- ...otherThemes
-} = themes;
-
-const reorderedThemes = {
- original,
- rainyDay,
- vaporTeal,
- theSixtiesUSA,
- olive,
- tokyoDark,
- rose,
- plum,
- matrix,
- travel,
- ...otherThemes
-};
-
-export const decorators = [
- GlobalStyle,
- withThemesProvider(Object.values(reorderedThemes))
-];
-
-export const parameters = {
- layout: 'fullscreen'
-};
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
new file mode 100644
index 00000000..088dfca6
--- /dev/null
+++ b/.storybook/preview.ts
@@ -0,0 +1,27 @@
+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'
+ ]
+ }
+ }
+};
diff --git a/.storybook/theme-picker/ThemeButton.tsx b/.storybook/theme-picker/ThemeButton.tsx
new file mode 100644
index 00000000..87c57329
--- /dev/null
+++ b/.storybook/theme-picker/ThemeButton.tsx
@@ -0,0 +1,26 @@
+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}
+
+
+ );
+}
diff --git a/.storybook/theme-picker/ThemeList.tsx b/.storybook/theme-picker/ThemeList.tsx
new file mode 100644
index 00000000..87e111b7
--- /dev/null
+++ b/.storybook/theme-picker/ThemeList.tsx
@@ -0,0 +1,77 @@
+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 => (
+
+ ))}
+
+ );
+}
diff --git a/.storybook/theme-picker/ThemeProvider.tsx b/.storybook/theme-picker/ThemeProvider.tsx
new file mode 100644
index 00000000..e8dd4e2a
--- /dev/null
+++ b/.storybook/theme-picker/ThemeProvider.tsx
@@ -0,0 +1,17 @@
+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()}
+
+ );
+};
diff --git a/.storybook/theme-picker/constants.ts b/.storybook/theme-picker/constants.ts
new file mode 100644
index 00000000..29654471
--- /dev/null
+++ b/.storybook/theme-picker/constants.ts
@@ -0,0 +1 @@
+export const THEMES_ID = 'storybook/themes';
diff --git a/.storybook/theme-picker/register.ts b/.storybook/theme-picker/register.ts
new file mode 100644
index 00000000..6c5d1834
--- /dev/null
+++ b/.storybook/theme-picker/register.ts
@@ -0,0 +1,17 @@
+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)
+});
diff --git a/.storybook/theme.js b/.storybook/theme.js
new file mode 100644
index 00000000..f5132194
--- /dev/null
+++ b/.storybook/theme.js
@@ -0,0 +1,36 @@
+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
+});
diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js
deleted file mode 100644
index bb31abf6..00000000
--- a/.storybook/webpack.config.js
+++ /dev/null
@@ -1,12 +0,0 @@
-const path = require('path');
-
-module.exports = ({ config }) => {
- config.resolve = Object.assign(config.resolve, {
- alias: {
- ...config.resolve.alias,
- react95: path.resolve(__dirname, '../src/index')
- }
- });
-
- return config;
-};
diff --git a/README.md b/README.md
index e585c68d..2cd1ee43 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
@@ -33,7 +33,7 @@ First, install component library and styled-components in your project directory
$ yarn add react95 styled-components
# npm
-$ npm i react95 styled-components
+$ npm install react95 styled-components
```
Apply style reset, wrap your app with ThemeProvider with theme of your choice... and you are ready to go! 🚀
@@ -42,7 +42,7 @@ Apply style reset, wrap your app with ThemeProvider with theme of your choice...
import React from 'react';
import { createGlobalStyle, ThemeProvider } from 'styled-components';
-import { styleReset, List, ListItem, Divider } from 'react95';
+import { MenuList, MenuListItem, Separator, styleReset } from 'react95';
// pick a theme of your choice
import original from 'react95/dist/themes/original';
// original Windows95 font (optionally)
@@ -72,12 +72,12 @@ const App = () => (
-
- 🎤 Sing
- 💃🏻 Dance
-
- 😴 Sleep
-
+
+ 🎤 Sing
+ 💃🏻 Dance
+
+ 😴 Sleep
+
);
diff --git a/docs/Contributing.stories.mdx b/docs/Contributing.stories.mdx
new file mode 100644
index 00000000..51b5cf45
--- /dev/null
+++ b/docs/Contributing.stories.mdx
@@ -0,0 +1,17 @@
+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
diff --git a/docs/installation.mdx b/docs/Getting-Started.stories.mdx
similarity index 61%
rename from docs/installation.mdx
rename to docs/Getting-Started.stories.mdx
index 2f887e7d..56cd9d8b 100644
--- a/docs/installation.mdx
+++ b/docs/Getting-Started.stories.mdx
@@ -1,9 +1,8 @@
----
-name: Getting Started
-route: /installation
----
+import { Meta } from '@storybook/addon-docs';
-## Installation
+
+
+# Installation
React95 is available as an [npm package](https://www.npmjs.com/package/react95).
@@ -12,23 +11,26 @@ React95 is available as an [npm package](https://www.npmjs.com/package/react95).
To install and save your `package.json` dependencies, run:
```sh
-// yarn
+# yarn
yarn add react95 styled-components
-// npm
-npm i -S 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 🙂
+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! 🚀
+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';
-import { styleReset, List, ListItem, Divider } from 'react95';
/* Pick a theme of your choice */
import original from 'react95/dist/themes/original';
@@ -50,7 +52,7 @@ const GlobalStyles = createGlobalStyle`
font-weight: bold;
font-style: normal
}
- body {
+ body, input, select, textarea {
font-family: 'ms_sans_serif';
}
${styleReset}
@@ -60,12 +62,12 @@ const App = () => (
-
- 🎤 Sing
- 💃🏻 Dance
-
- 😴 Sleep
-
+
+ 🎤 Sing
+ 💃🏻 Dance
+
+ 😴 Sleep
+
);
diff --git a/docs/submit-project.mdx b/docs/Submit-your-Project.stories.mdx
similarity index 66%
rename from docs/submit-project.mdx
rename to docs/Submit-your-Project.stories.mdx
index 131f93a2..ea280569 100644
--- a/docs/submit-project.mdx
+++ b/docs/Submit-your-Project.stories.mdx
@@ -1,9 +1,8 @@
----
-name: Submit your project
-route: /submit-project
----
+import { Meta } from '@storybook/addon-docs';
-### Submit your project
+
+
+# Submit your Project
Apps built with React95 will be featured on the official React95 [website](https://react95.io) 🤟🏻
diff --git a/docs/index.mdx b/docs/Welcome.stories.mdx
similarity index 51%
rename from docs/index.mdx
rename to docs/Welcome.stories.mdx
index 2c9b1452..1e96e430 100644
--- a/docs/index.mdx
+++ b/docs/Welcome.stories.mdx
@@ -1,21 +1,26 @@
----
-name: Welcome
-route: /
----
-
-## Welcome to React95
-
-
-
-
-
-
-
-
-
-
- Components
-
+import { Meta } from '@storybook/addon-docs';
+
+
+
+# Welcome to React95
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
- Refreshed Windows95 UI components for your modern React apps. Built
- with{' '}
-
- styled-components
- {' '}
- 💅
-
+**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](/installation) docs!
+Check out our [getting started](?path=/story/docs-getting-started--page) docs!
### Motivation
diff --git a/docs/component_template.mdx b/docs/component_template.mdx
deleted file mode 100644
index 84ec5ecd..00000000
--- a/docs/component_template.mdx
+++ /dev/null
@@ -1,26 +0,0 @@
----
-name: __component
-menu: Components
----
-
-import { __component } from 'react95';
-
-# __component
-
-## Usage
-
-
- __component
-
-
-## API
-
-### Import
-
-```
-import { __component } from 'react95'
-```
-
-### Props
-
-
diff --git a/docs/contributing.mdx b/docs/contributing.mdx
deleted file mode 100644
index e3960d3f..00000000
--- a/docs/contributing.mdx
+++ /dev/null
@@ -1,10 +0,0 @@
----
-name: Contributing
-route: /contributing
----
-
-### 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 ♥️
diff --git a/doczrc.js b/doczrc.js
deleted file mode 100644
index 7641d2ce..00000000
--- a/doczrc.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export default {
- typescript: false,
- menu: ['Welcome', 'Getting Started', 'Contributing', 'Submit your project'],
- themeConfig: {
- initialColorMode: 'light'
- },
- dest: './docs/build'
-};
diff --git a/jest.config.js b/jest.config.js
index 9ea69f2f..4069e068 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,4 +1,12 @@
module.exports = {
- setupFilesAfterEnv: ['/test/setup-test'],
+ globals: {
+ 'ts-jest': {
+ diagnostics: false,
+ isolatedModules: true
+ }
+ },
+ coverageReporters: ['text', 'html'],
+ preset: 'ts-jest/presets/default-esm',
+ setupFilesAfterEnv: ['/test/setup-test.ts'],
testEnvironment: 'jsdom'
};
diff --git a/package.json b/package.json
index 48f1176f..1ae71b9f 100644
--- a/package.json
+++ b/package.json
@@ -23,8 +23,9 @@
"license": "MIT",
"repository": "git@github.com:arturbien/React95.git",
"homepage": "https://react95.io",
- "main": "./dist/cjs/index.js",
- "module": "./dist/esm/index.js",
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
"files": [
"/dist"
],
@@ -32,8 +33,8 @@
"access": "public"
},
"scripts": {
- "start": "start-storybook -p 9009",
- "build:storybook": "build-storybook -o ./storybook",
+ "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",
@@ -41,10 +42,10 @@
"test:ci": "jest ./src --maxWorkers=2",
"test:watch": "jest ./src --watch",
"test:coverage": "jest ./src --coverage",
- "lint": "eslint src --ext .js",
+ "typescript": "tsc --noEmit",
+ "lint": "eslint --ext .js,.ts,.tsx src",
"lint:fix": "yarn run lint --fix",
"semantic-release": "semantic-release",
- "install:codesandbox": "yarn --ignore-engines",
"cz": "git-cz"
},
"peerDependencies": {
@@ -53,51 +54,76 @@
"styled-components": ">= 5.3.3"
},
"devDependencies": {
- "@babel/cli": "^7.18.9",
"@babel/core": "^7.18.9",
- "@babel/eslint-parser": "^7.18.9",
"@babel/plugin-proposal-class-properties": "^7.18.6",
- "@babel/plugin-transform-runtime": "^7.18.9",
- "@babel/preset-env": "^7.18.9",
- "@babel/preset-react": "^7.18.6",
- "@storybook/addon-docs": "^6.5.9",
- "@storybook/addon-storysource": "^6.5.9",
- "@storybook/react": "^6.5.9",
+ "@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",
- "babel-loader": "^8.2.5",
- "babel-plugin-styled-components": "^2.0.7",
+ "@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",
- "eslint": "^8.20.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.0",
+ "eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.30.1",
- "firebase-tools": "^11.3.0",
+ "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",
- "prop-types": "^15.8.1",
"react": "^17.0.2",
+ "react-docgen-typescript": "^2.2.2",
"react-dom": "^17.0.2",
"rimraf": "^3.0.2",
- "rollup": "^2.77.0",
- "rollup-plugin-babel": "^4.4.0",
- "rollup-plugin-commonjs": "^9.3.4",
+ "rollup": "^2.77.2",
"rollup-plugin-copy": "^3.4.0",
- "rollup-plugin-node-resolve": "^4.2.4",
+ "rollup-plugin-esbuild": "^4.9.1",
"rollup-plugin-replace": "^2.2.0",
"semantic-release": "^19.0.3",
- "storybook-addon-styled-component-theme": "^2.0.0",
- "styled-components": "^5.3.5"
+ "styled-components": "^5.3.5",
+ "ts-jest": "^28.0.7",
+ "typescript": "^4.7.4",
+ "webpack": "5"
},
"dependencies": {},
"husky": {
@@ -117,4 +143,4 @@
"path": "./node_modules/cz-conventional-changelog"
}
}
-}
\ No newline at end of file
+}
diff --git a/rollup.config.js b/rollup.config.js
index 66520ee5..1c463e36 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -1,65 +1,74 @@
-import babel from 'rollup-plugin-babel';
-import commonjs from 'rollup-plugin-commonjs';
-import resolve from 'rollup-plugin-node-resolve';
-import replace from 'rollup-plugin-replace';
+import typescript from '@rollup/plugin-typescript';
import copy from 'rollup-plugin-copy';
-import packageJson from './package.json';
+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 [
{
- input: './src/index.js',
+ ...baseBundle,
+ input: ['./src/index.ts', './src/types.ts'],
output: [
{
- file: packageJson.main,
+ dir: 'dist',
+ entryFileNames: '[name].js',
+ exports: 'auto',
format: 'cjs',
- sourcemap: true
+ preserveModules: true,
+ preserveModulesRoot: 'src'
},
{
- file: packageJson.module,
- format: 'esm',
- sourcemap: true
+ dir: 'dist',
+ entryFileNames: '[name].mjs',
+ exports: 'auto',
+ format: 'es',
+ preserveModules: true,
+ preserveModulesRoot: 'src'
}
],
plugins: [
- replace({
- 'process.env.NODE_ENV': JSON.stringify(NODE_ENV)
- }),
- babel({
- exclude: 'node_modules/**',
- runtimeHelpers: true
- }),
- resolve(),
- commonjs()
- ],
- external: id => /^react|react-dom|styled-components/.test(id)
+ ...baseBundle.plugins,
+ typescript({
+ tsconfig: './tsconfig.build.index.json',
+ declaration: true,
+ declarationDir: 'dist'
+ })
+ ]
},
{
- input: './src/common/themes/index.js',
+ ...baseBundle,
+ input: './src/common/themes/index.ts',
output: {
dir: 'dist/themes',
exports: 'default',
- format: 'cjs'
+ format: 'cjs',
+ preserveModules: true,
+ preserveModulesRoot: 'src/common/themes'
},
- preserveModules: true,
plugins: [
+ ...baseBundle.plugins,
copy({
targets: [
{ src: './src/assets/fonts/dist/*', dest: './dist/fonts' },
{ src: './src/assets/images/*', dest: './dist/images' }
]
}),
- replace({
- 'process.env.NODE_ENV': JSON.stringify(NODE_ENV)
- }),
- babel({
- exclude: 'node_modules/**',
- runtimeHelpers: true
- }),
- resolve(),
- commonjs()
- ],
- external: id => /^react|react-dom|styled-components/.test(id)
+ typescript({
+ tsconfig: './tsconfig.build.themes.json',
+ declaration: true,
+ declarationDir: 'dist/themes'
+ })
+ ]
}
];
diff --git a/src/Anchor/Anchor.js b/src/Anchor/Anchor.js
deleted file mode 100644
index 8f582c55..00000000
--- a/src/Anchor/Anchor.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled from 'styled-components';
-
-const StyledAnchor = styled.a`
- color: ${({ theme }) => theme.anchor};
- font-size: inherit;
- text-decoration: underline;
- &:visited {
- color: ${({ theme }) => theme.anchorVisited};
- }
-`;
-
-const Anchor = React.forwardRef(function Anchor(props, ref) {
- const { children, ...otherProps } = props;
-
- return (
-
- {children}
-
- );
-});
-
-Anchor.defaultProps = {};
-
-Anchor.propTypes = {
- children: propTypes.node.isRequired
-};
-
-export default Anchor;
diff --git a/src/Anchor/Anchor.mdx b/src/Anchor/Anchor.mdx
deleted file mode 100644
index 7c0c3409..00000000
--- a/src/Anchor/Anchor.mdx
+++ /dev/null
@@ -1,28 +0,0 @@
----
-name: Anchor
-menu: Components
----
-
-import Anchor from './Anchor';
-
-# Anchor
-
-## Usage
-
-
-
- Expensive Toys
-
-
-
-## API
-
-### Props
-
-
-
-### Import
-
-```
-import { Anchor } from 'react95'
-```
diff --git a/src/Anchor/Anchor.spec.js b/src/Anchor/Anchor.spec.tsx
similarity index 97%
rename from src/Anchor/Anchor.spec.js
rename to src/Anchor/Anchor.spec.tsx
index f6242811..ac539864 100644
--- a/src/Anchor/Anchor.spec.js
+++ b/src/Anchor/Anchor.spec.tsx
@@ -1,7 +1,8 @@
import React from 'react';
+
import { render } from '@testing-library/react';
-import Anchor from './Anchor';
+import { Anchor } from './Anchor';
const defaultProps = {
children: '',
diff --git a/src/Anchor/Anchor.stories.js b/src/Anchor/Anchor.stories.tsx
similarity index 78%
rename from src/Anchor/Anchor.stories.js
rename to src/Anchor/Anchor.stories.tsx
index 3ed5d6e9..7bb6c7c2 100644
--- a/src/Anchor/Anchor.stories.js
+++ b/src/Anchor/Anchor.stories.tsx
@@ -1,7 +1,7 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
-import styled from 'styled-components';
-
import { Anchor } from 'react95';
+import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
@@ -9,17 +9,16 @@ const Wrapper = styled.div`
`;
export default {
- title: 'Anchor',
+ title: 'Typography/Anchor',
component: Anchor,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
return (
- Everybody likes
+ Everybody likes{' '}
- {' '}
https://expensive.toys
diff --git a/src/Anchor/Anchor.tsx b/src/Anchor/Anchor.tsx
new file mode 100644
index 00000000..24c39123
--- /dev/null
+++ b/src/Anchor/Anchor.tsx
@@ -0,0 +1,33 @@
+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 };
diff --git a/src/AppBar/AppBar.js b/src/AppBar/AppBar.js
deleted file mode 100644
index 35944ca7..00000000
--- a/src/AppBar/AppBar.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled from 'styled-components';
-import { createBorderStyles, createBoxStyles } from '../common';
-
-const StyledAppBar = styled.header`
- ${createBorderStyles()};
- ${createBoxStyles()};
-
- position: ${props => (props.fixed ? 'fixed' : 'absolute')};
- top: 0;
- right: 0;
- left: auto;
- display: flex;
- flex-direction: column;
- width: 100%;
-`;
-
-const AppBar = React.forwardRef(function AppBar(props, ref) {
- const { children, ...otherProps } = props;
- return (
-
- {children}
-
- );
-});
-
-AppBar.defaultProps = {
- children: null,
- fixed: true
-};
-
-AppBar.propTypes = {
- children: propTypes.node,
- fixed: propTypes.bool
-};
-
-export default AppBar;
diff --git a/src/AppBar/AppBar.mdx b/src/AppBar/AppBar.mdx
deleted file mode 100644
index d38d9e8c..00000000
--- a/src/AppBar/AppBar.mdx
+++ /dev/null
@@ -1,91 +0,0 @@
----
-name: AppBar
-menu: Components
----
-
-import AppBar from './AppBar'
-import Toolbar from '../Toolbar/Toolbar'
-import Button from '../Button/Button'
-import TextField from '../TextField/TextField'
-import List from '../List/List'
-import ListItem from '../ListItem/ListItem'
-import Divider from '../Divider/Divider'
-
-# AppBar
-
-## Usage
-
-#### Default
-
-
-
- {() => {
- const [open, setOpen] = React.useState(false);
- function handleClick() {
- setOpen(!open);
- }
- function handleClose() {
- setOpen(false);
- }
- const renderMenu = () => {
- return (
-
- {open && (
-
-
-
- 👨💻
-
- Profile
-
-
-
- 📁
-
- My account
-
-
-
-
- 🔙
-
- Logout
-
-
- )}
-
- Start
-
-
- );
- }
- return (
-
-
- {renderMenu()}
-
-
-
- )
- }}
-
-
-## API
-
-### Import
-
-```
-import { AppBar } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/AppBar/AppBar.spec.js b/src/AppBar/AppBar.spec.tsx
similarity index 67%
rename from src/AppBar/AppBar.spec.js
rename to src/AppBar/AppBar.spec.tsx
index fcc5b459..0b7ba2f2 100644
--- a/src/AppBar/AppBar.spec.js
+++ b/src/AppBar/AppBar.spec.tsx
@@ -1,28 +1,28 @@
-import React from 'react';
import { render } from '@testing-library/react';
+import React from 'react';
-import AppBar from './AppBar';
+import { AppBar } from './AppBar';
const defaultProps = { children: '' };
describe(' ', () => {
it('should render header', () => {
const { container } = render( );
- const headerEl = container.firstChild;
+ const headerEl = container.firstElementChild;
- expect(headerEl.tagName).toBe('HEADER');
+ expect(headerEl && headerEl.tagName).toBe('HEADER');
});
it('should render children', () => {
const { container } = render(A nice app bar );
- const headerEl = container.firstChild;
+ const headerEl = container.firstElementChild;
expect(headerEl).toHaveTextContent('A nice app bar');
});
it('should render fixed prop properly', () => {
const { container, rerender } = render( );
- const headerEl = container.firstChild;
+ const headerEl = container.firstElementChild;
expect(headerEl).toHaveStyleRule('position', 'fixed');
@@ -31,11 +31,20 @@ describe(' ', () => {
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.firstChild;
+ const headerEl = container.firstElementChild;
expect(headerEl).toHaveAttribute('style', 'background-color: papayawhip;');
});
@@ -43,7 +52,7 @@ describe(' ', () => {
it('should render custom props', () => {
const customProps = { title: 'cool-header' };
const { container } = render( );
- const headerEl = container.firstChild;
+ const headerEl = container.firstElementChild;
expect(headerEl).toHaveAttribute('title', 'cool-header');
});
diff --git a/src/AppBar/AppBar.stories.js b/src/AppBar/AppBar.stories.tsx
similarity index 72%
rename from src/AppBar/AppBar.stories.js
rename to src/AppBar/AppBar.stories.tsx
index e1376567..c8fc80e9 100644
--- a/src/AppBar/AppBar.stories.js
+++ b/src/AppBar/AppBar.stories.tsx
@@ -1,28 +1,30 @@
-import React from 'react';
-import styled from 'styled-components';
+import { ComponentMeta } from '@storybook/react';
+import React, { useState } from 'react';
import {
AppBar,
- Toolbar,
- TextField,
Button,
- List,
- ListItem,
- Divider
+ MenuList,
+ MenuListItem,
+ Separator,
+ TextInput,
+ Toolbar
} from 'react95';
+import styled from 'styled-components';
import logoIMG from '../assets/images/logo.png';
-export default {
- title: 'AppBar',
- component: AppBar,
- decorators: [story => {story()} ]
-};
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] = React.useState(false);
+ const [open, setOpen] = useState(false);
return (
@@ -41,7 +43,7 @@ export function Default() {
Start
{open && (
- setOpen(false)}
>
-
+
👨💻
Profile
-
-
+
+
📁
My account
-
-
-
+
+
+
🔙
Logout
-
-
+
+
)}
-
+
);
diff --git a/src/AppBar/AppBar.tsx b/src/AppBar/AppBar.tsx
new file mode 100644
index 00000000..dd480069
--- /dev/null
+++ b/src/AppBar/AppBar.tsx
@@ -0,0 +1,45 @@
+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 };
diff --git a/src/Avatar/Avatar.js b/src/Avatar/Avatar.js
deleted file mode 100644
index e57071e9..00000000
--- a/src/Avatar/Avatar.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled from 'styled-components';
-
-const StyledAvatar = styled.div`
- 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 SlyledAvatarIMG = styled.img`
- display: block;
- object-fit: contain;
- width: 100%;
- height: 100%;
-`;
-
-const Avatar = React.forwardRef(function Avatar(props, ref) {
- const {
- alt,
- children,
- noBorder,
- size: sizeProp,
- square,
- src,
- ...otherProps
- } = props;
-
- const size = typeof sizeProp === 'number' ? `${sizeProp}px` : sizeProp;
- return (
-
- {src ? : children}
-
- );
-});
-
-Avatar.defaultProps = {
- alt: '',
- children: null,
- noBorder: false,
- size: 35,
- square: false,
- src: undefined
-};
-
-Avatar.propTypes = {
- alt: propTypes.string,
- children: propTypes.node,
- noBorder: propTypes.bool,
- size: propTypes.oneOfType([propTypes.string, propTypes.number]),
- square: propTypes.bool,
- src: propTypes.string
-};
-
-export default Avatar;
diff --git a/src/Avatar/Avatar.mdx b/src/Avatar/Avatar.mdx
deleted file mode 100644
index 888fbceb..00000000
--- a/src/Avatar/Avatar.mdx
+++ /dev/null
@@ -1,53 +0,0 @@
----
-name: Avatar
-menu: Components
----
-
-import Avatar from './Avatar';
-
-# Avatar
-
-## Usage
-
-#### Default
-
-
-
-
-
-#### No border
-
-
-
-
-
-#### Lettered
-
-
- AK
-
-
-#### Squared
-
-
-
-
- 🚀
-
-
-
-
-## API
-
-### Import
-
-```
-import { Avatar } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Avatar/Avatar.spec.js b/src/Avatar/Avatar.spec.tsx
similarity index 81%
rename from src/Avatar/Avatar.spec.js
rename to src/Avatar/Avatar.spec.tsx
index 1e7235dd..12ed72cb 100644
--- a/src/Avatar/Avatar.spec.js
+++ b/src/Avatar/Avatar.spec.tsx
@@ -1,9 +1,9 @@
-import React from 'react';
import { render } from '@testing-library/react';
+import React from 'react';
import { renderWithTheme, theme } from '../../test/utils';
-import Avatar from './Avatar';
+import { Avatar } from './Avatar';
describe(' ', () => {
it('should render component', () => {
@@ -14,16 +14,16 @@ describe(' ', () => {
it('should render children', () => {
const { container } = render(Avatar children );
- const avatarEl = container.firstChild;
+ const avatarEl = container.firstElementChild;
- expect(avatarEl.innerHTML).toBe('Avatar children');
+ expect(avatarEl && avatarEl.innerHTML).toBe('Avatar children');
});
it('should handle border properly', () => {
const { container, rerender } = renderWithTheme(
);
- const avatarEl = container.firstChild;
+ const avatarEl = container.firstElementChild;
expect(avatarEl).toHaveStyleRule(
'border-top',
@@ -37,7 +37,7 @@ describe(' ', () => {
it('should handle square properly', () => {
const { container, rerender } = render( );
- const avatarEl = container.firstChild;
+ const avatarEl = container.firstElementChild;
expect(avatarEl).toHaveStyleRule('border-radius', '0');
@@ -49,9 +49,9 @@ describe(' ', () => {
it('should render with source', async () => {
const catGif = 'https://cdn2.thecatapi.com/images/1ac.gif';
const { findByAltText } = render( );
- const imageEl = await findByAltText('cat avatar');
+ const imageEl = (await findByAltText('cat avatar')) as HTMLImageElement;
- expect(imageEl.src).toBe(catGif);
+ expect(imageEl && imageEl.src).toBe(catGif);
});
it('should render source with priority over children', async () => {
@@ -69,7 +69,7 @@ describe(' ', () => {
describe('prop: size', () => {
it('should set proper size', () => {
const { container } = renderWithTheme( );
- const avatarEl = container.firstChild;
+ const avatarEl = container.firstElementChild;
expect(avatarEl).toHaveStyleRule('width', '85%');
expect(avatarEl).toHaveStyleRule('height', '85%');
@@ -77,7 +77,7 @@ describe(' ', () => {
it('when passed a number, sets size in px', () => {
const { container } = renderWithTheme( );
- const avatarEl = container.firstChild;
+ const avatarEl = container.firstElementChild;
expect(avatarEl).toHaveStyleRule('width', '25px');
expect(avatarEl).toHaveStyleRule('height', '25px');
diff --git a/src/Avatar/Avatar.stories.js b/src/Avatar/Avatar.stories.tsx
similarity index 73%
rename from src/Avatar/Avatar.stories.js
rename to src/Avatar/Avatar.stories.tsx
index 9fee4e03..55bb1f00 100644
--- a/src/Avatar/Avatar.stories.js
+++ b/src/Avatar/Avatar.stories.tsx
@@ -1,6 +1,7 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
-import styled from 'styled-components';
import { Avatar } from 'react95';
+import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
@@ -11,19 +12,16 @@ const Wrapper = styled.div`
`;
export default {
- title: 'Avatar',
+ title: 'Other/Avatar',
component: Avatar,
decorators: [story => {story()} ]
-};
-
-const imageSrc =
- 'https://sphoto.nasza-klasa.pl/33278012/1/square/2658174fbd.jpeg?v=1';
+} as ComponentMeta;
export function Default() {
return (
-
-
+
+
AK
diff --git a/src/Avatar/Avatar.tsx b/src/Avatar/Avatar.tsx
new file mode 100644
index 00000000..89990fd4
--- /dev/null
+++ b/src/Avatar/Avatar.tsx
@@ -0,0 +1,86 @@
+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 };
diff --git a/src/Bar/Bar.js b/src/Bar/Bar.js
deleted file mode 100644
index c8e925bf..00000000
--- a/src/Bar/Bar.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled from 'styled-components';
-
-const StyledBar = styled.div`
- display: inline-block;
- box-sizing: border-box;
- height: ${({ size }) => size};
- width: 5px;
- border-top: 2px solid ${({ theme }) => theme.borderLightest};
- border-left: 2px solid ${({ theme }) => theme.borderLightest};
- border-bottom: 2px solid ${({ theme }) => theme.borderDark};
- border-right: 2px solid ${({ theme }) => theme.borderDark};
- background: ${({ theme }) => theme.material};
-`;
-// TODO: add horizontal variant
-// TODO: allow user to specify number of bars (like 3 horizontal bars for drag handle)
-const Bar = React.forwardRef(function Bar(props, ref) {
- const { size: sizeProp, ...otherProps } = props;
- const size = typeof sizeProp === 'number' ? `${sizeProp}px` : sizeProp;
-
- return ;
-});
-
-Bar.defaultProps = {
- size: '100%'
-};
-Bar.propTypes = {
- size: propTypes.oneOfType([propTypes.string, propTypes.number])
-};
-
-export default Bar;
diff --git a/src/Bar/Bar.mdx b/src/Bar/Bar.mdx
deleted file mode 100644
index 6c8b06eb..00000000
--- a/src/Bar/Bar.mdx
+++ /dev/null
@@ -1,34 +0,0 @@
----
-name: Bar
-menu: Components
----
-
-import Bar from '../Bar/Bar'
-import AppBar from '../AppBar/AppBar.js'
-import Toolbar from '../Toolbar/Toolbar.js'
-import Button from '../Button/Button.js'
-
-# Bar
-
-## Usage
-
-
-
-
-
- Edit
-
- Save
-
-
-
-
-
-
-## API
-
-### Import
-
-### Props
-
-
diff --git a/src/Button/Button.js b/src/Button/Button.js
deleted file mode 100644
index d28153b9..00000000
--- a/src/Button/Button.js
+++ /dev/null
@@ -1,178 +0,0 @@
-/* eslint-disable no-nested-ternary */
-
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled, { css } from 'styled-components';
-import {
- createBorderStyles,
- createWellBorderStyles,
- createBoxStyles,
- createFlatBoxStyles,
- createDisabledTextStyles,
- createHatchedBackground,
- focusOutline
-} from '../common';
-import { noOp } from '../common/utils';
-import { blockSizes } from '../common/system';
-
-const commonButtonStyles = css`
- position: relative;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: ${({ size }) => blockSizes[size]};
- width: ${({ fullWidth, square, size }) =>
- fullWidth ? '100%' : square ? blockSizes[size] : 'auto'};
- padding: ${({ square }) => (square ? 0 : `0 10px`)};
- font-size: 1rem;
- user-select: none;
- &:active {
- padding-top: ${({ isDisabled }) => !isDisabled && '2px'};
- }
- padding-top: ${({ active, isDisabled }) => active && !isDisabled && '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`
- ${({ variant, theme, active, isDisabled, primary }) =>
- 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 && !isDisabled && focusOutline}
- outline-offset: -4px;
- }
- `
- : variant === 'menu'
- ? css`
- ${createBoxStyles()};
- border: 2px solid transparent;
- &:hover,
- &:focus {
- ${!isDisabled && !active && createWellBorderStyles(false)}
- }
- &:active {
- ${!isDisabled && createWellBorderStyles(true)}
- }
- ${active && createWellBorderStyles(true)}
- ${isDisabled && createDisabledTextStyles()}
- `
- : css`
- ${createBoxStyles()};
- border: none;
- ${isDisabled && 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({ invert: true })
- : createBorderStyles({ invert: false })}
- }
- &:active:before {
- ${!isDisabled && createBorderStyles({ invert: true })}
- }
- &:focus:after,
- &:active:after {
- ${!active && !isDisabled && focusOutline}
- outline-offset: -8px;
- }
- &:active:focus:after,
- &:active:after {
- top: ${active ? '0' : '1px'};
- }
- `}
- ${commonButtonStyles}
-`;
-
-const Button = React.forwardRef(function Button(props, ref) {
- const { onClick, disabled, children, ...otherProps } = props;
-
- return (
-
- {children}
-
- );
-});
-
-Button.defaultProps = {
- type: 'button',
- onClick: null,
- disabled: false,
- fullWidth: false,
- size: 'md',
- square: false,
- active: false,
- // onTouchStart below to enable button :active style on iOS
- onTouchStart: noOp,
- primary: false,
- variant: 'default'
-};
-
-Button.propTypes = {
- type: propTypes.string,
- onClick: propTypes.func,
- disabled: propTypes.bool,
- fullWidth: propTypes.bool,
- size: propTypes.oneOf(['sm', 'md', 'lg']),
- square: propTypes.bool,
- active: propTypes.bool,
- onTouchStart: propTypes.func,
- primary: propTypes.bool,
- variant: propTypes.oneOf(['default', 'menu', 'flat']),
- // eslint-disable-next-line react/require-default-props
- children: propTypes.node
-};
-
-export default Button;
diff --git a/src/Button/Button.mdx b/src/Button/Button.mdx
deleted file mode 100644
index 5cdffc97..00000000
--- a/src/Button/Button.mdx
+++ /dev/null
@@ -1,120 +0,0 @@
----
-name: Button
-menu: Components
----
-
-import Button from './Button'
-import Window from '../Window/Window'
-import WindowContent from '../WindowContent/WindowContent'
-import Cutout from '../Cutout/Cutout'
-import Toolbar from '../Toolbar/Toolbar'
-
-# Button
-
-## Usage
-
-#### Default
-
-
- Default
-
-
-#### Disabled
-
-
- Disabled
-
-
-#### Full Width
-
-
- Full Width
-
-
-#### Square
-
-
-
-
- 🎂
-
-
-
-
-#### Active
-
-
- Default
-
-
-#### Different sizes
-
-
-
-
- small
-
-
- medium
-
-
- large
-
-
-
-
-#### Flat
-
-
-
-
-
-
- When you want to use Buttons on a light background (like scrollable
- content), just use the flat variant:
-
-
-
-
- Primary
-
-
- Regular
-
-
- Disabled
-
-
-
-
-
-
-
-
-## API
-
-### Import
-
-```
-import { Button } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Button/Button.spec.js b/src/Button/Button.spec.tsx
similarity index 92%
rename from src/Button/Button.spec.js
rename to src/Button/Button.spec.tsx
index 741f1c8b..a980b3c1 100644
--- a/src/Button/Button.spec.js
+++ b/src/Button/Button.spec.tsx
@@ -1,10 +1,10 @@
+import { fireEvent, render } from '@testing-library/react';
import React from 'react';
-import { render, fireEvent } from '@testing-library/react';
import { renderWithTheme, theme } from '../../test/utils';
import { blockSizes } from '../common/system';
-import Button from './Button';
+import { Button } from './Button';
const defaultProps = {
children: 'click me'
@@ -55,6 +55,10 @@ describe(' ', () => {
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', () => {
diff --git a/src/Button/Button.stories.js b/src/Button/Button.stories.tsx
similarity index 64%
rename from src/Button/Button.stories.js
rename to src/Button/Button.stories.tsx
index 8ca66f0f..ad4f91b3 100644
--- a/src/Button/Button.stories.js
+++ b/src/Button/Button.stories.tsx
@@ -1,17 +1,17 @@
-import React from 'react';
-import styled from 'styled-components';
-
+import { ComponentMeta } from '@storybook/react';
+import React, { useState } from 'react';
import {
Button,
+ MenuList,
+ MenuListItem,
+ ScrollView,
+ Separator,
+ Toolbar,
Window,
- WindowHeader,
WindowContent,
- List,
- ListItem,
- Divider,
- Cutout,
- Toolbar
+ WindowHeader
} from 'react95';
+import styled from 'styled-components';
const Wrapper = styled.div`
padding: 5rem;
@@ -29,10 +29,10 @@ const Wrapper = styled.div`
`;
export default {
- title: 'Button',
+ title: 'Controls/Button',
component: Button,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
return (
@@ -63,10 +63,87 @@ 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 Menu() {
- const [open, setOpen] = React.useState(false);
+export function Thin() {
+ const [open, setOpen] = useState(false);
return (
@@ -77,10 +154,10 @@ export function Menu() {
Kiwi.app
-
+
Upload
-
+
Save
setOpen(!open)}
size='sm'
active={open}
@@ -99,78 +176,41 @@ export function Menu() {
Share
{open && (
-
setOpen(false)}
>
- Copy link
-
- Facebook
- Twitter
- Instagram
-
-
+ Copy link
+
+ Facebook
+ Twitter
+ Instagram
+
+
MySpace
-
-
+
+
)}
-
+
-
-
-
- );
-}
-
-Menu.story = {
- name: 'menu'
-};
-
-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'
+Thin.story = {
+ name: 'thin'
};
diff --git a/src/Button/Button.tsx b/src/Button/Button.tsx
new file mode 100644
index 00000000..cd555af2
--- /dev/null
+++ b/src/Button/Button.tsx
@@ -0,0 +1,220 @@
+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 {
+ ${!active && !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 };
diff --git a/src/Checkbox/Checkbox.js b/src/Checkbox/Checkbox.js
deleted file mode 100644
index 8fc54371..00000000
--- a/src/Checkbox/Checkbox.js
+++ /dev/null
@@ -1,254 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled, { css } from 'styled-components';
-import { createHatchedBackground } from '../common';
-
-import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
-import { StyledCutout } from '../Cutout/Cutout';
-import { StyledListItem } from '../ListItem/ListItem';
-import {
- size,
- StyledInput,
- StyledLabel,
- LabelText
-} from '../SwitchBase/SwitchBase';
-
-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(StyledCutout)`
- ${sharedCheckboxStyles}
- width: ${size}px;
- height: ${size}px;
- background: ${({ theme, isDisabled }) =>
- isDisabled ? theme.material : theme.canvas};
- &:before {
- box-shadow: none;
- }
-`;
-const StyledFlatCheckbox = styled.div`
- position: relative;
- box-sizing: border-box;
- display: inline-block;
- background: ${({ theme, isDisabled }) =>
- isDisabled ? theme.flatLight : theme.canvas};
- ${sharedCheckboxStyles}
- width: ${size - 4}px;
- height: ${size - 4}px;
- outline: none;
- border: 2px solid ${({ theme }) => theme.flatDark};
- background: ${({ theme, isDisabled }) =>
- isDisabled ? theme.flatLight : theme.canvas};
-`;
-
-const StyledMenuCheckbox = styled.div`
- position: relative;
- box-sizing: border-box;
- display: inline-block;
- background: ${({ theme, isDisabled }) =>
- isDisabled ? theme.flatLight : theme.canvas};
- ${sharedCheckboxStyles}
- width: ${size - 4}px;
- height: ${size - 4}px;
- background: none;
- border: none;
- outline: none;
-`;
-
-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
- ${({ theme, isDisabled }) =>
- isDisabled ? theme.checkmarkDisabled : theme.checkmark};
- border-width: 0 3px 3px 0;
- transform: translate(-50%, -50%) rotate(45deg);
-
- ${({ variant, theme, isDisabled }) =>
- variant === 'menu'
- ? css`
- border-color: ${isDisabled
- ? theme.materialTextDisabled
- : theme.materialText};
- filter: drop-shadow(
- 1px 1px 0px
- ${isDisabled ? theme.materialTextDisabledShadow : 'transparent'}
- );
- `
- : css`
- border-color: ${isDisabled
- ? theme.checkmarkDisabled
- : theme.checkmark};
- `}
- ${StyledListItem}:hover & {
- ${({ theme, isDisabled, variant }) =>
- !isDisabled &&
- variant === 'menu' &&
- css`
- border-color: ${theme.materialTextInvert};
- `};
- }
-`;
-const IndeterminateIcon = styled.span.attrs(() => ({
- 'data-testid': 'indeterminateIcon'
-}))`
- display: inline-block;
- position: relative;
-
- ${({ variant }) =>
- variant === 'menu'
- ? css`
- height: calc(100% - 4px);
- width: calc(100% - 4px);
- `
- : css`
- width: 100%;
- height: 100%;
- `}
- &:after {
- content: '';
- display: block;
-
- width: 100%;
- height: 100%;
-
- ${({ theme, isDisabled }) =>
- createHatchedBackground({
- mainColor: isDisabled ? theme.checkmarkDisabled : theme.checkmark
- })}
- background-position: 0px 0px, 2px 2px;
-
- ${({ variant, isDisabled, theme }) =>
- variant === 'menu' &&
- css`
- ${StyledListItem}:hover & {
- ${createHatchedBackground({
- mainColor: theme.materialTextInvert
- })}
- }
- filter: drop-shadow(
- 1px 1px 0px
- ${isDisabled ? theme.materialTextDisabledShadow : 'transparent'}
- );
- `};
- }
-`;
-
-const CheckboxComponents = {
- flat: StyledFlatCheckbox,
- default: StyledCheckbox,
- menu: StyledMenuCheckbox
-};
-
-const Checkbox = React.forwardRef(function Checkbox(props, ref) {
- const {
- onChange,
- label,
- disabled,
- variant,
- value,
- checked,
- defaultChecked,
- indeterminate,
- name,
- className,
- style,
- ...otherProps
- } = props;
- const [state, setState] = useControlledOrUncontrolled({
- value: checked,
- defaultValue: defaultChecked
- });
- const handleChange = e => {
- const newState = e.target.checked;
- setState(newState);
- if (onChange) onChange(e);
- };
-
- const CheckboxComponent = CheckboxComponents[variant];
-
- let Icon = null;
- if (indeterminate) {
- Icon = IndeterminateIcon;
- } else if (state) {
- Icon = CheckmarkIcon;
- }
- return (
-
-
-
- {Icon && }
-
- {label && {label} }
-
- );
-});
-
-Checkbox.defaultProps = {
- label: '',
- disabled: false,
- variant: 'default',
- onChange: () => {},
- checked: undefined,
- style: {},
- defaultChecked: false,
- className: '',
- indeterminate: false,
- value: undefined,
- name: null
-};
-
-Checkbox.propTypes = {
- onChange: propTypes.func,
- name: propTypes.string,
- value: propTypes.oneOfType([
- propTypes.string,
- propTypes.number,
- propTypes.bool
- ]),
- label: propTypes.oneOfType([propTypes.string, propTypes.number]),
- checked: propTypes.bool,
- disabled: propTypes.bool,
- variant: propTypes.oneOf(['default', 'flat', 'menu']),
- style: propTypes.object,
- defaultChecked: propTypes.bool,
- indeterminate: propTypes.bool,
- className: propTypes.string
-};
-
-export default Checkbox;
diff --git a/src/Checkbox/Checkbox.mdx b/src/Checkbox/Checkbox.mdx
deleted file mode 100644
index d010ace9..00000000
--- a/src/Checkbox/Checkbox.mdx
+++ /dev/null
@@ -1,176 +0,0 @@
----
-name: Checkbox
-menu: Components
----
-
-import Checkbox from './Checkbox'
-import Fieldset from '../Fieldset/Fieldset'
-import Button from '../Button/Button'
-import Cutout from '../Cutout/Cutout'
-import List from '../List/List'
-import ListItem from '../ListItem/ListItem'
-import Divider from '../Divider/Divider'
-
-# Checkbox
-
-## Usage
-
-#### Controlled group
-
-
- {() => {
- const [steak, setSteak] = React.useState(true)
- const [tortilla, setTortilla] = React.useState(false)
- const [pizza, setPizza] = React.useState(false)
- const handleChange = event => {
- const { target: { value } } = event;
- if (value === 'steak') {
- setSteak(!steak)
- return
- }
- if (value === 'tortilla') {
- setTortilla(!tortilla)
- return
- }
- if (value === 'pizza') {
- setPizza(!pizza)
- return
- }
- };
- const reset = () => {
- setSteak(false)
- setTortilla(false)
- setPizza(false)
- }
- return (
-
-
-
-
-
-
-
-
-
- Diet mode
-
-
- );
- }}
-
-
-#### Uncontrolled
-
-
- <>
-
-
-
- >
-
-
-#### Flat
-
-
-
- When you want to add input field on a light background (like
- scrollable content), just use the flat variant:
-
-
-
-
-
-
-
-
-
-#### Menu
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-## API
-
-### Import
-
-```
-import { Checkbox } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Checkbox/Checkbox.spec.js b/src/Checkbox/Checkbox.spec.tsx
similarity index 94%
rename from src/Checkbox/Checkbox.spec.js
rename to src/Checkbox/Checkbox.spec.tsx
index e2c77ed7..edb02060 100644
--- a/src/Checkbox/Checkbox.spec.js
+++ b/src/Checkbox/Checkbox.spec.tsx
@@ -1,6 +1,7 @@
import React from 'react';
+
import { renderWithTheme } from '../../test/utils';
-import Checkbox from './Checkbox';
+import { Checkbox } from './Checkbox';
describe(' ', () => {
describe('label', () => {
@@ -106,7 +107,7 @@ describe(' ', () => {
);
rerender( );
- const checkbox = getByRole('checkbox');
+ const checkbox = getByRole('checkbox') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
expect(getByRole('checkbox')).toHaveAttribute('checked');
@@ -119,7 +120,7 @@ describe(' ', () => {
it('should uncheck the checkbox', () => {
const { getByRole, rerender } = renderWithTheme( );
rerender( );
- const checkbox = getByRole('checkbox');
+ const checkbox = getByRole('checkbox') as HTMLInputElement;
expect(checkbox.checked).toBe(false);
expect(getByRole('checkbox')).not.toHaveAttribute('checked');
@@ -131,7 +132,7 @@ describe(' ', () => {
describe('uncontrolled', () => {
it('can change checked state uncontrolled starting from defaultChecked', () => {
const { getByRole } = renderWithTheme( );
- const checkbox = getByRole('checkbox');
+ const checkbox = getByRole('checkbox') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
diff --git a/src/Checkbox/Checkbox.stories.js b/src/Checkbox/Checkbox.stories.tsx
similarity index 78%
rename from src/Checkbox/Checkbox.stories.js
rename to src/Checkbox/Checkbox.stories.tsx
index 7b8d0fcf..92fb7dee 100644
--- a/src/Checkbox/Checkbox.stories.js
+++ b/src/Checkbox/Checkbox.stories.tsx
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import styled from 'styled-components';
-import { Checkbox, Fieldset, Cutout, List, ListItem, Divider } from 'react95';
+import { ComponentMeta } from '@storybook/react';
+import { Checkbox, GroupBox, ScrollView } from 'react95';
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
@@ -18,10 +19,10 @@ const Wrapper = styled.div`
`;
export default {
- title: 'Checkbox',
+ title: 'Controls/Checkbox',
component: Checkbox,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
const [state, setState] = useState({
@@ -33,7 +34,7 @@ export function Default() {
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 chosenIngredients = ingredientsArr.reduce((a, b) => a + b, 0);
const isIndeterminate = ![0, possibleIngredients].includes(chosenIngredients);
@@ -60,10 +61,8 @@ export function Default() {
}
};
- const toggleIngredient = e => {
- const {
- target: { value }
- } = e;
+ const toggleIngredient = (e: React.ChangeEvent) => {
+ const value = e.target.value as 'cheese' | 'bacon' | 'broccoli';
setState({
...state,
[value]: !state[value]
@@ -72,7 +71,7 @@ export function Default() {
return (
-
+
-
+
(val ? 1 : 0));
const possibleIngredients = Object.keys(state).length;
- const chosenIngredients = ingredientsArr.reduce((a, b) => a + b, 0);
+ const chosenIngredients = ingredientsArr.reduce((a, b) => a + b, 0);
const isIndeterminate = ![0, possibleIngredients].includes(chosenIngredients);
@@ -162,10 +161,8 @@ export function Flat() {
}
};
- const toggleIngredient = e => {
- const {
- target: { value }
- } = e;
+ const toggleIngredient = (e: React.ChangeEvent) => {
+ const value = e.target.value as 'cheese' | 'bacon' | 'broccoli';
setState({
...state,
[value]: !state[value]
@@ -173,9 +170,9 @@ export function Flat() {
};
return (
-
+
-
+
-
+
-
+
);
}
Flat.story = {
name: 'flat'
};
-
-export function Menu() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-Menu.story = {
- name: 'menu'
-};
diff --git a/src/Checkbox/Checkbox.tsx b/src/Checkbox/Checkbox.tsx
new file mode 100644
index 00000000..97fe3d1a
--- /dev/null
+++ b/src/Checkbox/Checkbox.tsx
@@ -0,0 +1,200 @@
+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 };
diff --git a/src/ColorInput/ColorInput.js b/src/ColorInput/ColorInput.js
deleted file mode 100644
index d23ae1c0..00000000
--- a/src/ColorInput/ColorInput.js
+++ /dev/null
@@ -1,161 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled, { css } from 'styled-components';
-import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
-import { focusOutline } from '../common';
-import { StyledButton } from '../Button/Button';
-import Divider from '../Divider/Divider';
-
-const Trigger = styled(StyledButton)`
- padding-left: 8px;
-`;
-
-const StyledDivider = styled(Divider)`
- 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`
- box-sizing: border-box;
- height: 19px;
- display: inline-block;
- width: 35px;
- margin-right: 5px;
-
- background: ${({ color }) => color};
-
- ${({ isDisabled }) =>
- isDisabled
- ? 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`
- width: 0px;
- height: 0px;
- border-left: 6px solid transparent;
- border-right: 6px solid transparent;
- display: inline-block;
- margin-left: 6px;
-
- ${({ isDisabled }) =>
- isDisabled
- ? 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 = React.forwardRef(function ColorInput(props, ref) {
- const { value, defaultValue, onChange, disabled, variant, ...otherProps } =
- props;
-
- const [valueDerived, setValueState] = useControlledOrUncontrolled({
- value,
- defaultValue
- });
-
- const handleChange = e => {
- const color = e.target.value;
- setValueState(color);
- if (onChange) {
- onChange(e);
- }
- };
-
- return (
- // we only need button styles, so we display
- // it as a div and reset type attribute
-
-
-
- {variant === 'default' && }
-
-
- );
-});
-
-ColorInput.defaultProps = {
- value: undefined,
- defaultValue: undefined,
- disabled: false,
- variant: 'default',
- onChange: () => {}
-};
-
-ColorInput.propTypes = {
- value: propTypes.string,
- defaultValue: propTypes.string,
- onChange: propTypes.func,
- disabled: propTypes.bool,
- variant: propTypes.oneOf(['default', 'flat'])
-};
-export default ColorInput;
diff --git a/src/ColorInput/ColorInput.spec.js b/src/ColorInput/ColorInput.spec.tsx
similarity index 84%
rename from src/ColorInput/ColorInput.spec.js
rename to src/ColorInput/ColorInput.spec.tsx
index eaa94f19..8e2dc4c6 100644
--- a/src/ColorInput/ColorInput.spec.js
+++ b/src/ColorInput/ColorInput.spec.tsx
@@ -1,14 +1,14 @@
-import React from 'react';
import { fireEvent } from '@testing-library/react';
+import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import ColorInput from './ColorInput';
+import { ColorInput } from './ColorInput';
-function rgb2hex(str) {
+function rgb2hex(str: string) {
const rgb = str.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
- function hex(x) {
+ function hex(x: string) {
return `0${parseInt(x, 10).toString(16)}`.slice(-2);
}
- return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`;
+ return rgb ? `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}` : '';
}
describe(' ', () => {
@@ -16,7 +16,7 @@ describe(' ', () => {
const color = '#f0f0dd';
const onChange = jest.fn();
const { container } = renderWithTheme( );
- const input = container.querySelector(`[type="color"]`);
+ const input = container.querySelector(`[type="color"]`) as HTMLInputElement;
fireEvent.change(input, { target: { value: color } });
expect(onChange).toBeCalledTimes(1);
});
@@ -24,7 +24,7 @@ describe(' ', () => {
it('should properly pass value to input element', () => {
const color = '#f0f0dd';
const { container } = renderWithTheme( );
- const input = container.querySelector(`[type="color"]`);
+ const input = container.querySelector(`[type="color"]`) as HTMLInputElement;
expect(input.value).toBe(color);
});
diff --git a/src/ColorInput/ColorInput.stories.js b/src/ColorInput/ColorInput.stories.tsx
similarity index 84%
rename from src/ColorInput/ColorInput.stories.js
rename to src/ColorInput/ColorInput.stories.tsx
index 41bfe4f6..5ec6a981 100644
--- a/src/ColorInput/ColorInput.stories.js
+++ b/src/ColorInput/ColorInput.stories.tsx
@@ -1,8 +1,8 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
-
import styled from 'styled-components';
-import { ColorInput, Cutout } from '..';
+import { ColorInput, ScrollView } from 'react95';
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
@@ -28,10 +28,10 @@ const Wrapper = styled.div`
`;
export default {
- title: 'ColorInput',
+ title: 'Controls/ColorInput',
component: ColorInput,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
return (
@@ -50,7 +50,7 @@ Default.story = {
export function Flat() {
return (
-
+
enabled:
@@ -61,7 +61,7 @@ export function Flat() {
-
+
);
}
diff --git a/src/ColorInput/ColorInput.tsx b/src/ColorInput/ColorInput.tsx
new file mode 100644
index 00000000..bf396d0c
--- /dev/null
+++ b/src/ColorInput/ColorInput.tsx
@@ -0,0 +1,170 @@
+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 };
diff --git a/src/Counter/Counter.js b/src/Counter/Counter.js
deleted file mode 100644
index cb7f0e6c..00000000
--- a/src/Counter/Counter.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled from 'styled-components';
-
-import { createWellBorderStyles } from '../common';
-import Digit from './Digit';
-
-const CounterWrapper = styled.div`
- ${createWellBorderStyles(true)}
- display: inline-flex;
- background: #000000;
-`;
-
-const pixelSizes = {
- sm: 1,
- md: 2,
- lg: 3,
- xl: 4
-};
-
-const Counter = React.forwardRef(function Counter(props, ref) {
- const { value, minLength, size, ...otherProps } = props;
- let stringValue = value.toString();
- if (minLength && minLength > stringValue.length) {
- stringValue =
- Array(minLength - stringValue.length)
- .fill('0')
- .join('') + stringValue;
- }
- return (
-
- {stringValue.split('').map((digit, i) => (
-
- ))}
-
- );
-});
-
-Counter.defaultProps = {
- minLength: 3,
- size: 'md',
- value: 0
-};
-
-Counter.propTypes = {
- minLength: propTypes.number,
- size: propTypes.oneOf(['sm', 'md', 'lg']),
- value: propTypes.number
-};
-
-export default Counter;
diff --git a/src/Counter/Counter.mdx b/src/Counter/Counter.mdx
deleted file mode 100644
index e748310c..00000000
--- a/src/Counter/Counter.mdx
+++ /dev/null
@@ -1,22 +0,0 @@
----
-name: Bar
-menu: Components
----
-
-import Counter from '../Counter/Counter';
-
-# Counter
-
-## Usage
-
-
-
-
-
-## API
-
-### Import
-
-### Props
-
-
diff --git a/src/Counter/Counter.spec.js b/src/Counter/Counter.spec.tsx
similarity index 67%
rename from src/Counter/Counter.spec.js
rename to src/Counter/Counter.spec.tsx
index fa69b1d4..74b1de70 100644
--- a/src/Counter/Counter.spec.js
+++ b/src/Counter/Counter.spec.tsx
@@ -1,12 +1,13 @@
import React from 'react';
+
import { renderWithTheme } from '../../test/utils';
-import Counter from './Counter';
+import { Counter } from './Counter';
describe(' ', () => {
it('should render', () => {
const { container } = renderWithTheme( );
- const counter = container.firstChild;
+ const counter = container.firstElementChild;
expect(counter).toBeInTheDocument();
});
@@ -15,15 +16,17 @@ describe(' ', () => {
const { container } = renderWithTheme(
);
- const counter = container.firstChild;
+ const counter = container.firstElementChild;
expect(counter).toHaveAttribute('style', 'background-color: papayawhip;');
});
it('should handle custom props', () => {
- const customProps = { title: 'potatoe' };
+ const customProps: React.HTMLAttributes = {
+ title: 'potatoe'
+ };
const { container } = renderWithTheme( );
- const counter = container.firstChild;
+ const counter = container.firstElementChild;
expect(counter).toHaveAttribute('title', 'potatoe');
});
@@ -33,18 +36,18 @@ describe(' ', () => {
const { container } = renderWithTheme(
);
- const counter = container.firstChild;
+ const counter = container.firstElementChild;
- expect(counter.childElementCount).toBe(7);
+ expect(counter && counter.childElementCount).toBe(7);
});
it('value length takes priority if bigger than minLength', () => {
const { container } = renderWithTheme(
);
- const counter = container.firstChild;
+ const counter = container.firstElementChild;
- expect(counter.childElementCount).toBe(4);
+ expect(counter && counter.childElementCount).toBe(4);
});
});
});
diff --git a/src/Counter/Counter.stories.js b/src/Counter/Counter.stories.tsx
similarity index 80%
rename from src/Counter/Counter.stories.js
rename to src/Counter/Counter.stories.tsx
index 3d7d2436..a7553247 100644
--- a/src/Counter/Counter.stories.js
+++ b/src/Counter/Counter.stories.tsx
@@ -1,8 +1,8 @@
+import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
+import { Button, Counter, Frame } from 'react95';
import styled from 'styled-components';
-import { Counter, Panel, Button } from 'react95';
-
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.desktopBackground};
@@ -20,23 +20,23 @@ const Wrapper = styled.div`
`;
export default {
- title: 'Counter',
+ 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!
-
+
);
}
diff --git a/src/Counter/Counter.tsx b/src/Counter/Counter.tsx
new file mode 100644
index 00000000..7c2b5c2f
--- /dev/null
+++ b/src/Counter/Counter.tsx
@@ -0,0 +1,46 @@
+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 };
diff --git a/src/Counter/Digit.js b/src/Counter/Digit.tsx
similarity index 90%
rename from src/Counter/Digit.js
rename to src/Counter/Digit.tsx
index c66b3207..b5358263 100644
--- a/src/Counter/Digit.js
+++ b/src/Counter/Digit.tsx
@@ -1,10 +1,16 @@
import React from 'react';
-import propTypes from 'prop-types';
import styled, { css } from 'styled-components';
import { createHatchedBackground } from '../common';
+import { CommonStyledProps } from '../types';
-const DigitWrapper = styled.div`
+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;
@@ -167,8 +173,8 @@ const digitActiveSegments = [
[1, 1, 1, 1, 1, 0, 1] // 9
];
-function Digit({ digit, pixelSize, ...otherProps }) {
- const segmentClasses = digitActiveSegments[digit].map((isActive, i) =>
+function Digit({ digit = 0, pixelSize = 2, ...otherProps }: DigitProps) {
+ const segmentClasses = digitActiveSegments[Number(digit)].map((isActive, i) =>
isActive ? `${segments[i]} active` : segments[i]
);
return (
@@ -180,14 +186,4 @@ function Digit({ digit, pixelSize, ...otherProps }) {
);
}
-Digit.defaultProps = {
- pixelSize: 2,
- digit: 0
-};
-
-Digit.propTypes = {
- pixelSize: propTypes.number,
- digit: propTypes.oneOfType([propTypes.number, propTypes.string])
-};
-
-export default Digit;
+export { Digit, DigitProps };
diff --git a/src/Cutout/Cutout.mdx b/src/Cutout/Cutout.mdx
deleted file mode 100644
index f1fb6a8a..00000000
--- a/src/Cutout/Cutout.mdx
+++ /dev/null
@@ -1,43 +0,0 @@
----
-name: Cutout
-menu: Components
----
-
-import Cutout from './Cutout'
-import Window from '../Window/Window'
-import WindowContent from '../WindowContent/WindowContent'
-
-# Cutout
-
-## Usage
-
-
-
-
-
-
- React95
-
-
-
-
-
-
-## API
-
-### Import
-
-```
-import { Cutout } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Cutout/Cutout.spec.js b/src/Cutout/Cutout.spec.js
deleted file mode 100644
index 605e1e53..00000000
--- a/src/Cutout/Cutout.spec.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import { render } from '@testing-library/react';
-
-import Cutout from './Cutout';
-
-describe(' ', () => {
- it('should render cutout', () => {
- const { container } = render( );
- const cutout = container.firstChild;
-
- expect(cutout).toBeInTheDocument();
- });
-
- it('should render custom styles', () => {
- const { container } = render(
-
- );
- const cutout = container.firstChild;
-
- expect(cutout).toHaveAttribute('style', 'background-color: papayawhip;');
- });
-
- it('should render children', async () => {
- const { findByText } = render(
-
- Cool cutout
-
- );
- const content = await findByText(/cool cutout/i);
-
- expect(content).toBeInTheDocument();
- });
-
- it('should render custom props', () => {
- const customProps = { title: 'cutout' };
- const { container } = render( );
- const cutout = container.firstChild;
-
- expect(cutout).toHaveAttribute('title', 'cutout');
- });
-});
diff --git a/src/DatePicker/DatePicker.js b/src/DatePicker/DatePicker.js
deleted file mode 100644
index 3cb11fa7..00000000
--- a/src/DatePicker/DatePicker.js
+++ /dev/null
@@ -1,218 +0,0 @@
-import React, { Component } from 'react';
-import propTypes from 'prop-types';
-
-import styled from 'styled-components';
-
-import Window from '../Window/Window';
-import WindowHeader from '../WindowHeader/WindowHeader';
-import WindowContent from '../WindowContent/WindowContent';
-import Select from '../Select/Select';
-import NumberField from '../NumberField/NumberField';
-import Cutout from '../Cutout/Cutout';
-import Button from '../Button/Button';
-import Toolbar from '../Toolbar/Toolbar';
-
-const Calendar = styled(Cutout)`
- 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`
- 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)};
- }
-`;
-
-function daysInMonth(year, month) {
- return new Date(year, month + 1, 0).getDate();
-}
-
-function dayIndex(year, month, day) {
- return new Date(year, month, day).getDay();
-}
-
-class DatePicker extends Component {
- static propTypes = {
- className: propTypes.string,
- shadow: propTypes.bool,
- onAccept: propTypes.func,
- onCancel: propTypes.func,
- date: propTypes.instanceOf(Date)
- };
-
- static defaultProps = {
- shadow: true,
- className: '',
- onAccept: null,
- onCancel: null,
- date: null
- };
-
- static convertDateToState(date) {
- const day = date.getDate();
- const month = date.getMonth();
- const year = date.getFullYear();
-
- return { day, month, year };
- }
-
- constructor(props) {
- super(props);
-
- const initialDate = DatePicker.convertDateToState(props.date || new Date());
- this.state = initialDate;
- }
-
- handleMonthSelect = e => this.setState({ month: e.target.value });
-
- handleYearSelect = year => this.setState({ year });
-
- handleDaySelect = day => this.setState({ day });
-
- handleAccept = () => {
- const { year, month, day } = this.state;
- const { onAccept } = this.props;
- const date = new Date(year, month, day);
-
- onAccept(date);
- };
-
- render() {
- let { day } = this.state;
- const { month, year } = this.state;
- const { shadow, className, onAccept, onCancel } = this.props;
-
- 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' }
- ];
-
- // eslint-disable-next-line
- const dayPickerItems = Array.apply(null, { length: 42 });
- const firstDayIndex = dayIndex(year, month, 1);
-
- const daysNumber = daysInMonth(year, month);
- day = day < daysNumber ? day : daysNumber;
- dayPickerItems.forEach((item, i) => {
- if (i >= firstDayIndex && i < daysNumber + firstDayIndex) {
- const dayNumber = i - firstDayIndex + 1;
-
- dayPickerItems[i] = (
- {
- this.handleDaySelect(dayNumber);
- }}
- >
-
- {dayNumber}
-
-
- );
- } else {
- dayPickerItems[i] = (
-
- );
- }
- });
-
- return (
-
-
-
- 📆
-
- Date
-
-
-
-
-
-
-
-
- S
- M
- T
- W
- T
- F
- S
-
- {dayPickerItems}
-
-
-
- Cancel
-
-
- OK
-
-
-
-
- );
- }
-}
-
-export default DatePicker;
diff --git a/src/DatePicker/DatePicker.mdx b/src/DatePicker/DatePicker.mdx
deleted file mode 100644
index bd233686..00000000
--- a/src/DatePicker/DatePicker.mdx
+++ /dev/null
@@ -1,26 +0,0 @@
----
-name: DatePicker
-menu: Components
----
-
-import DatePicker from './DatePicker';
-
-# DatePicker
-
-## Usage
-
-
- console.log(date)} />
-
-
-## API
-
-### Import
-
-```
-import { DatePicker } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/DatePicker/DatePicker.stories.js b/src/DatePicker/DatePicker.stories.js
deleted file mode 100644
index 029b2880..00000000
--- a/src/DatePicker/DatePicker.stories.js
+++ /dev/null
@@ -1,23 +0,0 @@
-// ⭕️ DON'T SHOW DATEPICKER BEFORE IT'S FINISHED AND TESTED ⭕️
-
-// import React from 'react';
-// import styled from 'styled-components';
-// import { DatePicker } from 'react95';
-
-// export default {
-// title: 'DatePicker',
-// component: DatePicker,
-// decorators: [story => {story()} ]
-// };
-// const Wrapper = styled.div`
-// padding: 5rem;
-// background: ${({ theme }) => theme.desktopBackground};
-// `;
-
-// export const Default = () => (
-// console.log(date)} />
-// );
-
-// Default.story = {
-// name: 'default'
-// };
diff --git a/src/DatePicker/DatePicker.stories.tsx b/src/DatePicker/DatePicker.stories.tsx
new file mode 100644
index 00000000..9591d210
--- /dev/null
+++ b/src/DatePicker/DatePicker.stories.tsx
@@ -0,0 +1,24 @@
+/* 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'
+};
diff --git a/src/DatePicker/DatePicker.tsx b/src/DatePicker/DatePicker.tsx
new file mode 100644
index 00000000..11d1c53d
--- /dev/null
+++ b/src/DatePicker/DatePicker.tsx
@@ -0,0 +1,213 @@
+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 };
diff --git a/src/Desktop/Desktop.mdx b/src/Desktop/Desktop.mdx
deleted file mode 100644
index 2c51f8e4..00000000
--- a/src/Desktop/Desktop.mdx
+++ /dev/null
@@ -1,22 +0,0 @@
----
-name: Desktop
-menu: Components
----
-
-import Desktop from '../Desktop/Desktop';
-
-# Desktop
-
-## Usage
-
-
-
-
-
-## API
-
-### Import
-
-### Props
-
-
diff --git a/src/Divider/Divider.js b/src/Divider/Divider.js
deleted file mode 100644
index a116aa3e..00000000
--- a/src/Divider/Divider.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled from 'styled-components';
-
-const StyledDivider = styled.hr`
- ${({ orientation, theme, size }) =>
- orientation === 'vertical'
- ? `
- height: ${size};
- border-left: 2px solid ${theme.borderDark};
- border-right: 2px solid ${theme.borderLightest};
- margin: 0;
- `
- : `
- width: ${size};
- border-bottom: 2px solid ${theme.borderLightest};
- border-top: 2px solid ${theme.borderDark};
- margin: 0;
- `}
-`;
-
-const Divider = React.forwardRef(function Divider(props, ref) {
- const { size: sizeProp, ...otherProps } = props;
- const size = typeof sizeProp === 'number' ? `${sizeProp}px` : sizeProp;
- return ;
-});
-
-Divider.defaultProps = {
- size: '100%',
- orientation: 'horizontal'
-};
-
-Divider.propTypes = {
- size: propTypes.oneOfType([propTypes.string, propTypes.number]),
- orientation: propTypes.oneOf(['horizontal', 'vertical'])
-};
-
-export default Divider;
diff --git a/src/Divider/Divider.mdx b/src/Divider/Divider.mdx
deleted file mode 100644
index debc9b46..00000000
--- a/src/Divider/Divider.mdx
+++ /dev/null
@@ -1,47 +0,0 @@
----
-
-name: Divider
-menu: Components
----import Divider from './Divider'
-import List from '../List/List'
-import ListItem from '../ListItem/ListItem'
-
-# Divider
-
-## Usage
-
-#### Default
-
-
-
- Item 1
-
- Item 2
-
- Item 3
-
-
-
-#### vertical
-
-
-
- Item 1
-
- Item 2
-
- Item 3
-
-
-
-## API
-
-### Import
-
-```
-import { Divider } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Divider/Divider.spec.js b/src/Divider/Divider.spec.js
deleted file mode 100644
index 472007c9..00000000
--- a/src/Divider/Divider.spec.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import React from 'react';
-
-import { renderWithTheme } from '../../test/utils';
-
-import Divider from './Divider';
-
-describe(' ', () => {
- it('should render Divider', () => {
- const { container } = renderWithTheme( );
- const divider = container.firstChild;
-
- expect(divider).toBeInTheDocument();
- });
-
- describe('prop: size', () => {
- it('defaults to 100%', () => {
- const { container } = renderWithTheme( );
- const divider = container.firstChild;
- expect(divider).toHaveStyleRule('width', '100%');
- });
- it('sets size passed correctly', () => {
- const size = '53px';
- const { container } = renderWithTheme( );
- const divider = container.firstChild;
-
- expect(divider).toHaveStyleRule('width', size);
- });
- });
-
- describe('prop: orientation', () => {
- it('renders horizontal line by default', () => {
- const size = '53px';
- const { container } = renderWithTheme( );
- const divider = container.firstChild;
-
- expect(divider).toHaveStyleRule('width', size);
- });
-
- it('renders vertical line when orientation="vertical"', () => {
- const size = '53px';
- const { container } = renderWithTheme(
-
- );
- const divider = container.firstChild;
-
- expect(divider).toHaveStyleRule('height', size);
- });
- });
- describe('prop: size', () => {
- it('should set proper size', () => {
- const { container } = renderWithTheme( );
- const avatarEl = container.firstChild;
-
- expect(avatarEl).toHaveStyleRule('width', '85%');
- });
-
- it('when passed a number, sets size in px', () => {
- const { container } = renderWithTheme( );
- const avatarEl = container.firstChild;
-
- expect(avatarEl).toHaveStyleRule('width', '25px');
- });
-
- it('should set height when vertical', () => {
- const { container } = renderWithTheme(
-
- );
- const avatarEl = container.firstChild;
-
- expect(avatarEl).toHaveStyleRule('height', '25px');
- });
- });
-});
diff --git a/src/Divider/Divider.stories.js b/src/Divider/Divider.stories.js
deleted file mode 100644
index f726fbf8..00000000
--- a/src/Divider/Divider.stories.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-import { Divider, List, ListItem } from 'react95';
-
-export default {
- title: 'Divider',
- component: Divider,
- decorators: [story => {story()} ]
-};
-const Wrapper = styled.div`
- padding: 5rem;
- background: ${({ theme }) => theme.desktopBackground};
-`;
-
-export function Default() {
- return (
- <>
-
- Item 1
-
- Item 2
-
- Item 3
-
-
- Item 1
-
- Item 2
-
- Item 3
-
- >
- );
-}
-
-Default.story = {
- name: 'default'
-};
diff --git a/src/Fieldset/Fieldset.js b/src/Fieldset/Fieldset.js
deleted file mode 100644
index dd11b078..00000000
--- a/src/Fieldset/Fieldset.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled, { css } from 'styled-components';
-import { createDisabledTextStyles } from '../common';
-
-const StyledFieldset = styled.fieldset`
- 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.isDisabled && 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 Fieldset = React.forwardRef(function Fieldset(props, ref) {
- const { label, disabled, variant, children, ...otherProps } = props;
- return (
-
- {label && {label} }
- {children}
-
- );
-});
-
-Fieldset.defaultProps = {
- disabled: false,
- variant: 'default',
- label: null,
- children: null
-};
-
-Fieldset.propTypes = {
- label: propTypes.node,
- children: propTypes.node,
- disabled: propTypes.bool,
- variant: propTypes.oneOf(['default', 'flat'])
-};
-
-export default Fieldset;
diff --git a/src/Fieldset/Fieldset.mdx b/src/Fieldset/Fieldset.mdx
deleted file mode 100644
index 3aeeb9b8..00000000
--- a/src/Fieldset/Fieldset.mdx
+++ /dev/null
@@ -1,103 +0,0 @@
----
-name: Fieldset
-menu: Components
----
-
-import Fieldset from './Fieldset'
-import Window from '../Window/Window'
-import WindowContent from '../WindowContent/WindowContent'
-import Cutout from '../Cutout/Cutout'
-import Checkbox from '../Checkbox/Checkbox'
-
-# Fieldset
-
-## Usage
-
-#### Default
-
-
-
-
-
- Some content here
-
- 😍
-
-
-
-
-
-
-#### With label
-
-
-
-
-
- Some content here
-
- 😍
-
-
-
-
-
-
-#### Flat
-
-
- {() => {
- const [enabled, setEnabled] = React.useState(false)
- return (
-
-
-
- <>
-
- When you want to use Fieldset on a light background (like scrollable
- content), just use the flat variant:
-
-
-
setEnabled(!enabled)}
- />
- }
- disabled={!enabled}
- >
- <>
- Some content here
-
- 😍
-
- >
-
-
- >
-
-
-
- );
- }}
-
-
-## API
-
-### Import
-
-```
-import { Fieldset } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Fieldset/Fieldset.spec.js b/src/Fieldset/Fieldset.spec.js
deleted file mode 100644
index c8ff087d..00000000
--- a/src/Fieldset/Fieldset.spec.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from 'react';
-
-import { renderWithTheme, theme } from '../../test/utils';
-
-import Fieldset from './Fieldset';
-
-describe(' ', () => {
- it('renders Fieldset', () => {
- const { container } = renderWithTheme( );
- const fieldset = container.firstChild;
-
- expect(fieldset).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 fieldset = container.firstChild;
- const legend = fieldset.querySelector('legend');
- expect(legend.textContent).toBe(labelText);
- });
- it('when not provided, element is not rendered', () => {
- const { container } = renderWithTheme( );
- const fieldset = container.firstChild;
- const legend = fieldset.querySelector('legend');
- expect(legend).not.toBeInTheDocument();
- });
- });
- describe('prop: disabled', () => {
- it('renders with disabled text content', () => {
- const { container } = renderWithTheme( );
- const fieldset = container.firstChild;
-
- expect(fieldset).toHaveAttribute('aria-disabled', 'true');
-
- expect(fieldset).toHaveStyleRule('color', theme.materialTextDisabled);
- expect(fieldset).toHaveStyleRule(
- 'text-shadow',
- `1px 1px ${theme.materialTextDisabledShadow}`
- );
- });
- });
-});
diff --git a/src/Frame/Frame.spec.tsx b/src/Frame/Frame.spec.tsx
new file mode 100644
index 00000000..2cabe125
--- /dev/null
+++ b/src/Frame/Frame.spec.tsx
@@ -0,0 +1,41 @@
+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');
+ });
+});
diff --git a/src/Panel/Panel.stories.js b/src/Frame/Frame.stories.tsx
similarity index 51%
rename from src/Panel/Panel.stories.js
rename to src/Frame/Frame.stories.tsx
index 47ed286c..dd19c232 100644
--- a/src/Panel/Panel.stories.js
+++ b/src/Frame/Frame.stories.tsx
@@ -1,8 +1,8 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
+import { Frame } from 'react95';
import styled from 'styled-components';
-import { Panel } from 'react95';
-
const Wrapper = styled.div`
padding: 5rem;
background: ${({ theme }) => theme.material};
@@ -19,28 +19,30 @@ const Wrapper = styled.div`
`;
export default {
- title: 'Panel',
- component: Panel,
+ title: 'Layout/Frame',
+ component: Frame,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
return (
-
- Notice the subtle difference in borders. The lightest border is not on
- the edge of this panel.
+ 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 panel on the other hand has the lightest border on the edge. Use
- this panel inside 'outside' panels.
+
+ This frame of the 'button' variant on the other hand has the
+ lightest border on the edge. Use this frame inside 'window'
+ frames.
-
- Put some content here
-
-
-
+
+
- The 'well' variant of a panel is often used as a window
- footer.
-
-
+ The 'status' variant of a frame is often used as a status bar
+ at the end of the window.
+
+
);
}
diff --git a/src/Frame/Frame.tsx b/src/Frame/Frame.tsx
new file mode 100644
index 00000000..8094ee14
--- /dev/null
+++ b/src/Frame/Frame.tsx
@@ -0,0 +1,68 @@
+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 };
diff --git a/src/GroupBox/GroupBox.spec.tsx b/src/GroupBox/GroupBox.spec.tsx
new file mode 100644
index 00000000..168c58ad
--- /dev/null
+++ b/src/GroupBox/GroupBox.spec.tsx
@@ -0,0 +1,53 @@
+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}`
+ );
+ });
+ });
+});
diff --git a/src/Fieldset/Fieldset.stories.js b/src/GroupBox/GroupBox.stories.tsx
similarity index 74%
rename from src/Fieldset/Fieldset.stories.js
rename to src/GroupBox/GroupBox.stories.tsx
index 5589ce18..0cba694b 100644
--- a/src/Fieldset/Fieldset.stories.js
+++ b/src/GroupBox/GroupBox.stories.tsx
@@ -1,35 +1,36 @@
+import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
+import { Checkbox, GroupBox, ScrollView, Window, WindowContent } from 'react95';
import styled from 'styled-components';
-import { Checkbox, Cutout, Fieldset, Window, WindowContent } from 'react95';
-
-export default {
- title: 'Fieldset',
- component: Fieldset,
- decorators: [story => {story()} ]
-};
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
😍
-
+
);
@@ -43,23 +44,23 @@ export function Flat() {
return (
-
-
+
Some content here
😍
-
+
-
+
Some content here
😍
-
-
+
+
);
@@ -74,14 +75,13 @@ export function ToggleExample() {
return (
- setState(!state)}
/>
}
@@ -90,7 +90,7 @@ export function ToggleExample() {
😍
-
+
);
diff --git a/src/GroupBox/GroupBox.tsx b/src/GroupBox/GroupBox.tsx
new file mode 100644
index 00000000..a2d381c5
--- /dev/null
+++ b/src/GroupBox/GroupBox.tsx
@@ -0,0 +1,69 @@
+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 };
diff --git a/src/Bar/Bar.spec.js b/src/Handle/Handle.spec.tsx
similarity index 71%
rename from src/Bar/Bar.spec.js
rename to src/Handle/Handle.spec.tsx
index 91f08fb9..4455413b 100644
--- a/src/Bar/Bar.spec.js
+++ b/src/Handle/Handle.spec.tsx
@@ -1,11 +1,12 @@
import React from 'react';
+
import { renderWithTheme } from '../../test/utils';
-import Bar from './Bar';
+import { Handle } from './Handle';
-describe(' ', () => {
+describe(' ', () => {
it('should render bar', () => {
- const { container } = renderWithTheme( );
+ const { container } = renderWithTheme( );
const barEl = container.firstChild;
expect(barEl).toBeInTheDocument();
@@ -13,7 +14,7 @@ describe(' ', () => {
it('should handle custom style', () => {
const { container } = renderWithTheme(
-
+
);
const barEl = container.firstChild;
@@ -22,7 +23,7 @@ describe(' ', () => {
it('should handle custom props', () => {
const customProps = { title: 'potatoe' };
- const { container } = renderWithTheme( );
+ const { container } = renderWithTheme( );
const barEl = container.firstChild;
expect(barEl).toHaveAttribute('title', 'potatoe');
@@ -30,14 +31,14 @@ describe(' ', () => {
describe('prop: size', () => {
it('should set proper size', () => {
- const { container } = renderWithTheme( );
+ 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 { container } = renderWithTheme( );
const barEl = container.firstChild;
expect(barEl).toHaveStyleRule('height', '25px');
diff --git a/src/Bar/Bar.stories.js b/src/Handle/Handle.stories.tsx
similarity index 67%
rename from src/Bar/Bar.stories.js
rename to src/Handle/Handle.stories.tsx
index 0379406c..a6a9fe04 100644
--- a/src/Bar/Bar.stories.js
+++ b/src/Handle/Handle.stories.tsx
@@ -1,28 +1,29 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
+import { AppBar, Button, Handle, Toolbar } from 'react95';
import styled from 'styled-components';
-import { Bar, AppBar, Toolbar, Button } from 'react95';
-
-export default {
- title: 'Bar',
- component: Bar,
- decorators: [story => {story()} ]
-};
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
-
+
);
diff --git a/src/Handle/Handle.tsx b/src/Handle/Handle.tsx
new file mode 100644
index 00000000..fc8fd851
--- /dev/null
+++ b/src/Handle/Handle.tsx
@@ -0,0 +1,29 @@
+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 };
diff --git a/src/Hourglass/Hourglass.js b/src/Hourglass/Hourglass.js
deleted file mode 100644
index 3adc5250..00000000
--- a/src/Hourglass/Hourglass.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled from 'styled-components';
-import base64hourglass from './base64hourglass';
-
-const StyledContainer = styled.span`
- display: inline-block;
-`;
-
-const StyledHourglass = styled.span`
- display: block;
- background: ${base64hourglass};
- background-size: cover;
- width: 100%;
- height: 100%;
-`;
-
-const Hourglass = React.forwardRef(function HourGlass(props, ref) {
- const { size, style, ...otherProps } = props;
- return (
-
-
-
- );
-});
-
-Hourglass.defaultProps = {
- size: '30px',
- style: {}
-};
-
-Hourglass.propTypes = {
- size: propTypes.oneOfType([propTypes.string, propTypes.number]),
- // eslint-disable-next-line react/forbid-prop-types
- style: propTypes.object
-};
-export default Hourglass;
diff --git a/src/Hourglass/Hourglass.mdx b/src/Hourglass/Hourglass.mdx
deleted file mode 100644
index 0d4b8b8e..00000000
--- a/src/Hourglass/Hourglass.mdx
+++ /dev/null
@@ -1,26 +0,0 @@
----
-name: Hourglass
-menu: Components
----
-
-import Hourglass from './Hourglass';
-
-# Hourglass
-
-## Usage
-
-
-
-
-
-## API
-
-### Import
-
-```
-import { Hourglass } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Hourglass/Hourglass.spec.js b/src/Hourglass/Hourglass.spec.js
deleted file mode 100644
index 198b80df..00000000
--- a/src/Hourglass/Hourglass.spec.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-import { render } from '@testing-library/react';
-
-import Hourglass from './Hourglass';
-
-describe(' ', () => {
- it('should render hourglass', () => {
- const { container } = render( );
- const barEl = container.firstChild;
-
- expect(barEl).toBeInTheDocument();
- });
-
- it('should render correct size', () => {
- const { container } = render( );
- const hourglass = container.firstChild;
-
- const computedStyles = window.getComputedStyle(hourglass);
- expect(computedStyles.width).toBe('66px');
- expect(computedStyles.height).toBe('66px');
- });
-
- it('should handle custom props', () => {
- const customProps = { alt: 'hourglass' };
- const { container } = render( );
- const hourglass = container.firstChild;
-
- expect(hourglass).toHaveAttribute('alt', 'hourglass');
- });
-});
diff --git a/src/Hourglass/Hourglass.spec.tsx b/src/Hourglass/Hourglass.spec.tsx
new file mode 100644
index 00000000..46cdab07
--- /dev/null
+++ b/src/Hourglass/Hourglass.spec.tsx
@@ -0,0 +1,34 @@
+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');
+ });
+});
diff --git a/src/Hourglass/Hourglass.stories.js b/src/Hourglass/Hourglass.stories.tsx
similarity index 68%
rename from src/Hourglass/Hourglass.stories.js
rename to src/Hourglass/Hourglass.stories.tsx
index ef87013e..644a7f91 100644
--- a/src/Hourglass/Hourglass.stories.js
+++ b/src/Hourglass/Hourglass.stories.tsx
@@ -1,20 +1,21 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
-import styled from 'styled-components';
-
import { Hourglass } from 'react95';
+import styled from 'styled-components';
-export default {
- title: 'Hourglass',
- component: Hourglass,
- decorators: [story => {story()} ]
-};
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 ;
+ return ;
}
Default.story = {
diff --git a/src/Hourglass/Hourglass.tsx b/src/Hourglass/Hourglass.tsx
new file mode 100644
index 00000000..4f71b7eb
--- /dev/null
+++ b/src/Hourglass/Hourglass.tsx
@@ -0,0 +1,38 @@
+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 };
diff --git a/src/Hourglass/base64hourglass.js b/src/Hourglass/base64hourglass.tsx
similarity index 100%
rename from src/Hourglass/base64hourglass.js
rename to src/Hourglass/base64hourglass.tsx
diff --git a/src/List/List.js b/src/List/List.js
deleted file mode 100644
index 7f96b4bf..00000000
--- a/src/List/List.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled from 'styled-components';
-import { createBorderStyles, createBoxStyles } from '../common';
-
-const StyledList = styled.ul`
- box-sizing: border-box;
- width: ${props => (props.fullWidth ? '100%' : 'auto')};
- padding: 4px;
- ${createBorderStyles({ windowBorders: true })}
- ${createBoxStyles()}
- ${props =>
- props.inline &&
- `
- display: inline-flex;
- align-items: center;
- `}
- list-style: none;
- position: relative;
-`;
-// TODO keyboard controls
-const List = React.forwardRef(function List(props, ref) {
- const { children, ...otherProps } = props;
-
- return (
-
- {children}
-
- );
-});
-
-List.defaultProps = {
- fullWidth: false,
- shadow: true,
- inline: false,
- children: null
-};
-
-List.propTypes = {
- fullWidth: propTypes.bool,
- inline: propTypes.bool,
- shadow: propTypes.bool,
- children: propTypes.node
-};
-
-export default List;
diff --git a/src/List/List.mdx b/src/List/List.mdx
deleted file mode 100644
index 7c70e7b9..00000000
--- a/src/List/List.mdx
+++ /dev/null
@@ -1,69 +0,0 @@
----
-name: List
-menu: Components
----
-
-import List from './List';
-import ListItem from '../ListItem/ListItem';
-
-# List
-
-## Usage
-
-#### Default
-
-
-
- Photos
- Videos
- Other
-
-
-
-#### Inline
-
-
-
-
-
- 🌿
-
-
-
- Tackle
- Growl
- Razor Leaf
-
-
-
-#### No shadow
-
-
-
- Photos
- Videos
- Other
-
-
-
-#### Full width
-
-
-
- Photos
- Videos
- Other
-
-
-
-## API
-
-### Import
-
-```
-import { List } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/List/List.spec.js b/src/List/List.spec.js
deleted file mode 100644
index 47ba9b07..00000000
--- a/src/List/List.spec.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react';
-
-import { renderWithTheme } from '../../test/utils';
-
-import List from './List';
-
-describe('
', () => {
- it('renders List', () => {
- const { container } = renderWithTheme(
);
- const list = container.firstChild;
-
- expect(list).toBeInTheDocument();
- });
- it('is an ul', () => {
- const { container } = renderWithTheme(
);
- const list = container.firstChild;
-
- expect(list.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 list = container.firstChild;
-
- expect(list).toHaveStyleRule('display', 'inline-flex');
- expect(list).toHaveStyleRule('align-items', 'center');
- });
- });
- describe('prop: fullWidth', () => {
- it('has 100% width', () => {
- const { container } = renderWithTheme(
);
- const list = container.firstChild;
-
- expect(list).toHaveStyleRule('width', '100%');
- });
- });
-});
diff --git a/src/List/List.stories.js b/src/List/List.stories.js
deleted file mode 100644
index 213611f8..00000000
--- a/src/List/List.stories.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-import { List, ListItem, Bar, Divider } from 'react95';
-
-const Wrapper = styled.div`
- padding: 5rem;
- background: ${({ theme }) => theme.desktopBackground};
- display: flex;
- align-items: center;
- & > * {
- margin-right: 1rem;
- }
-`;
-
-export default {
- title: 'List',
- component: List,
- subcomponents: { ListItem },
- decorators: [story => {story()} ]
-};
-
-export function Default() {
- return (
- <>
-
- Photos
-
- Link
-
- Other
-
-
-
-
- 🌿
-
-
-
- Tackle
- Growl
- Razor Leaf
-
-
-
- View
-
-
- Paste
- Paste Shortcut
- Undo Copy
-
- Properties
-
-
-
-
- 😎
-
-
-
-
- 🤖
-
-
-
-
- 🎁
-
-
-
- >
- );
-}
-
-Default.story = {
- name: 'default'
-};
diff --git a/src/ListItem/ListItem.js b/src/ListItem/ListItem.js
deleted file mode 100644
index a86e5366..00000000
--- a/src/ListItem/ListItem.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled from 'styled-components';
-import { createDisabledTextStyles } from '../common';
-import { blockSizes } from '../common/system';
-
-export const StyledListItem = styled.li`
- 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: ${({ isDisabled }) => (isDisabled ? 'none' : 'auto')};
- font-weight: ${({ primary }) => (primary ? 'bold' : 'normal')};
- &:hover {
- ${({ theme, isDisabled }) =>
- !isDisabled &&
- `
- color: ${theme.materialTextInvert};
- background: ${theme.hoverBackground};
- `}
-
- cursor: default;
- }
- ${props => props.isDisabled && createDisabledTextStyles()}
-`;
-
-const ListItem = React.forwardRef(function ListItem(props, ref) {
- const {
- size,
- disabled,
- // tabIndex: tabIndexProp,
- square,
- children,
- onClick,
- primary,
- ...otherProps
- } = props;
- // let tabIndex;
- // if (!disabled) {
- // tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
- // }
-
- return (
-
- {children}
-
- );
-});
-
-ListItem.defaultProps = {
- disabled: false,
- size: 'lg',
- square: false,
- onClick: null,
- children: null,
- primary: false
- // tabIndex: undefined
-};
-
-ListItem.propTypes = {
- size: propTypes.oneOf(['sm', 'md', 'lg']),
- disabled: propTypes.bool,
- square: propTypes.bool,
- children: propTypes.node,
- onClick: propTypes.func,
- primary: propTypes.bool
- // tabIndex: propTypes.number
-};
-
-export default ListItem;
diff --git a/src/ListItem/ListItem.mdx b/src/ListItem/ListItem.mdx
deleted file mode 100644
index 7f398f56..00000000
--- a/src/ListItem/ListItem.mdx
+++ /dev/null
@@ -1,104 +0,0 @@
----
-name: ListItem
-menu: Components
----
-
-import ListItem from './ListItem';
-import List from '../List/List';
-import Divider from '../Divider/Divider';
-
-# ListItem
-
-## Usage
-
-#### Default
-
-
-
- Item 1
- Item 2
- Item 3
-
-
-
-#### Disabled
-
-
-
- disabled
- disabled
- disabled
-
-
-
-#### Square
-
-
-
-
-
- 😎
-
-
-
-
- 🤖
-
-
-
-
- 🎁
-
-
-
-
-
-#### Small size
-
-
-
- View
-
- Paste
- Paste Shortcut
- Undo Copy
-
- Properties
-
-
-
-#### Render as link
-
-
-
- Normal item
-
-
- 🔗
-
- Link!
-
-
-
-
-#### Primary
-
-
-
- Item 1
- Item 2
- Item 3
-
-
-
-## API
-
-### Import
-
-```
-import { ListItem } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/ListItem/ListItem.spec.js b/src/ListItem/ListItem.spec.js
deleted file mode 100644
index d85a99f7..00000000
--- a/src/ListItem/ListItem.spec.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from 'react';
-
-import { renderWithTheme, theme } from '../../test/utils';
-import { blockSizes } from '../common/system';
-import ListItem from './ListItem';
-
-const defaultSize = 'lg';
-describe(' ', () => {
- it('renders ListItem', () => {
- const { container } = renderWithTheme( );
- const listItem = container.firstChild;
- expect(listItem).toBeInTheDocument();
- expect(listItem).toHaveAttribute('aria-disabled', 'false');
- expect(ListItem.defaultProps.size).toBe(defaultSize);
- });
- it('renders children', () => {
- const textContent = 'Hi there!';
- const { getByText } = renderWithTheme(
-
- {textContent}
-
- );
- expect(getByText(textContent)).toBeInTheDocument();
- });
- it('should have a default role of menuitem', () => {
- const { container } = renderWithTheme( );
- const listItem = container.firstChild;
- expect(listItem).toHaveAttribute('role', 'menuitem');
- });
-
- it('should render with custom role', () => {
- const { container } = renderWithTheme( );
- const listItem = container.firstChild;
- expect(listItem).toHaveAttribute('role', 'option');
- });
-
- // it('should have a tabIndex of -1 by default', () => {
- // const { container } = renderWithTheme( );
- // const listItem = container.firstChild;
- // expect(listItem).toHaveAttribute('tabIndex', '-1');
- // });
- describe('prop: disabled', () => {
- it('should not trigger onClick callback', () => {
- const clickHandler = jest.fn();
- const { container } = renderWithTheme(
-
- );
- const listItem = container.firstChild;
- listItem.click();
- expect(clickHandler).not.toBeCalled();
- expect(listItem).toHaveAttribute('aria-disabled', 'true');
- });
- it('renders with disabled styles ', () => {
- const { container } = renderWithTheme( );
- const listItem = container.firstChild;
- expect(listItem).toHaveStyleRule('pointer-events', 'none');
- expect(listItem).toHaveStyleRule('color', theme.materialTextDisabled);
- expect(listItem).toHaveStyleRule(
- 'text-shadow',
- `1px 1px ${theme.materialTextDisabledShadow}`
- );
- });
- });
- describe('prop: onClick', () => {
- it('should be called when clicked', () => {
- const clickHandler = jest.fn();
- const { container } = renderWithTheme(
-
- );
- const listItem = container.firstChild;
- listItem.click();
- expect(clickHandler).toHaveBeenCalledTimes(1);
- });
- });
- describe('prop: square', () => {
- it('should render square ListItem', () => {
- const { getByRole } = renderWithTheme( );
- const listItem = getByRole('menuitem');
-
- expect(listItem).toHaveStyleRule('width', blockSizes[defaultSize]);
- expect(listItem).toHaveStyleRule('height', blockSizes[defaultSize]);
- });
- });
- describe('prop: size', () => {
- it('should define ListItem height', () => {
- const size = 'sm';
- const { getByRole } = renderWithTheme( );
- const listItem = getByRole('menuitem');
-
- expect(listItem).toHaveStyleRule('height', blockSizes[size]);
- });
- });
-});
diff --git a/src/LoadingIndicator/LoadingIndicator.js b/src/LoadingIndicator/LoadingIndicator.js
deleted file mode 100644
index 47a55a4d..00000000
--- a/src/LoadingIndicator/LoadingIndicator.js
+++ /dev/null
@@ -1,161 +0,0 @@
-import React, { forwardRef } from 'react';
-import propTypes from 'prop-types';
-
-import styled, { keyframes, css } from 'styled-components';
-
-import { StyledCutout } from '../Cutout/Cutout';
-
-const Wrapper = styled.div`
- display: inline-block;
- height: 15px;
- width: 100%;
-`;
-const ProgressCutout = styled(StyledCutout)`
- width: 100%;
- height: 100%;
- width: 100%;
- position: relative;
- padding: 0;
- overflow: hidden;
-`;
-
-// animations taken from https://material.io/develop/web/ Linear Progress
-const primaryTranslate = keyframes`
- 0% {
- transform: translateX(0);
- }
- 20% {
- animation-timing-function: cubic-bezier(0.5, 0, 0.701732, 0.495819);
- transform: translateX(0);
- }
- 59.15% {
- animation-timing-function: cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);
- transform: translateX(83.67142%);
- }
- 100% {
- transform: translateX(200.611057%);
- }
-`;
-const primaryScale = keyframes`
-0% {
- transform: scaleX(0.08);
- }
- 36.65% {
- animation-timing-function: cubic-bezier(0.334731, 0.12482, 0.785844, 1);
- transform: scaleX(0.08);
- }
- 69.15% {
- animation-timing-function: cubic-bezier(0.06, 0.11, 0.6, 1);
- transform: scaleX(0.661479);
- }
- 100% {
- transform: scaleX(0.08);
- }
-`;
-const secondaryTranslate = keyframes`
- 0% {
- animation-timing-function: cubic-bezier(0.15, 0, 0.515058, 0.409685);
- transform: translateX(0);
- }
- 25% {
- animation-timing-function: cubic-bezier(0.31033, 0.284058, 0.8, 0.733712);
- transform: translateX(37.651913%);
- }
- 48.35% {
- animation-timing-function: cubic-bezier(0.4, 0.627035, 0.6, 0.902026);
- transform: translateX(84.386165%);
- }
- 100% {
- transform: translateX(160.277782%);
- }
-`;
-const secondaryScale = keyframes`
- 0% {
- animation-timing-function: cubic-bezier(0.205028, 0.057051, 0.57661, 0.453971);
- transform: scaleX(0.08);
- }
- 19.15% {
- animation-timing-function: cubic-bezier(0.152313, 0.196432, 0.648374, 1.004315);
- transform: scaleX(0.457104);
- }
- 44.15% {
- animation-timing-function: cubic-bezier(0.257759, -0.003163, 0.211762, 1.38179);
- transform: scaleX(0.72796);
- }
- 100% {
- transform: scaleX(0.08);
- }
-`;
-const sharedIndeterminateStyles = css`
- height: 100%;
- width: 100%;
- position: absolute;
- transform-origin: top left;
- transform: scaleX(0);
-`;
-const sharedIndeterminateInnerStyles = css`
- height: 100%;
- width: 100%;
- display: inline-block;
- background: ${({ theme }) => theme.progress};
- position: absolute;
-`;
-const IndeterminateWrapper = styled.div`
- position: relative;
- top: 2px;
- left: 2px;
- width: calc(100% - 4px);
- height: calc(100% - 4px);
- overflow: hidden;
-`;
-const IndeterminatePrimary = styled.div`
- ${sharedIndeterminateStyles}
- left: -145.166611%;
- animation: ${primaryTranslate} 2s infinite linear;
-`;
-const IndeterminatePrimaryInner = styled.span`
- ${sharedIndeterminateInnerStyles}
- animation: ${primaryScale} 2s infinite linear;
-`;
-const IndeterminateSecondary = styled.div`
- ${sharedIndeterminateStyles}
- left: -54.888891%;
- animation: ${secondaryTranslate} 2s infinite linear;
-`;
-const IndeterminateSecondaryInner = styled.span`
- ${sharedIndeterminateInnerStyles}
- animation: ${secondaryScale} 2s infinite linear;
-`;
-
-const LoadingIndicator = forwardRef(function LoadingIndicator(props, ref) {
- const { isLoading, shadow, ...otherProps } = props;
-
- return (
-
-
- {isLoading && (
-
-
-
-
-
-
-
-
- )}
-
-
- );
-});
-
-LoadingIndicator.defaultProps = {
- shadow: false,
- isLoading: true
-};
-
-LoadingIndicator.propTypes = {
- shadow: propTypes.bool,
- isLoading: propTypes.bool
-};
-
-export default LoadingIndicator;
diff --git a/src/LoadingIndicator/LoadingIndicator.spec.js b/src/LoadingIndicator/LoadingIndicator.spec.js
deleted file mode 100644
index 058fa3d9..00000000
--- a/src/LoadingIndicator/LoadingIndicator.spec.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-
-import { renderWithTheme } from '../../test/utils';
-import LoadingIndicator from './LoadingIndicator';
-
-describe(' ', () => {
- it('renders LoadingIndicator', () => {
- const { getByRole } = renderWithTheme( );
-
- const progress = getByRole('progressbar');
-
- expect(progress).toBeInTheDocument();
- });
-
- describe('prop: isLoading', () => {
- it('when set to false, does not display progress bars', () => {
- const { rerender, queryByTestId } = renderWithTheme( );
-
- expect(queryByTestId('loading-wrapper')).not.toBeNull();
-
- rerender( );
-
- expect(queryByTestId('loading-wrapper')).toBeNull();
- });
- });
-});
diff --git a/src/LoadingIndicator/LoadingIndicator.stories.js b/src/LoadingIndicator/LoadingIndicator.stories.js
deleted file mode 100644
index 68521696..00000000
--- a/src/LoadingIndicator/LoadingIndicator.stories.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-
-import styled from 'styled-components';
-
-import { LoadingIndicator } from '..';
-
-const Wrapper = styled.div`
- background: ${({ theme }) => theme.material};
- padding: 5rem;
-`;
-
-export default {
- title: 'LoadingIndicator',
- component: LoadingIndicator,
- decorators: [story => {story()} ]
-};
-
-export function Default() {
- return (
- <>
- Loading...
-
- >
- );
-}
-
-Default.story = {
- name: 'default'
-};
diff --git a/src/MenuList/MenuList.spec.tsx b/src/MenuList/MenuList.spec.tsx
new file mode 100644
index 00000000..4bbae381
--- /dev/null
+++ b/src/MenuList/MenuList.spec.tsx
@@ -0,0 +1,47 @@
+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%');
+ });
+ });
+});
diff --git a/src/MenuList/MenuList.stories.tsx b/src/MenuList/MenuList.stories.tsx
new file mode 100644
index 00000000..05633a91
--- /dev/null
+++ b/src/MenuList/MenuList.stories.tsx
@@ -0,0 +1,85 @@
+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'
+};
diff --git a/src/MenuList/MenuList.tsx b/src/MenuList/MenuList.tsx
new file mode 100644
index 00000000..57b57848
--- /dev/null
+++ b/src/MenuList/MenuList.tsx
@@ -0,0 +1,36 @@
+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 };
diff --git a/src/MenuList/MenuListItem.spec.tsx b/src/MenuList/MenuListItem.spec.tsx
new file mode 100644
index 00000000..734d31d2
--- /dev/null
+++ b/src/MenuList/MenuListItem.spec.tsx
@@ -0,0 +1,92 @@
+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]);
+ });
+ });
+});
diff --git a/src/MenuList/MenuListItem.tsx b/src/MenuList/MenuListItem.tsx
new file mode 100644
index 00000000..45c7e1f2
--- /dev/null
+++ b/src/MenuList/MenuListItem.tsx
@@ -0,0 +1,92 @@
+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 };
diff --git a/src/Desktop/Desktop.spec.js b/src/Monitor/Monitor.spec.tsx
similarity index 54%
rename from src/Desktop/Desktop.spec.js
rename to src/Monitor/Monitor.spec.tsx
index 397cf297..43f01b08 100644
--- a/src/Desktop/Desktop.spec.js
+++ b/src/Monitor/Monitor.spec.tsx
@@ -1,28 +1,31 @@
import React from 'react';
+
import { renderWithTheme } from '../../test/utils';
-import Desktop from './Desktop';
+import { Monitor } from './Monitor';
-describe(' ', () => {
+describe(' ', () => {
it('should render', () => {
- const { container } = renderWithTheme( );
- const desktopElement = container.firstChild;
+ const { container } = renderWithTheme( );
+ const monitorElement = container.firstElementChild;
- expect(desktopElement).toBeInTheDocument();
+ expect(monitorElement).toBeInTheDocument();
});
it('should handle custom props', () => {
- const customProps = { title: 'potatoe' };
- const { container } = renderWithTheme( );
- const desktopElement = container.firstChild;
+ const customProps: React.HTMLAttributes = {
+ title: 'potatoe'
+ };
+ const { container } = renderWithTheme( );
+ const monitorElement = container.firstElementChild;
- expect(desktopElement).toHaveAttribute('title', 'potatoe');
+ expect(monitorElement).toHaveAttribute('title', 'potatoe');
});
describe('prop: backgroundStyles', () => {
it('should forward styles to background element', () => {
const { getByTestId } = renderWithTheme(
-
+
);
const backgroundElement = getByTestId('background');
@@ -35,7 +38,7 @@ describe(' ', () => {
describe('prop: children', () => {
it('children should be rendered in background element', () => {
- const { getByTestId } = renderWithTheme(Hi! );
+ const { getByTestId } = renderWithTheme(Hi! );
const backgroundElement = getByTestId('background');
expect(backgroundElement.innerHTML).toBe('Hi!');
diff --git a/src/Desktop/Desktop.stories.js b/src/Monitor/Monitor.stories.tsx
similarity index 57%
rename from src/Desktop/Desktop.stories.js
rename to src/Monitor/Monitor.stories.tsx
index c5ec5863..2e3394fd 100644
--- a/src/Desktop/Desktop.stories.js
+++ b/src/Monitor/Monitor.stories.tsx
@@ -1,20 +1,21 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
+import { Monitor } from 'react95';
import styled from 'styled-components';
-import { Desktop } from 'react95';
-
-export default {
- title: 'Desktop',
- component: Desktop,
- decorators: [story => {story()} ]
-};
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 ;
+ return ;
}
Default.story = {
diff --git a/src/Desktop/Desktop.js b/src/Monitor/Monitor.tsx
similarity index 76%
rename from src/Desktop/Desktop.js
rename to src/Monitor/Monitor.tsx
index ecc00a54..8abfa45e 100644
--- a/src/Desktop/Desktop.js
+++ b/src/Monitor/Monitor.tsx
@@ -1,8 +1,12 @@
-import React from 'react';
-import propTypes from 'prop-types';
+import React, { forwardRef } from 'react';
import styled from 'styled-components';
-import { StyledCutout } from '../Cutout/Cutout';
+import { StyledScrollView } from '../ScrollView/ScrollView';
+
+type MonitorProps = {
+ backgroundStyles?: React.CSSProperties;
+ children?: React.ReactNode;
+};
const Wrapper = styled.div`
position: relative;
@@ -14,7 +18,7 @@ const Inner = styled.div`
position: relative;
`;
-const Monitor = styled.div`
+const MonitorBody = styled.div`
position: relative;
z-index: 1;
box-sizing: border-box;
@@ -52,7 +56,7 @@ const Monitor = styled.div`
}
`;
-const Background = styled(StyledCutout).attrs(() => ({
+const Background = styled(StyledScrollView).attrs(() => ({
'data-testid': 'background'
}))`
width: 100%;
@@ -102,29 +106,21 @@ const Stand = styled.div`
}
`;
-const Desktop = React.forwardRef(function Desktop(props, ref) {
- const { backgroundStyles, children, ...otherProps } = props;
-
- return (
-
-
-
- {children}
-
-
-
-
- );
-});
-
-Desktop.defaultProps = {
- backgroundStyles: null
-};
+const Monitor = forwardRef(
+ ({ backgroundStyles, children, ...otherProps }, ref) => {
+ return (
+
+
+
+ {children}
+
+
+
+
+ );
+ }
+);
-Desktop.propTypes = {
- backgroundStyles: propTypes.object,
- // eslint-disable-next-line react/require-default-props
- children: propTypes.node
-};
+Monitor.displayName = 'Monitor';
-export default Desktop;
+export { Monitor, MonitorProps };
diff --git a/src/NumberField/NumberField.js b/src/NumberField/NumberField.js
deleted file mode 100644
index 140c7bbd..00000000
--- a/src/NumberField/NumberField.js
+++ /dev/null
@@ -1,200 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled, { css } from 'styled-components';
-
-import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
-import { clamp } from '../common/utils';
-
-import Button from '../Button/Button';
-import { blockSizes } from '../common/system';
-import TextField from '../TextField/TextField';
-
-const StyledNumberFieldWrapper = styled.div`
- display: inline-flex;
- align-items: center;
-`;
-
-const StyledButton = styled(Button)`
- width: 30px;
- padding: 0;
- flex-shrink: 0;
-
- ${({ isFlat }) =>
- isFlat
- ? css`
- height: calc(50% - 1px);
- `
- : css`
- height: 50%;
- &:before {
- border-left-color: ${({ theme }) => theme.borderLight};
- border-top-color: ${({ theme }) => theme.borderLight};
- box-shadow: inset 1px 1px 0px 1px
- ${({ theme }) => theme.borderLightest},
- inset -1px -1px 0 1px ${({ theme }) => theme.borderDark};
- }
- `}
-`;
-
-const StyledButtonWrapper = styled.div`
- display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
- justify-content: space-between;
-
- ${({ isFlat }) =>
- isFlat
- ? css`
- height: calc(${blockSizes.md} - 4px);
- `
- : css`
- height: ${blockSizes.md};
- margin-left: 2px;
- `}
-`;
-
-const StyledButtonIcon = styled.span`
- 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 NumberField = React.forwardRef(function NumberField(props, ref) {
- const {
- value,
- defaultValue,
- disabled,
- className,
- variant,
- step,
- width,
- min,
- max,
- onChange,
- style
- } = props;
-
- const [valueDerived, setValueState] = useControlledOrUncontrolled({
- value,
- defaultValue
- });
-
- const handleInputChange = e => {
- const newValue = e.target.value;
- setValueState(newValue);
- };
-
- const handleClick = val => {
- const stateValue = parseFloat(valueDerived);
- const newValue = clamp(
- +parseFloat(stateValue + val).toFixed(2),
- min,
- max
- ).toString();
-
- setValueState(newValue);
-
- if (onChange) {
- onChange(newValue);
- }
- };
-
- const onBlur = () => {
- if (onChange) {
- onChange(valueDerived);
- }
- };
-
- return (
-
-
-
- handleClick(step)}
- >
-
-
- handleClick(-step)}
- >
-
-
-
-
- );
-});
-
-NumberField.defaultProps = {
- className: '',
- defaultValue: undefined,
- disabled: false,
- max: null,
- min: null,
- step: 1,
- onChange: null,
- style: {},
- value: undefined,
- variant: 'default',
- width: null
-};
-
-NumberField.propTypes = {
- className: propTypes.string,
- defaultValue: propTypes.number,
- disabled: propTypes.bool,
- max: propTypes.number,
- min: propTypes.number,
- step: propTypes.number,
- onChange: propTypes.func,
- style: propTypes.object,
- value: propTypes.number,
- variant: propTypes.oneOf(['default', 'flat']),
- width: propTypes.oneOfType([propTypes.string, propTypes.number])
-};
-
-export default NumberField;
diff --git a/src/NumberField/NumberField.mdx b/src/NumberField/NumberField.mdx
deleted file mode 100644
index f1960bb7..00000000
--- a/src/NumberField/NumberField.mdx
+++ /dev/null
@@ -1,78 +0,0 @@
----
-name: NumberField
-menu: Components
----
-
-import NumberField from './NumberField';
-import Cutout from '../Cutout/Cutout'
-
-# NumberField
-
-## Usage
-
-#### Default
-
-
- console.log(value)} />
-
-
-#### Fixed width
-
-
- console.log(value)} />
-
-
-#### Disabled
-
-
- console.log(value)} />
-
-
-#### Disabled keyboard input
-
-
- console.log(value)}
- />
-
-
-#### No shadow
-
-
- console.log(value)}
- />
-
-
-#### Flat
-
-
-
-
- When you want to use NumberField on a light background (like scrollable
- content), just use the flat variant:
-
- console.log(value)}
- />
-
-
-
-## API
-
-### Import
-
-```
-import { NumberField } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/NumberField/NumberField.spec.js b/src/NumberInput/NumberInput.spec.tsx
similarity index 64%
rename from src/NumberField/NumberField.spec.js
rename to src/NumberInput/NumberInput.spec.tsx
index e72dbb99..486d193f 100644
--- a/src/NumberField/NumberField.spec.js
+++ b/src/NumberInput/NumberInput.spec.tsx
@@ -1,37 +1,37 @@
-import React from 'react';
import { fireEvent } from '@testing-library/react';
+import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import NumberField from './NumberField';
+import { NumberInput } from './NumberInput';
// TODO: should we pass number or string to callbacks?
-describe(' ', () => {
+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');
+ 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');
+ const input = container.querySelector('input') as HTMLInputElement;
input.focus();
- fireEvent.change(input, { target: { value: 777 } });
+ fireEvent.change(input, { target: { value: '777' } });
expect(handleChange).toHaveBeenCalledTimes(0);
input.blur();
expect(handleChange).toHaveBeenCalledTimes(1);
- expect(handleChange).toHaveBeenCalledWith('777');
+ expect(handleChange).toHaveBeenCalledWith(777);
});
// TODO: this test passes even tho it fails in real-life
@@ -39,13 +39,13 @@ describe(' ', () => {
const handleChange = jest.fn();
const { getByTestId, container } = renderWithTheme(
-
+
);
- const input = container.querySelector('input');
+ const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
input.focus();
- fireEvent.keyDown(document.activeElement, {
+ fireEvent.keyDown(document.activeElement as HTMLInputElement, {
key: '2'
});
incrementButton.click();
@@ -55,22 +55,22 @@ describe(' ', () => {
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');
+ const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
- fireEvent.change(input, { target: { value: 2 } });
+ fireEvent.change(input, { target: { value: '2' } });
incrementButton.click();
- expect(handleChange).toHaveBeenCalledWith('3');
+ expect(handleChange).toHaveBeenCalledWith(3);
});
it('should reach max value', () => {
const { getByTestId, container } = renderWithTheme(
-
+
);
- const input = container.querySelector('input');
+ const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
incrementButton.click();
@@ -79,9 +79,9 @@ describe(' ', () => {
it('should reach min value', () => {
const { getByTestId, container } = renderWithTheme(
-
+
);
- const input = container.querySelector('input');
+ const input = container.querySelector('input') as HTMLInputElement;
const decrementButton = getByTestId('decrement');
decrementButton.click();
@@ -91,9 +91,9 @@ describe(' ', () => {
describe('prop: step', () => {
it('should be 1 by default', () => {
const { getByTestId, container } = renderWithTheme(
-
+
);
- const input = container.querySelector('input');
+ const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
incrementButton.click();
@@ -102,9 +102,9 @@ describe(' ', () => {
it('should change value by specified step', () => {
const { getByTestId, container } = renderWithTheme(
-
+
);
- const input = container.querySelector('input');
+ const input = container.querySelector('input') as HTMLInputElement;
const decrementButton = getByTestId('decrement');
decrementButton.click();
@@ -113,9 +113,9 @@ describe(' ', () => {
it('should handle decimal step', () => {
const { getByTestId, container } = renderWithTheme(
-
+
);
- const input = container.querySelector('input');
+ const input = container.querySelector('input') as HTMLInputElement;
const decrementButton = getByTestId('decrement');
decrementButton.click();
@@ -126,9 +126,9 @@ describe(' ', () => {
describe('prop: disabled', () => {
it('should render disabled', () => {
const { getByTestId, container } = renderWithTheme(
-
+
);
- const input = container.querySelector('input');
+ const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
const decrementButton = getByTestId('decrement');
@@ -139,9 +139,9 @@ describe(' ', () => {
it('should not react to button clicks', () => {
const { getByTestId, container } = renderWithTheme(
-
+
);
- const input = container.querySelector('input');
+ const input = container.querySelector('input') as HTMLInputElement;
const incrementButton = getByTestId('increment');
const decrementButton = getByTestId('decrement');
@@ -156,16 +156,20 @@ describe(' ', () => {
describe('prop: width', () => {
it('should render component of specified width', () => {
const { container } = renderWithTheme(
-
+
);
- expect(getComputedStyle(container.firstChild).width).toBe('93px');
+ expect(
+ getComputedStyle(container.firstElementChild as HTMLInputElement).width
+ ).toBe('93px');
});
it('should handle %', () => {
const { container } = renderWithTheme(
-
+
);
- expect(getComputedStyle(container.firstChild).width).toBe('93%');
+ expect(
+ getComputedStyle(container.firstElementChild as HTMLInputElement).width
+ ).toBe('93%');
});
});
});
diff --git a/src/NumberField/NumberField.stories.js b/src/NumberInput/NumberInput.stories.tsx
similarity index 58%
rename from src/NumberField/NumberField.stories.js
rename to src/NumberInput/NumberInput.stories.tsx
index 889618fa..d9f089c6 100644
--- a/src/NumberField/NumberField.stories.js
+++ b/src/NumberInput/NumberInput.stories.tsx
@@ -1,8 +1,8 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
+import { ScrollView, NumberInput } from 'react95';
import styled from 'styled-components';
-import { NumberField, Cutout } from 'react95';
-
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
@@ -21,19 +21,19 @@ const Wrapper = styled.div`
`;
export default {
- title: 'NumberField',
- component: NumberField,
+ title: 'Controls/NumberInput',
+ component: NumberInput,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
return (
<>
-
+
-
+
-
+
>
);
}
@@ -44,12 +44,12 @@ Default.story = {
export function Flat() {
return (
-
+
- When you want to use NumberField on a light background (like scrollable
+ When you want to use NumberInput on a light background (like scrollable
content), just use the flat variant:
-
-
+
-
-
+
+
);
}
diff --git a/src/NumberInput/NumberInput.tsx b/src/NumberInput/NumberInput.tsx
new file mode 100644
index 00000000..88da010c
--- /dev/null
+++ b/src/NumberInput/NumberInput.tsx
@@ -0,0 +1,202 @@
+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 };
diff --git a/src/Panel/Panel.js b/src/Panel/Panel.js
deleted file mode 100644
index e3cacf47..00000000
--- a/src/Panel/Panel.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled, { css } from 'styled-components';
-import {
- createBorderStyles,
- createBoxStyles,
- createWellBorderStyles
-} from '../common';
-
-const createPanelStyles = (variant = 'default') => {
- switch (variant) {
- case 'well':
- return css`
- ${createWellBorderStyles(true)}
- `;
- case 'outside':
- return css`
- ${createBorderStyles({ windowBorders: true })}
- `;
- default:
- return css`
- ${createBorderStyles()}
- `;
- }
-};
-
-const StyledPanel = styled.div`
- position: relative;
- font-size: 1rem;
- ${({ variant }) => createPanelStyles(variant)}
- ${createBoxStyles()}
-`;
-
-const Panel = React.forwardRef(function Panel(props, ref) {
- const { children, variant, ...otherProps } = props;
- return (
-
- {children}
-
- );
-});
-
-Panel.defaultProps = {
- children: null,
- shadow: false,
- variant: 'outside'
-};
-
-Panel.propTypes = {
- variant: propTypes.oneOf(['outside', 'inside', 'well']),
- children: propTypes.node,
- shadow: propTypes.bool
-};
-
-export default Panel;
diff --git a/src/Panel/Panel.spec.js b/src/Panel/Panel.spec.js
deleted file mode 100644
index 7d35a1f4..00000000
--- a/src/Panel/Panel.spec.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import { render } from '@testing-library/react';
-
-import Panel from './Panel';
-
-describe(' ', () => {
- it('should render panel', () => {
- const { container } = render( );
- const panel = container.firstChild;
-
- expect(panel).toBeInTheDocument();
- });
-
- it('should render custom styles', () => {
- const { container } = render(
-
- );
- const panel = container.firstChild;
-
- expect(panel).toHaveAttribute('style', 'background-color: papayawhip;');
- });
-
- it('should render children', async () => {
- const { findByText } = render(
-
- Cool panel
-
- );
- const content = await findByText(/cool panel/i);
-
- expect(content).toBeInTheDocument();
- });
-
- it('should render custom props', () => {
- const customProps = { title: 'panel' };
- const { container } = render( );
- const panel = container.firstChild;
-
- expect(panel).toHaveAttribute('title', 'panel');
- });
-});
diff --git a/src/Progress/Progress.js b/src/Progress/Progress.js
deleted file mode 100644
index 62e43477..00000000
--- a/src/Progress/Progress.js
+++ /dev/null
@@ -1,167 +0,0 @@
-import React, { forwardRef, useRef, useState, useEffect } from 'react';
-import propTypes from 'prop-types';
-
-import styled, { css } from 'styled-components';
-
-import { StyledCutout } from '../Cutout/Cutout';
-import { blockSizes } from '../common/system';
-
-const Wrapper = styled.div`
- display: inline-block;
- height: ${blockSizes.md};
- width: 100%;
-`;
-const ProgressCutout = styled(StyledCutout)`
- width: 100%;
- height: 100%;
- width: 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 }) => value}% 0,
- ${({ value }) => 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 Progress = forwardRef(function Progress(props, ref) {
- const { value, variant, shadow, hideValue, ...otherProps } = props;
- const displayValue = hideValue ? null : `${value}%`;
-
- const progressProps = {};
- if (value !== undefined) {
- progressProps['aria-valuenow'] = Math.round(value);
- }
-
- const tilesWrapperRef = useRef();
- const savedCallback = useRef();
- const [tilesNumber, setTilesNumber] = useState(0);
-
- // TODO debounce this function
- function updateTilesNumber() {
- if (tilesWrapperRef.current) {
- const progressWidth =
- tilesWrapperRef.current.getBoundingClientRect().width;
- const newTilesNumber = Math.round(
- ((value / 100) * progressWidth) / tileWidth
- );
- setTilesNumber(newTilesNumber);
- }
- }
- useEffect(() => {
- savedCallback.current = updateTilesNumber;
- });
- useEffect(() => {
- function update() {
- savedCallback.current();
- }
-
- // then listen on window resize to recalculate number of tiles
- window.addEventListener('resize', update);
- return () => window.removeEventListener('resize', update);
- }, []);
-
- // recalculate number of tiles when value changes
- useEffect(() => {
- savedCallback.current();
- }, [value]);
- return (
-
-
- {variant === 'default' ? (
- <>
- {displayValue}
-
- {displayValue}
-
- >
- ) : (
-
- {Array(tilesNumber)
- .fill(null)
- .map((_, index) => (
-
- ))}
-
- )}
-
-
- );
-});
-
-Progress.defaultProps = {
- value: 0,
- shadow: true,
- variant: 'default',
- hideValue: false
-};
-
-Progress.propTypes = {
- value: propTypes.number,
- shadow: propTypes.bool,
- variant: propTypes.oneOf(['default', 'tile']),
- hideValue: propTypes.bool
-};
-
-export default Progress;
diff --git a/src/Progress/Progress.mdx b/src/Progress/Progress.mdx
deleted file mode 100644
index fb5e97ab..00000000
--- a/src/Progress/Progress.mdx
+++ /dev/null
@@ -1,49 +0,0 @@
----
-name: Progress
-menu: Components
----
-
-import Progress from './Progress';
-
-# Progress
-
-## Usage
-
-#### Default
-
-
- {() => {
- {
- /*
- @toDo
- for some reason the value isn't being updated, check this later
- */
- }
- const [percent, setPercent] = React.useState(0);
- React.useEffect(() => {
- const timer = setInterval(() => {
- setPercent(previousPercent => {
- if (previousPercent === 100) {
- return 0;
- }
- const diff = Math.random() * 10;
- return Math.min(previousPercent + diff, 100);
- });
- }, 1000);
- return () => clearInterval(timer);
- }, []);
- return ;
- }}
-
-
-## API
-
-### Import
-
-```
-import { Progress } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Progress/Progress.spec.js b/src/ProgressBar/ProgressBar.spec.tsx
similarity index 68%
rename from src/Progress/Progress.spec.js
rename to src/ProgressBar/ProgressBar.spec.tsx
index 54038307..fb2c053d 100644
--- a/src/Progress/Progress.spec.js
+++ b/src/ProgressBar/ProgressBar.spec.tsx
@@ -1,31 +1,37 @@
import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import Progress from './Progress';
+import { ProgressBar } from './ProgressBar';
-describe(' ', () => {
- it('renders Progress', () => {
+describe(' ', () => {
+ it('renders ProgressBar', () => {
const value = 32;
- const { getByRole } = renderWithTheme( );
+ const { getByRole } = renderWithTheme( );
- const progress = getByRole('progressbar');
+ const progressBar = getByRole('progressbar');
- expect(progress).toBeInTheDocument();
- expect(progress).toHaveAttribute('aria-valuenow', value.toString());
+ 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( );
+ const { queryByTestId } = renderWithTheme(
+
+ );
- expect(queryByTestId('defaultProgress1').textContent).toBe(`${value}%`);
- expect(queryByTestId('defaultProgress2').textContent).toBe(`${value}%`);
+ 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% )`
+ `polygon( 0 0, ${value}% 0, ${value}% 100%, 0 100% )`
);
expect(queryByTestId('indeterminateProgress')).not.toBeInTheDocument();
@@ -34,7 +40,9 @@ describe(' ', () => {
describe('variant: "tile"', () => {
it('Renders "tile" progress', () => {
- const { queryByTestId } = renderWithTheme( );
+ const { queryByTestId } = renderWithTheme(
+
+ );
expect(queryByTestId('defaultProgress1')).not.toBeInTheDocument();
expect(queryByTestId('defaultProgress2')).not.toBeInTheDocument();
expect(queryByTestId('tileProgress')).toBeInTheDocument();
@@ -62,7 +70,7 @@ describe(' ', () => {
it('renders progress bars, but does not show value', () => {
const value = 32;
const { queryByTestId } = renderWithTheme(
-
+
);
expect(queryByTestId('defaultProgress1')).toBeInTheDocument();
expect(queryByTestId('defaultProgress2')).toBeInTheDocument();
diff --git a/src/Progress/Progress.stories.js b/src/ProgressBar/ProgressBar.stories.tsx
similarity index 78%
rename from src/Progress/Progress.stories.js
rename to src/ProgressBar/ProgressBar.stories.tsx
index 4712cce5..458cae26 100644
--- a/src/Progress/Progress.stories.js
+++ b/src/ProgressBar/ProgressBar.stories.tsx
@@ -1,19 +1,18 @@
-import React, { useState, useEffect } from 'react';
-
+import { ComponentMeta } from '@storybook/react';
+import React, { useEffect, useState } from 'react';
+import { ProgressBar } from 'react95';
import styled from 'styled-components';
-import { Progress } from 'react95';
-
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
`;
export default {
- title: 'Progress',
- component: Progress,
+ title: 'Controls/ProgressBar',
+ component: ProgressBar,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
const [percent, setPercent] = useState(0);
@@ -33,7 +32,7 @@ export function Default() {
};
}, []);
- return ;
+ return ;
}
Default.story = {
@@ -58,7 +57,7 @@ export function Tile() {
};
}, []);
- return ;
+ return ;
}
Tile.story = {
@@ -83,7 +82,7 @@ export function HideValue() {
};
}, []);
- return ;
+ return ;
}
HideValue.story = {
diff --git a/src/ProgressBar/ProgressBar.tsx b/src/ProgressBar/ProgressBar.tsx
new file mode 100644
index 00000000..349f8079
--- /dev/null
+++ b/src/ProgressBar/ProgressBar.tsx
@@ -0,0 +1,164 @@
+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 };
diff --git a/src/Radio/Radio.js b/src/Radio/Radio.js
deleted file mode 100644
index 5c024d60..00000000
--- a/src/Radio/Radio.js
+++ /dev/null
@@ -1,177 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled, { css } from 'styled-components';
-import { createFlatBoxStyles } from '../common';
-import { StyledCutout } from '../Cutout/Cutout';
-import { StyledListItem } from '../ListItem/ListItem';
-
-import {
- size,
- StyledInput,
- StyledLabel,
- LabelText
-} from '../SwitchBase/SwitchBase';
-
-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;
-`;
-// had to overwrite box-shadow for StyledCheckbox since the default made checkbox too dark
-const StyledCheckbox = styled(StyledCutout)`
- ${sharedCheckboxStyles}
- background: ${({ theme, isDisabled }) =>
- isDisabled ? 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: ${({ theme, isDisabled }) =>
- isDisabled ? 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%;
- }
-`;
-const StyledMenuCheckbox = styled.div`
- ${sharedCheckboxStyles}
- position: relative;
- display: inline-block;
- box-sizing: border-box;
- border: none;
- outline: none;
- background: none;
-`;
-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%;
- ${({ variant, theme, isDisabled }) =>
- variant === 'menu'
- ? css`
- background: ${isDisabled
- ? theme.materialTextDisabled
- : theme.materialText};
- filter: drop-shadow(
- 1px 1px 0px
- ${isDisabled ? theme.materialTextDisabledShadow : 'transparent'}
- );
- `
- : css`
- background: ${isDisabled ? theme.checkmarkDisabled : theme.checkmark};
- `}
- ${StyledListItem}:hover & {
- ${({ theme, isDisabled, variant }) =>
- !isDisabled &&
- variant === 'menu' &&
- css`
- background: ${theme.materialTextInvert};
- `};
- }
-`;
-
-const CheckboxComponents = {
- flat: StyledFlatCheckbox,
- default: StyledCheckbox,
- menu: StyledMenuCheckbox
-};
-
-const Radio = React.forwardRef(function Radio(props, ref) {
- const {
- onChange,
- label,
- disabled,
- variant,
- checked,
- className,
- style,
- ...otherProps
- } = props;
-
- const CheckboxComponent = CheckboxComponents[variant];
-
- return (
-
-
- {checked && }
-
-
- {label && {label} }
-
- );
-});
-
-Radio.defaultProps = {
- onChange: undefined,
- name: null,
- value: undefined,
- checked: undefined,
- label: '',
- disabled: false,
- variant: 'default',
- className: '',
- style: {}
-};
-
-Radio.propTypes = {
- onChange: propTypes.func,
- name: propTypes.string,
- value: propTypes.oneOfType([
- propTypes.string,
- propTypes.number,
- propTypes.bool
- ]),
- label: propTypes.oneOfType([propTypes.string, propTypes.number]),
- checked: propTypes.bool,
- disabled: propTypes.bool,
- variant: propTypes.oneOf(['default', 'flat', 'menu']),
- // eslint-disable-next-line react/forbid-prop-types
- style: propTypes.any,
- className: propTypes.string
-};
-
-export default Radio;
diff --git a/src/Radio/Radio.mdx b/src/Radio/Radio.mdx
deleted file mode 100644
index ec593a84..00000000
--- a/src/Radio/Radio.mdx
+++ /dev/null
@@ -1,33 +0,0 @@
----
-name: Radio
-menu: Components
----
-
-import Radio from './Radio';
-import Fieldset from '../Fieldset/Fieldset';
-import Window from '../Window/Window';
-import WindowContent from '../WindowContent/WindowContent';
-
-# Radio
-
-## Usage
-
-
-
-
-
-
-
-
-
-## API
-
-### Import
-
-```
-import { Radio } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Radio/Radio.spec.js b/src/Radio/Radio.spec.tsx
similarity index 93%
rename from src/Radio/Radio.spec.js
rename to src/Radio/Radio.spec.tsx
index dbf62624..fab02340 100644
--- a/src/Radio/Radio.spec.js
+++ b/src/Radio/Radio.spec.tsx
@@ -1,6 +1,7 @@
import React from 'react';
+
import { renderWithTheme } from '../../test/utils';
-import Radio from './Radio';
+import { Radio } from './Radio';
describe(' ', () => {
describe('label', () => {
@@ -51,7 +52,7 @@ describe(' ', () => {
);
rerender( );
- const checkbox = getByRole('radio');
+ const checkbox = getByRole('radio') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
expect(getByRole('radio')).toHaveAttribute('checked');
@@ -66,7 +67,7 @@ describe(' ', () => {
);
rerender( );
- const checkbox = getByRole('radio');
+ const checkbox = getByRole('radio') as HTMLInputElement;
expect(checkbox.checked).toBe(false);
expect(getByRole('radio')).not.toHaveAttribute('checked');
diff --git a/src/Radio/Radio.stories.js b/src/Radio/Radio.stories.tsx
similarity index 61%
rename from src/Radio/Radio.stories.js
rename to src/Radio/Radio.stories.tsx
index a64d4152..1190462c 100644
--- a/src/Radio/Radio.stories.js
+++ b/src/Radio/Radio.stories.tsx
@@ -1,15 +1,7 @@
+import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
+import { GroupBox, Radio, ScrollView, Window, WindowContent } from 'react95';
import styled from 'styled-components';
-import {
- Radio,
- Cutout,
- Fieldset,
- Window,
- WindowContent,
- List,
- ListItem,
- Divider
-} from 'react95';
const Wrapper = styled.div`
padding: 5rem;
@@ -24,20 +16,22 @@ const Wrapper = styled.div`
}
}
`;
+
export default {
- title: 'Radio',
+ title: 'Controls/Radio',
component: Radio,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
const [state, setState] = useState('Pear');
- const handleChange = e => setState(e.target.value);
+ const handleChange = (e: React.ChangeEvent) =>
+ setState(e.target.value);
return (
-
+
-
+
);
@@ -82,18 +76,19 @@ Default.story = {
export function Flat() {
const [state, setState] = useState('Pear');
- const handleChange = e => setState(e.target.value);
+ 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:
-
+
-
-
+
+
);
@@ -140,65 +135,3 @@ export function Flat() {
Flat.story = {
name: 'flat'
};
-
-export function Menu() {
- const [state, setState] = useState({
- tool: 'Brush',
- color: 'Black'
- });
- const handleToolChange = e => setState({ ...state, tool: e.target.value });
- const handleColorChange = e => setState({ ...state, color: e.target.value });
-
- const { tool, color } = state;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-Menu.story = {
- name: 'menu'
-};
diff --git a/src/Radio/Radio.tsx b/src/Radio/Radio.tsx
new file mode 100644
index 00000000..cf9aaf34
--- /dev/null
+++ b/src/Radio/Radio.tsx
@@ -0,0 +1,146 @@
+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 };
diff --git a/src/ScrollView/ScrollView.spec.tsx b/src/ScrollView/ScrollView.spec.tsx
new file mode 100644
index 00000000..71593cde
--- /dev/null
+++ b/src/ScrollView/ScrollView.spec.tsx
@@ -0,0 +1,44 @@
+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');
+ });
+});
diff --git a/src/Cutout/Cutout.stories.js b/src/ScrollView/ScrollView.stories.tsx
similarity index 63%
rename from src/Cutout/Cutout.stories.js
rename to src/ScrollView/ScrollView.stories.tsx
index 845cdf3e..4d7964b5 100644
--- a/src/Cutout/Cutout.stories.js
+++ b/src/ScrollView/ScrollView.stories.tsx
@@ -1,17 +1,24 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
+import { ScrollView, Window, WindowContent } from 'react95';
+import styled from 'styled-components';
-import { Cutout, Window, WindowContent } from 'react95';
+const Wrapper = styled.div`
+ padding: 5rem;
+ background: ${({ theme }) => theme.desktopBackground};
+`;
export default {
- title: 'Cutout',
- component: Cutout
-};
+ title: 'Layout/ScrollView',
+ component: ScrollView,
+ decorators: [story => {story()} ]
+} as ComponentMeta;
export function Default() {
return (
-
+
React95 is the best UI library ever created
@@ -25,7 +32,7 @@ export function Default() {
React95 is the best UI library ever created
React95 is the best UI library ever created
-
+
);
diff --git a/src/Cutout/Cutout.js b/src/ScrollView/ScrollView.tsx
similarity index 63%
rename from src/Cutout/Cutout.js
rename to src/ScrollView/ScrollView.tsx
index eaa18b87..632ae1e3 100644
--- a/src/Cutout/Cutout.js
+++ b/src/ScrollView/ScrollView.tsx
@@ -1,9 +1,15 @@
-import React from 'react';
-import propTypes from 'prop-types';
+import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { insetShadow, createScrollbars } from '../common';
+import { CommonStyledProps } from '../types';
-export const StyledCutout = styled.div`
+type ScrollViewProps = {
+ children?: React.ReactNode;
+ shadow?: boolean;
+} & React.HTMLAttributes &
+ CommonStyledProps;
+
+export const StyledScrollView = styled.div>`
position: relative;
box-sizing: border-box;
padding: 2px;
@@ -44,23 +50,16 @@ const Content = styled.div`
${createScrollbars()}
`;
-const Cutout = React.forwardRef(function Cutout(props, ref) {
- const { children, ...otherProps } = props;
- return (
-
- {children}
-
- );
-});
-
-Cutout.defaultProps = {
- children: null,
- shadow: true
-};
+const ScrollView = forwardRef(
+ ({ children, shadow = true, ...otherProps }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
-Cutout.propTypes = {
- children: propTypes.node,
- shadow: propTypes.bool
-};
+ScrollView.displayName = 'ScrollView';
-export default Cutout;
+export { ScrollView, ScrollViewProps };
diff --git a/src/Select/Select.js b/src/Select/Select.js
deleted file mode 100644
index 0fc47248..00000000
--- a/src/Select/Select.js
+++ /dev/null
@@ -1,518 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
-import useForkRef from '../common/hooks/useForkRef';
-
-import { clamp } from '../common/utils';
-
-import {
- StyledDropdownButton,
- StyledDropdownIcon,
- StyledDropdownMenu,
- StyledDropdownMenuItem,
- StyledFlatSelectWrapper,
- StyledInner,
- StyledNativeOption,
- StyledNativeSelect,
- StyledSelectContent,
- StyledSelectWrapper
-} from './Select.styles';
-
-const KEYS = {
- ARROW_DOWN: 'ArrowDown',
- ARROW_LEFT: 'ArrowLeft',
- ARROW_RIGHT: 'ArrowRight',
- ARROW_UP: 'ArrowUp',
- ENTER: 'Enter',
- ESC: 'Escape',
- SPACE: ' ',
- TAB: 'Tab'
-};
-
-function areEqualValues(a, b) {
- if (typeof b === 'object' && b !== null) {
- return a === b;
- }
- return String(a) === String(b);
-}
-
-const getWrapper = variant =>
- variant === 'flat' ? StyledFlatSelectWrapper : StyledSelectWrapper;
-
-const getDisplayLabel = (selectedOption, formatDisplay) => {
- if (selectedOption) {
- if (formatDisplay) {
- return formatDisplay(selectedOption);
- }
- return selectedOption.label;
- }
- return '';
-};
-
-const getDefaultValue = (defaultValue, options) => {
- if (defaultValue) {
- return defaultValue;
- }
- if (options && options[0]) {
- return options[0].value;
- }
- return undefined;
-};
-
-const Select = React.forwardRef(function Select(props, ref) {
- const {
- 'aria-label': ariaLabel,
- className,
- defaultValue,
- disabled,
- formatDisplay,
- inputRef: inputRefProp,
- labelId,
- menuMaxHeight,
- name,
- native,
- onBlur,
- onChange,
- onClose,
- onFocus,
- onOpen,
- open: openProp,
- options: optionsProp,
- readOnly,
- SelectDisplayProps,
- shadow,
- style,
- value: valueProp,
- variant,
- width,
- ...otherProps
- } = props;
- const wrapperRef = React.useRef();
- const displayNode = React.useRef();
- const inputRef = React.useRef();
- const dropdownRef = React.useRef();
- const options = optionsProp.filter(Boolean);
- const [value, setValueState] = useControlledOrUncontrolled({
- value: valueProp,
- defaultValue: getDefaultValue(defaultValue, options)
- });
-
- const { current: isOpenControlled } = React.useRef(openProp != null);
- const [openState, setOpenState] = React.useState(false);
- const open =
- displayNode !== null && (isOpenControlled ? openProp : openState);
- const handleRef = useForkRef(ref, inputRefProp);
-
- // to hijack native focus. when somebody passes ref
- // and triggers focus, we focus displayNode instead of input
- React.useImperativeHandle(
- handleRef,
- () => ({
- focus: () => {
- displayNode.current.focus();
- },
- node: inputRef.current,
- value
- }),
- [displayNode, value]
- );
-
- const getSelectedOption = selectedValue =>
- options.find(opt => {
- if (selectedValue) {
- return (
- opt.value === selectedValue ||
- opt.value === parseInt(selectedValue, 10)
- );
- }
- return opt.value === value;
- });
-
- const getFocusedNodeIndex = () => {
- let focusedIndex = -1;
-
- if (dropdownRef && dropdownRef.current) {
- const optionNodes = Array.from(dropdownRef.current.childNodes);
- focusedIndex = optionNodes.findIndex(
- node => node === document.activeElement
- );
- }
- return focusedIndex;
- };
-
- const update = (opens, e) => {
- if (opens) {
- if (onOpen) {
- onOpen(e);
- }
- } else if (onClose) {
- onClose(e);
- }
-
- if (!isOpenControlled) {
- setOpenState(opens);
- }
- };
-
- const handleOpen = e => {
- update(true, e);
- };
-
- const handleClose = e => {
- update(false, e);
- };
-
- const toggleOpen = e => {
- update(!open, e);
- };
-
- React.useEffect(() => {
- const handleClick = e => {
- if (openState) {
- if (!wrapperRef.current.contains(e.target)) {
- e.preventDefault();
- handleClose(e);
- displayNode.current.focus();
- }
- }
- };
- document.addEventListener('mousedown', handleClick);
- return () => {
- document.removeEventListener('mousedown', handleClick);
- };
- });
-
- const handleMouseDown = e => {
- // ignore everything but left-click
- if (e.button !== 0) {
- return;
- }
- // hijack the default focus behavior.
- e.preventDefault();
- displayNode.current.focus();
-
- if (open) {
- handleClose(e);
- } else {
- handleOpen(e);
- }
- };
-
- const handleOptionClick = opt => e => {
- const newValue = opt.value;
- setValueState(newValue);
-
- if (onChange) {
- e.persist();
- Object.defineProperty(e, 'target', {
- writable: true,
- value: { value: newValue, name }
- });
- onChange(e, opt);
- }
- handleClose(e);
- displayNode.current.focus();
- };
-
- const handleKeyDown = e => {
- const { key } = e;
- // the native select doesn't respond to enter on mac, but it's recommended by
- // https://www.w3.org/TR/wai-aria-practices/examples/listbox/listbox-collapsible.html
- const { ARROW_DOWN, ARROW_UP, ENTER, SPACE, TAB } = KEYS;
-
- if (key === TAB) {
- if (open) {
- // if dropdown is open- close it
- // prevent default behaviour (focusing on next element)
- // and focus select instead
- e.preventDefault();
- toggleOpen(e);
- displayNode.current.focus();
- }
- } else {
- e.preventDefault();
- if (key === SPACE) {
- // space toggles the dropdown (open/closed) while keeping focus
- toggleOpen(e);
- displayNode.current.focus();
- } else if ([ARROW_DOWN, ARROW_UP, ENTER].includes(key)) {
- if (!open) {
- handleOpen(e);
- }
- const currentFocusIndex = getFocusedNodeIndex();
- if ([ARROW_UP, ARROW_DOWN].includes(key)) {
- const change = key === ARROW_UP ? -1 : 1;
- const nextOptionIndex = clamp(
- currentFocusIndex + change,
- 0,
- options.length - 1
- );
- if (dropdownRef.current) {
- const nextOption = dropdownRef.current.childNodes[nextOptionIndex];
- nextOption.focus();
- }
- } else if (
- key === ENTER &&
- currentFocusIndex > -1 &&
- dropdownRef.current
- ) {
- setValueState(options[currentFocusIndex].value);
- handleClose(e);
- displayNode.current.focus();
- if (onChange) {
- const option = options[currentFocusIndex];
- e.persist();
- Object.defineProperty(e, 'target', {
- writable: true,
- value: { value: option.value, name }
- });
- onChange(e, option);
- }
- }
- }
- }
- };
-
- const handleBlur = e => {
- // trigger onBlur only when dropdown is closesd
- // otherwise onBlur would be triggered when switching focus
- // from display node to
- if (!open && onBlur) {
- e.persist();
- // Preact support, target is read only property on a native event.
- Object.defineProperty(e, 'target', {
- writable: true,
- value: { value, name }
- });
- onBlur(e);
- }
- };
-
- const handleOptionKeyUp = e => {
- if (e.key === KEYS.SPACE) {
- // otherwise our MenuItems dispatches a click event
- // it's not behavior of the native and causes
- // the select to close immediately since we open on space keydown
- e.preventDefault();
- }
- };
-
- // function below to enable using
- // value/defaultValue in native select
- const handleNativeSelection = (e, opt) => {
- e.stopPropagation();
-
- const nextSelection = opt || getSelectedOption(e.target.value);
-
- if (onChange) {
- onChange(e, nextSelection);
- }
-
- setValueState(nextSelection.value);
-
- if (displayNode.current) {
- displayNode.current.focus();
- }
- };
-
- const isEnabled = !(disabled || readOnly);
- const selectedOption = getSelectedOption();
- const displayLabel = getDisplayLabel(selectedOption, formatDisplay);
- const tabIndex = isEnabled ? '1' : undefined;
- const Wrapper = getWrapper(variant, native);
- const OptionComponent = native ? StyledNativeOption : StyledDropdownMenuItem;
-
- const optionsContent = options.map((opt, i) => {
- const optionProps = {
- 'data-value': native ? undefined : opt.value,
- key: `${value}-${i}`,
- onClick: native ? undefined : handleOptionClick(opt),
- onKeyUp: native ? undefined : handleOptionKeyUp,
- role: native ? null : 'option',
- tabIndex: native ? undefined : '0'
- };
-
- const selected = areEqualValues(opt.value, value);
- if (!native) {
- optionProps['aria-selected'] = selected ? 'true' : undefined;
- } else {
- optionProps.value = opt.value;
- }
-
- return (
- {
- if (el && opt.value === value) {
- el.focus();
- }
- }}
- {...optionProps}
- >
- {opt.label}
-
- );
- });
-
- const wrapperCommonProps = {
- className,
- style: { ...style, width }
- };
-
- const DropdownButton = (
-
-
-
- );
- if (native) {
- return (
-
-
-
- {optionsContent}
-
- {DropdownButton}
-
-
- );
- }
- const buttonId =
- SelectDisplayProps.id ||
- (name ? `react95-component-select-${name}` : undefined);
- return (
-
-
-
- {displayLabel}
-
- {DropdownButton}
-
- {isEnabled && open && (
-
- {optionsContent}
-
- )}
-
- );
-});
-
-Select.defaultProps = {
- 'aria-label': undefined,
- className: undefined,
- defaultValue: undefined,
- disabled: undefined,
- formatDisplay: undefined,
- inputRef: undefined,
- labelId: undefined,
- menuMaxHeight: undefined,
- name: undefined,
- native: false,
- onBlur: undefined,
- onChange: undefined,
- onClose: undefined,
- onFocus: undefined,
- onOpen: undefined,
- open: undefined,
- options: [],
- readOnly: undefined,
- SelectDisplayProps: {},
- shadow: true,
- style: {},
- value: undefined,
- variant: 'default',
- width: 'auto'
-};
-
-Select.propTypes = {
- 'aria-label': propTypes.string,
- className: propTypes.string,
- // eslint-disable-next-line react/forbid-prop-types
- defaultValue: propTypes.any,
- disabled: propTypes.bool,
- formatDisplay: propTypes.func,
- // eslint-disable-next-line react/forbid-prop-types
- inputRef: propTypes.any,
- menuMaxHeight: propTypes.oneOfType([propTypes.string, propTypes.number]),
- name: propTypes.string,
- native: propTypes.bool,
- onBlur: propTypes.func,
- onChange: propTypes.func,
- onClose: propTypes.func,
- onFocus: propTypes.func,
- onOpen: propTypes.func,
- labelId: propTypes.string,
- open: propTypes.bool,
- options: propTypes.arrayOf(
- propTypes.shape({
- label: propTypes.string,
- value: propTypes.any
- })
- ),
- readOnly: propTypes.bool,
- // eslint-disable-next-line react/forbid-prop-types
- SelectDisplayProps: propTypes.object,
- shadow: propTypes.bool,
- style: propTypes.object,
- // eslint-disable-next-line react/forbid-prop-types
- value: propTypes.any,
- variant: propTypes.oneOf(['default', 'flat']),
- width: propTypes.oneOfType([propTypes.string, propTypes.number])
-};
-
-export default Select;
diff --git a/src/Select/Select.mdx b/src/Select/Select.mdx
deleted file mode 100644
index 85dc4325..00000000
--- a/src/Select/Select.mdx
+++ /dev/null
@@ -1,143 +0,0 @@
----
-name: Select
-menu: Components
----
-
-import Select from './Select'
-import Window from '../Window/Window'
-import WindowContent from '../WindowContent/WindowContent'
-import Cutout from '../Cutout/Cutout'
-
-# Select
-
-## Usage
-
-#### Fixed Width
-
-
- {() => {
- const items = [
- { value: 1, label: '⚡ Pikachu' },
- { value: 2, label: '🌿 Bulbasaur' },
- { value: 3, label: '💦 Squirtle' },
- { value: 4, label: '🔥 Charizard' },
- { value: 5, label: '🎤 Jigglypuff' },
- { value: 6, label: '🛌🏻 Snorlax' },
- { value: 7, label: '⛰ Geodude' }
- ];
- return (
- console.log(value)}
- width={150}
- />
- );
- }}
-
-
-#### Fixed Height
-
-
- {() => {
- const items = [
- { value: 1, label: '⚡ Pikachu' },
- { value: 2, label: '🌿 Bulbasaur' },
- { value: 3, label: '💦 Squirtle' },
- { value: 4, label: '🔥 Charizard' },
- { value: 5, label: '🎤 Jigglypuff' },
- { value: 6, label: '🛌🏻 Snorlax' },
- { value: 7, label: '⛰ Geodude' }
- ];
- return (
- console.log(value)}
- height={100}
- width={150}
- />
- );
- }}
-
-
-#### No shadow
-
-
- {() => {
- const items = [
- { value: 1, label: '⚡ Pikachu' },
- { value: 2, label: '🌿 Bulbasaur' },
- { value: 3, label: '💦 Squirtle' },
- { value: 4, label: '🔥 Charizard' },
- { value: 5, label: '🎤 Jigglypuff' },
- { value: 6, label: '🛌🏻 Snorlax' },
- { value: 7, label: '⛰ Geodude' }
- ];
- return (
- console.log(value)}
- />
- );
- }}
-
-
-#### Flat
-
-
- {() => {
- const items = [
- { value: 1, label: '⚡ Pikachu' },
- { value: 2, label: '🌿 Bulbasaur' },
- { value: 3, label: '💦 Squirtle' },
- { value: 4, label: '🔥 Charizard' },
- { value: 5, label: '🎤 Jigglypuff' },
- { value: 6, label: '🛌🏻 Snorlax' },
- { value: 7, label: '⛰ Geodude' }
- ];
- return (
-
-
-
-
- When you want to use Select on a light background (like scrollable
- content), just use the flat variant:
-
-
- console.log(value)}
- height={100}
- width={150}
- />
-
-
-
-
- );
- }}
-
-
-## API
-
-### Import
-
-```
-import { Select } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Select/Select.spec.js b/src/Select/Select.spec.js
deleted file mode 100644
index 2210e8e5..00000000
--- a/src/Select/Select.spec.js
+++ /dev/null
@@ -1,424 +0,0 @@
-// Bsased on https://github.com/mui-org/material-ui
-import React from 'react';
-import { fireEvent } from '@testing-library/react';
-import { renderWithTheme } from '../../test/utils';
-import Select from './Select';
-
-const options = [
- { label: 'ten', value: 10 },
- { label: 'twenty', value: 20 },
- { label: 'thirty', value: 30 }
-];
-
-describe(' ', () => {
- it('should be able to mount the component', () => {
- const { container } = renderWithTheme(
-
- );
- expect(container.querySelector('input').value).toBe('10');
- });
-
- it('renders dropdown button with icon', () => {
- const { getByTestId } = renderWithTheme(
-
- );
-
- const button = 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', () => {
- const { getByRole } = renderWithTheme(
-
- );
- expect(getByRole('button')).toHaveProperty('tabIndex', 1);
- });
- it('should accept null child', () => {
- renderWithTheme( );
- });
- it('should have an input with [type="hidden"] by default', () => {
- const { container } = renderWithTheme(
-
- );
- expect(container.querySelector('input')).toHaveAttribute('type', 'hidden');
- });
- 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();
- const { getByRole, getAllByRole, queryByRole } = 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 = getByRole('button');
- fireEvent.mouseDown(trigger);
- expect(handleBlur).toHaveBeenCalledTimes(0);
- expect(getByRole('listbox')).toBeInTheDocument();
- const o = getAllByRole('option');
- fireEvent.mouseDown(o[0]);
- o[0].click();
- expect(handleBlur).toHaveBeenCalledTimes(0);
- expect(queryByRole('listbox', { hidden: false })).toBe(null);
- });
- it('options should have a data-value attribute', () => {
- const { getAllByRole } = renderWithTheme(
-
- );
- const o = getAllByRole('option');
- expect(o[0]).toHaveAttribute('data-value', '10');
- expect(o[1]).toHaveAttribute('data-value', '20');
- });
- [' ', 'ArrowUp', 'ArrowDown', 'Enter'].forEach(key => {
- it(`should open menu when pressed ${key} key on select`, () => {
- const { getByRole } = renderWithTheme(
-
- );
- getByRole('button').focus();
- fireEvent.keyDown(document.activeElement, { key });
- expect(getByRole('listbox', { hidden: false })).toBeInTheDocument();
- fireEvent.keyUp(document.activeElement, { key });
- expect(getByRole('listbox', { hidden: false })).toBeInTheDocument();
- });
- });
- it('should pass "name" as part of the event.target for onBlur', () => {
- const handleBlur = jest.fn(event => event.target.name);
- const { getByRole } = renderWithTheme(
-
- );
- const button = getByRole('button');
- button.focus();
- button.blur();
- expect(handleBlur).toHaveBeenCalledTimes(1);
- expect(handleBlur.mock.results[0].value).toBe('blur-testing');
- });
-
- // TODO why it doesn't work ?
- // it('should call onClose when user clicks outside of component', () => {
- // const handleClose = jest.fn();
- // const { getByTestId } = renderWithTheme(
- //
- // );
- // expect(handleClose).toHaveBeenCalledTimes(0);
- // act(() => {
- // fireEvent.click(getByTestId('el'));
- // });
- // expect(handleClose).toHaveBeenCalledTimes(1);
- // });
-
- describe('prop: menuMaxHeight', () => {
- it('sets max-height to dropdown', () => {
- const { getByRole } = renderWithTheme(
-
- );
-
- const listbox = getByRole('listbox');
- expect(
- listbox.getAttribute('style').includes('max-height: 220px')
- ).toBeTruthy();
- });
- });
-
- describe('prop: onChange', () => {
- it('should get selected option from arguments', () => {
- const onChange = jest.fn();
- const { getAllByRole, getByRole } = renderWithTheme(
-
- );
- fireEvent.mouseDown(getByRole('button'));
- getAllByRole('option')[1].click();
- expect(onChange).toHaveBeenCalledTimes(1);
- const selected = onChange.mock.calls[0][1];
- expect(selected.value).toBe(20);
- expect(selected.label).toBe('twenty');
- });
- });
- describe('prop: value', () => {
- it('should select the option based on the number value', () => {
- const { getAllByRole } = renderWithTheme(
-
- );
- const o = 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 the option based on the string value', () => {
- const { getAllByRole } = renderWithTheme(
-
- );
- const o = 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 };
- const { getAllByRole } = renderWithTheme(
-
- );
- const o = 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 = {};
- const { getByRole } = renderWithTheme(
-
- );
- expect(getByRole('button')).toHaveTextContent('object-label');
- });
- });
-
- 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
- const { getByRole } = renderWithTheme( );
- expect(getByRole('button', { hidden: true })).toHaveAttribute(
- 'aria-expanded',
- 'true'
- );
- });
- it('aria-expanded is not present if the listbox isnt displayed', () => {
- const { getByRole } = renderWithTheme( );
- expect(getByRole('button')).not.toHaveAttribute('aria-expanded');
- });
- it('indicates that activating the button displays a listbox', () => {
- const { getByRole } = renderWithTheme( );
- expect(getByRole('button')).toHaveAttribute('aria-haspopup', 'listbox');
- });
- it('renders an element with listbox behavior', () => {
- const { getByRole } = renderWithTheme( );
- expect(getByRole('listbox')).toBeVisible();
- });
-
- it('the listbox is focusable', () => {
- const { getByRole } = renderWithTheme( );
- const listbox = getByRole('listbox');
- listbox.focus();
- expect(listbox).toHaveFocus();
- });
- it('identifies each selectable element containing an option', () => {
- const { getAllByRole } = renderWithTheme(
-
- );
- const o = getAllByRole('option');
- expect(o[0]).toHaveTextContent('ten');
- expect(o[1]).toHaveTextContent('twenty');
- });
- it('indicates the selected option', () => {
- const { getAllByRole } = renderWithTheme(
-
- );
- expect(getAllByRole('option')[1]).toHaveAttribute(
- 'aria-selected',
- 'true'
- );
- });
- it('it will fallback to its content for the accessible name when it has no name', () => {
- const { getByRole } = renderWithTheme( );
- expect(getByRole('button')).not.toHaveAttribute('aria-labelledby');
- });
- it('is labelled by itself when it has a name', () => {
- const { getByRole } = renderWithTheme( );
- const button = getByRole('button');
- expect(button).toHaveAttribute(
- 'aria-labelledby',
- button.getAttribute('id')
- );
- });
- it('is labelled by itself when it has an id which is preferred over name', () => {
- const { getAllByRole } = renderWithTheme(
- <>
- Chose first option:
-
- Chose second option:
-
- >
- );
- const triggers = getAllByRole('button');
- expect(triggers[0]).toHaveAttribute(
- 'aria-labelledby',
- `select-1-label ${triggers[0].getAttribute('id')}`
- );
- expect(triggers[1]).toHaveAttribute(
- 'aria-labelledby',
- `select-2-label ${triggers[1].getAttribute('id')}`
- );
- });
- it('can be labelled by an additional element if its id is provided in `labelId`', () => {
- const { getByRole } = renderWithTheme(
- <>
- Choose one:
-
- >
- );
- expect(getByRole('button')).toHaveAttribute(
- 'aria-labelledby',
- `select-label ${getByRole('button').getAttribute('id')}`
- );
- });
- it('the list of options is not labelled by default', () => {
- const { getByRole } = renderWithTheme( );
- expect(getByRole('listbox')).not.toHaveAttribute('aria-labelledby');
- });
- it('the list of options can be labelled by providing `labelId`', () => {
- const { getByRole } = renderWithTheme(
- <>
- Choose one:
-
- >
- );
- expect(getByRole('listbox')).toHaveAttribute(
- 'aria-labelledby',
- 'select-label'
- );
- });
- });
- describe('prop: readOnly', () => {
- it('should not trigger any event with readOnly', () => {
- const { getByRole, queryByRole } = renderWithTheme(
-
- );
- getByRole('button').focus();
- fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
- expect(queryByRole('listbox')).not.toBeInTheDocument();
- fireEvent.keyUp(document.activeElement, { key: 'ArrowDown' });
- expect(queryByRole('listbox')).not.toBeInTheDocument();
- });
- });
-
- describe('prop: SelectDisplayProps', () => {
- it('should apply additional props to trigger element', () => {
- const { getByRole } = renderWithTheme(
-
- );
- expect(getByRole('button')).toHaveAttribute('data-test', 'SelectDisplay');
- });
- });
-
- describe('prop: renderValue', () => {
- it('should use the prop to render the value', () => {
- const formatDisplay = x => `0b${x.value.toString(2)}`;
- const { getByRole } = renderWithTheme(
-
- );
- expect(getByRole('button')).toHaveTextContent('0b10');
- });
- });
-
- describe('prop: open (controlled)', () => {
- // TODO add more tests
- it('should be open when initially true', () => {
- const { getByRole } = renderWithTheme(
-
- );
- expect(getByRole('listbox')).toBeInTheDocument();
- });
- it('open only with the left mouse button click', () => {
- // Right/middle mouse click shouldn't open the Select
- const { getByRole, queryByRole } = renderWithTheme(
-
- );
- const trigger = getByRole('button');
- // If clicked by the right/middle mouse button, no options list should be opened
- fireEvent.mouseDown(trigger, { button: 1 });
- expect(queryByRole('listbox')).not.toBeInTheDocument();
- fireEvent.mouseDown(trigger, { button: 2 });
- expect(queryByRole('listbox')).not.toBeInTheDocument();
- });
- });
-
- describe('prop: inputRef', () => {
- 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();
- const { getByRole } = renderWithTheme( );
- ref.current.focus();
- expect(getByRole('button')).toHaveFocus();
- });
- });
- describe('prop: name', () => {
- it('should have no id when name is not provided', () => {
- const { getByRole } = renderWithTheme( );
- expect(getByRole('button')).not.toHaveAttribute('id');
- });
- it('should have select-`name` id when name is provided', () => {
- const { getByRole } = renderWithTheme( );
- expect(getByRole('button')).toHaveAttribute(
- 'id',
- 'react95-component-select-foo'
- );
- });
- });
- describe('prop: native', () => {
- it('renders a ', () => {
- const { container } = renderWithTheme( );
- expect(container.querySelector('select')).toBeInTheDocument();
- });
- it('can be labelled with a ', () => {
- const { getByLabelText } = renderWithTheme(
- <>
- A select
-
- >
- );
- expect(getByLabelText('A select')).toHaveProperty('tagName', 'SELECT');
- });
- });
-});
diff --git a/src/Select/Select.spec.tsx b/src/Select/Select.spec.tsx
new file mode 100644
index 00000000..01f53842
--- /dev/null
+++ b/src/Select/Select.spec.tsx
@@ -0,0 +1,711 @@
+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');
+ });
+ });
+});
diff --git a/src/Select/Select.stories.data.ts b/src/Select/Select.stories.data.ts
new file mode 100644
index 00000000..000a7a19
--- /dev/null
+++ b/src/Select/Select.stories.data.ts
@@ -0,0 +1,153 @@
+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 }));
diff --git a/src/Select/Select.stories.js b/src/Select/Select.stories.tsx
similarity index 66%
rename from src/Select/Select.stories.js
rename to src/Select/Select.stories.tsx
index 4454e0ff..b3aa769c 100644
--- a/src/Select/Select.stories.js
+++ b/src/Select/Select.stories.tsx
@@ -1,18 +1,25 @@
/* 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';
-import { Select, Window, WindowContent, Cutout, Fieldset } from 'react95';
+const options = PokemonOptions;
-const options = [
- { value: 1, label: '⚡ Pikachu' },
- { value: 2, label: '🌿 Bulbasaur' },
- { value: 3, label: '💦 Squirtle' },
- { value: 4, label: '🔥 Mega Charizard Y' },
- { value: 5, label: '🎤 Jigglypuff' },
- { value: 6, label: '🛌🏻 Snorlax' },
- { value: 7, label: '⛰ Geodude' }
-];
+const nativeOptions = options.map(option => ({
+ ...option,
+ value: String(option.value)
+}));
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
@@ -37,18 +44,21 @@ const Wrapper = styled.div`
}
`;
-const onChange = (evt, nextSelection) => console.log(evt, nextSelection);
+const onChange = (
+ selectedOption: SelectOption,
+ changeOptions: { fromEvent: React.SyntheticEvent | Event }
+) => console.log(selectedOption, changeOptions.fromEvent);
export default {
- title: 'Select',
+ title: 'Controls/Select',
component: Select,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
return (
-
+
-
-
-
+
+ console.log('native close')}
onBlur={() => console.log('native blur')}
onFocus={() => console.log('native focus')}
/>
-
-
+
);
}
@@ -103,12 +108,12 @@ export function Flat() {
return (
-
+
When you want to use Select on a light background (like scrollable
content), just use the flat variant:
-
+
-
-
-
+
+
-
-
-
+
+
);
@@ -154,7 +157,7 @@ Flat.story = {
export function CustomDisplayFormatting() {
return (
`${opt.label.toUpperCase()} 👍 👍`}
+ formatDisplay={opt => `${opt.label?.toUpperCase()} 👍 👍`}
onChange={onChange}
options={options}
width={220}
diff --git a/src/Select/Select.styles.js b/src/Select/Select.styles.tsx
similarity index 66%
rename from src/Select/Select.styles.js
rename to src/Select/Select.styles.tsx
index 22e79a91..4697e4f6 100644
--- a/src/Select/Select.styles.js
+++ b/src/Select/Select.styles.tsx
@@ -1,14 +1,23 @@
import styled, { css } from 'styled-components';
-import { StyledButton as Button } from '../Button/Button';
+import { StyledButton as Button } from '../Button/Button';
import {
- shadow as commonShadow,
createDisabledTextStyles,
createFlatBoxStyles,
- createScrollbars
+ createScrollbars,
+ shadow as commonShadow
} from '../common';
import { blockSizes } from '../common/system';
-import { StyledCutout } from '../Cutout/Cutout';
+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;
@@ -48,29 +57,31 @@ export const StyledSelectContent = styled.div`
border: 2px dotted ${({ theme }) => theme.focusSecondary};
}
`;
-const sharedWrapperStyles = css`
+const sharedWrapperStyles = css`
height: ${blockSizes.md};
display: inline-block;
- color: ${({ theme, isDisabled }) =>
- isDisabled ? createDisabledTextStyles() : theme.canvasText};
+ color: ${({ $disabled = false, theme }) =>
+ $disabled ? createDisabledTextStyles() : theme.canvasText};
font-size: 1rem;
- cursor: ${({ isDisabled }) => (isDisabled ? 'default' : 'pointer')};
+ cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
`;
-export const StyledSelectWrapper = styled(StyledCutout)`
+export const StyledSelectWrapper = styled(
+ StyledScrollView
+)`
${sharedWrapperStyles}
- background: ${({ theme, isDisabled }) =>
- isDisabled ? theme.material : theme.canvas};
+ background: ${({ $disabled = false, theme }) =>
+ $disabled ? theme.material : theme.canvas};
&:focus {
outline: 0;
}
`;
-export const StyledFlatSelectWrapper = styled.div`
+export const StyledFlatSelectWrapper = styled.div`
${createFlatBoxStyles()}
${sharedWrapperStyles}
- background: ${({ theme, isDisabled }) =>
- isDisabled ? theme.flatLight : theme.canvas};
+ background: ${({ $disabled = false, theme }) =>
+ $disabled ? theme.flatLight : theme.canvas};
`;
export const StyledNativeSelect = styled.select`
@@ -96,11 +107,13 @@ export const StyledNativeSelect = styled.select`
}
`;
-export const StyledDropdownButton = styled(Button)`
+export const StyledDropdownButton = styled(Button).attrs(() => ({
+ 'aria-hidden': 'true'
+}))>`
width: 30px;
padding: 0;
flex-shrink: 0;
- ${({ variant }) =>
+ ${({ variant = 'default' }) =>
variant === 'flat'
? css`
height: 100%;
@@ -108,15 +121,8 @@ export const StyledDropdownButton = styled(Button)`
`
: css`
height: 100%;
- &:before {
- border-left-color: ${({ theme }) => theme.borderLight};
- border-top-color: ${({ theme }) => theme.borderLight};
- box-shadow: inset 1px 1px 0px 1px
- ${({ theme }) => theme.borderLightest},
- inset -1px -1px 0 1px ${({ theme }) => theme.borderDark};
- }
`}
- ${({ native, variant }) =>
+ ${({ native = false, variant = 'default' }) =>
native &&
(variant === 'flat'
? `
@@ -130,11 +136,11 @@ export const StyledDropdownButton = styled(Button)`
right: 2px;
height: calc(100% - 4px);
`)}
- pointer-events: ${({ isDisabled, native }) =>
- isDisabled || native ? 'none' : 'auto'}
+ pointer-events: ${({ $disabled = false, native = false }) =>
+ $disabled || native ? 'none' : 'auto'}
`;
-export const StyledDropdownIcon = styled.span`
+export const StyledDropdownIcon = styled.span`
position: absolute;
left: 50%;
top: 50%;
@@ -145,10 +151,10 @@ export const StyledDropdownIcon = styled.span`
border-right: 6px solid transparent;
display: inline-block;
border-top: 6px solid
- ${({ theme, isDisabled }) =>
- isDisabled ? theme.materialTextDisabled : theme.materialText};
- ${({ theme, isDisabled }) =>
- isDisabled &&
+ ${({ $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};
@@ -158,7 +164,7 @@ export const StyledDropdownIcon = styled.span`
}
`;
-export const StyledDropdownMenu = styled.ul`
+export const StyledDropdownMenu = styled.ul`
box-sizing: border-box;
font-size: 1rem;
@@ -172,7 +178,7 @@ export const StyledDropdownMenu = styled.ul`
z-index: 1;
cursor: pointer;
box-shadow: ${commonShadow};
- ${({ variant }) =>
+ ${({ variant = 'default' }) =>
variant === 'flat'
? css`
bottom: 2px;
@@ -184,10 +190,10 @@ export const StyledDropdownMenu = styled.ul`
width: calc(100% - 2px);
border: 2px solid ${({ theme }) => theme.borderDarkest};
`}
- ${({ variant }) => createScrollbars(variant)}
+ ${({ variant = 'default' }) => createScrollbars(variant)}
`;
-export const StyledDropdownMenuItem = styled.li`
+export const StyledDropdownMenuItem = styled.li<{ active: boolean }>`
box-sizing: border-box;
width: 100%;
@@ -200,12 +206,9 @@ export const StyledDropdownMenuItem = styled.li`
overflow: hidden;
text-overflow: ellipsis;
color: ${({ theme }) => theme.canvasText};
- &:hover,
&:focus {
- ${sharedHoverStyles}
outline: 0;
}
+ ${({ active }) => (active ? sharedHoverStyles : '')}
user-select: none;
`;
-
-export const StyledNativeOption = styled.option``;
diff --git a/src/Select/Select.tsx b/src/Select/Select.tsx
new file mode 100644
index 00000000..1f195a7a
--- /dev/null
+++ b/src/Select/Select.tsx
@@ -0,0 +1,291 @@
+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 };
diff --git a/src/Select/Select.types.ts b/src/Select/Select.types.ts
new file mode 100644
index 00000000..7d2577b8
--- /dev/null
+++ b/src/Select/Select.types.ts
@@ -0,0 +1,69 @@
+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;
diff --git a/src/Select/SelectNative.spec.tsx b/src/Select/SelectNative.spec.tsx
new file mode 100644
index 00000000..e4bdb8b3
--- /dev/null
+++ b/src/Select/SelectNative.spec.tsx
@@ -0,0 +1,75 @@
+// 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'
+ );
+ });
+ });
+});
diff --git a/src/Select/SelectNative.tsx b/src/Select/SelectNative.tsx
new file mode 100644
index 00000000..64e1c58e
--- /dev/null
+++ b/src/Select/SelectNative.tsx
@@ -0,0 +1,85 @@
+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 };
diff --git a/src/Select/useSelectCommon.tsx b/src/Select/useSelectCommon.tsx
new file mode 100644
index 00000000..3919e44b
--- /dev/null
+++ b/src/Select/useSelectCommon.tsx
@@ -0,0 +1,82 @@
+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]
+ );
+};
diff --git a/src/Select/useSelectState.ts b/src/Select/useSelectState.ts
new file mode 100644
index 00000000..7af49241
--- /dev/null
+++ b/src/Select/useSelectState.ts
@@ -0,0 +1,591 @@
+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
+ ]
+ );
+};
diff --git a/src/Separator/Separator.spec.tsx b/src/Separator/Separator.spec.tsx
new file mode 100644
index 00000000..2e8c8e86
--- /dev/null
+++ b/src/Separator/Separator.spec.tsx
@@ -0,0 +1,74 @@
+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');
+ });
+ });
+});
diff --git a/src/Separator/Separator.stories.tsx b/src/Separator/Separator.stories.tsx
new file mode 100644
index 00000000..030e95f9
--- /dev/null
+++ b/src/Separator/Separator.stories.tsx
@@ -0,0 +1,41 @@
+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'
+};
diff --git a/src/Separator/Separator.tsx b/src/Separator/Separator.tsx
new file mode 100644
index 00000000..1956a098
--- /dev/null
+++ b/src/Separator/Separator.tsx
@@ -0,0 +1,29 @@
+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 };
diff --git a/src/Slider/Slider.js b/src/Slider/Slider.js
deleted file mode 100644
index 59ae75c6..00000000
--- a/src/Slider/Slider.js
+++ /dev/null
@@ -1,580 +0,0 @@
-// helper functions and event handling basically copied from Material UI (https://github.com/mui-org/material-ui) Slider component
-import React, { useRef } from 'react';
-import propTypes from 'prop-types';
-
-import styled, { css } from 'styled-components';
-import {
- createBoxStyles,
- createBorderStyles,
- createFlatBoxStyles,
- createDisabledTextStyles,
- createHatchedBackground
-} from '../common';
-import { clamp, roundValueToStep } from '../common/utils';
-import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
-import useForkRef from '../common/hooks/useForkRef';
-import { useIsFocusVisible } from '../common/hooks/useIsFocusVisible';
-import { StyledCutout } from '../Cutout/Cutout';
-
-function percentToValue(percent, min, max) {
- return (max - min) * percent + min;
-}
-
-function trackFinger(event, touchId) {
- if (touchId.current !== undefined && event.changedTouches) {
- for (let i = 0; i < event.changedTouches.length; i += 1) {
- const touch = event.changedTouches[i];
- if (touch.identifier === touchId.current) {
- return {
- x: touch.clientX,
- y: touch.clientY
- };
- }
- }
- return false;
- }
- return {
- x: event.clientX,
- y: event.clientY
- };
-}
-const useEnhancedEffect =
- typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
-
-function useEventCallback(fn) {
- const ref = React.useRef(fn);
- useEnhancedEffect(() => {
- ref.current = fn;
- });
- return React.useCallback((...args) => (0, ref.current)(...args), []);
-}
-function ownerDocument(node) {
- return (node && node.ownerDocument) || document;
-}
-function findClosest(values, currentValue) {
- const { index: closestIndex } = values.reduce((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;
-}
-
-function focusThumb(sliderRef) {
- if (!sliderRef.current.contains(document.activeElement)) {
- sliderRef.current.querySelector(`#swag`).focus();
- }
-}
-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};
- `}
- }
-
- ${({ vertical, size }) =>
- 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: ${({ isDisabled }) => (isDisabled ? 'none' : 'auto')};
-`;
-const sharedGrooveStyles = () => css`
- position: absolute;
- ${({ vertical }) =>
- 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(StyledCutout)`
- ${sharedGrooveStyles()}
-`;
-const StyledFlatGroove = styled(StyledCutout)`
- ${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;
- ${({ vertical }) =>
- 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()}
- `}
- ${({ isDisabled, theme }) =>
- isDisabled &&
- createHatchedBackground({
- mainColor: theme.material,
- secondaryColor: theme.borderLightest
- })}
-`;
-
-const tickHeight = 6;
-const Tick = styled.span`
- display: inline-block;
- position: absolute;
-
- ${({ vertical }) =>
- 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};
- ${({ isDisabled, theme }) =>
- isDisabled &&
- 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;
-
- ${({ vertical }) =>
- vertical
- ? css`
- transform: translate(${tickHeight + 2}px, ${tickHeight + 1}px);
- `
- : css`
- transform: translate(-0.5ch, calc(100% + 2px));
- `}
-`;
-
-const Slider = React.forwardRef(function Slider(props, ref) {
- const {
- value,
- defaultValue,
- step,
- min,
- max,
- size: sizeProp,
- marks: marksProp,
- onChange,
- onChangeCommitted,
- onMouseDown,
- name,
- orientation,
- variant,
- disabled,
- ...otherProps
- } = props;
- const Groove = variant === 'flat' ? StyledFlatGroove : StyledGroove;
- const vertical = orientation === 'vertical';
- const [valueDerived, setValueState] = useControlledOrUncontrolled({
- value,
- defaultValue
- });
-
- const {
- isFocusVisible,
- onBlurVisible,
- ref: focusVisibleRef
- } = useIsFocusVisible();
- const [focusVisible, setFocusVisible] = React.useState(false);
- const sliderRef = useRef();
- const handleFocusRef = useForkRef(focusVisibleRef, sliderRef);
- const handleRef = useForkRef(ref, handleFocusRef);
-
- const handleFocus = useEventCallback(event => {
- if (isFocusVisible(event)) {
- setFocusVisible(true);
- }
- });
- const handleBlur = useEventCallback(() => {
- if (focusVisible !== false) {
- setFocusVisible(false);
- onBlurVisible();
- }
- });
-
- const touchId = React.useRef();
-
- const marks =
- marksProp === true && step !== null
- ? [...Array(Math.round((max - min) / step) + 1)].map((_, index) => ({
- value: min + step * index
- }))
- : marksProp || [];
-
- const handleKeyDown = useEventCallback(event => {
- const tenPercents = (max - min) / 10;
- const marksValues = marks.map(mark => mark.value);
- const marksIndex = marksValues.indexOf(valueDerived);
- let newValue;
-
- 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);
-
- if (onChange) {
- onChange(event, newValue);
- }
- if (onChangeCommitted) {
- onChangeCommitted(event, newValue);
- }
- });
-
- const getNewValue = React.useCallback(
- finger => {
- const { current: slider } = sliderRef;
- const rect = slider.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;
- },
- [max, min, step]
- );
-
- const handleTouchMove = useEventCallback(event => {
- const finger = trackFinger(event, touchId);
-
- if (!finger) {
- return;
- }
- const newValue = getNewValue(finger);
-
- focusThumb(sliderRef);
- setValueState(newValue);
- setFocusVisible(true);
-
- if (onChange) {
- onChange(event, newValue);
- }
- });
- const handleTouchEnd = useEventCallback(event => {
- const finger = trackFinger(event, touchId);
-
- if (!finger) {
- return;
- }
-
- const newValue = getNewValue(finger);
-
- if (onChangeCommitted) {
- onChangeCommitted(event, 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 => {
- // TODO should we also pass event together with new value to callbacks? (same thing with other input components)
- if (onMouseDown) {
- onMouseDown(event);
- }
- event.preventDefault();
- const finger = trackFinger(event, touchId);
- const newValue = getNewValue(finger);
- focusThumb(sliderRef);
-
- setValueState(newValue);
- setFocusVisible(true);
-
- if (onChange) {
- onChange(event, newValue);
- }
- const doc = ownerDocument(sliderRef.current);
- doc.addEventListener('mousemove', handleTouchMove);
- doc.addEventListener('mouseup', handleTouchEnd);
- });
- const handleTouchStart = useEventCallback(event => {
- // 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;
- }
- const finger = trackFinger(event, touchId);
- const newValue = getNewValue(finger);
- focusThumb(sliderRef);
-
- setValueState(newValue);
- setFocusVisible(true);
-
- if (onChange) {
- onChange(event, newValue);
- }
-
- const doc = ownerDocument(sliderRef.current);
- doc.addEventListener('touchmove', handleTouchMove);
- doc.addEventListener('touchend', handleTouchEnd);
- });
- React.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]);
-
- const size = typeof sizeProp === 'number' ? `${sizeProp}px` : sizeProp;
-
- return (
-
- {/* should we keep the hidden input ? */}
-
- {marks &&
- marks.map(m => (
-
- {m.label && (
-
- {m.label}
-
- )}
-
- ))}
-
-
-
- );
-});
-
-Slider.defaultProps = {
- defaultValue: undefined,
- value: undefined,
- step: 1,
- min: 0,
- max: 100,
- size: '100%',
- onChange: null,
- onChangeCommitted: null,
- onMouseDown: null,
-
- name: null,
- marks: false,
- variant: 'default',
- orientation: 'horizontal',
- disabled: false
-};
-
-Slider.propTypes = {
- value: propTypes.number,
- defaultValue: propTypes.number,
-
- step: propTypes.number,
- min: propTypes.number,
- max: propTypes.number,
- size: propTypes.oneOfType([propTypes.string, propTypes.number]),
- onChange: propTypes.func,
- onChangeCommitted: propTypes.func,
- onMouseDown: propTypes.func,
-
- name: propTypes.string,
- marks: propTypes.oneOfType([propTypes.bool, propTypes.array]),
- variant: propTypes.oneOf(['default', 'flat']),
- orientation: propTypes.oneOf(['horizontal', 'vertical']),
- disabled: propTypes.bool
-};
-export default Slider;
diff --git a/src/Slider/Slider.spec.js b/src/Slider/Slider.spec.tsx
similarity index 73%
rename from src/Slider/Slider.spec.js
rename to src/Slider/Slider.spec.tsx
index ebee1e21..43b56359 100644
--- a/src/Slider/Slider.spec.js
+++ b/src/Slider/Slider.spec.tsx
@@ -1,24 +1,33 @@
// Pretty much straight out copied from https://github.com/mui-org/material-ui 😂
-
-import React from 'react';
import { fireEvent } from '@testing-library/react';
+import React from 'react';
import { renderWithTheme, Touch } from '../../test/utils';
-import Slider from './Slider';
+import { Slider } from './Slider';
-function createTouches(touches) {
+function createTouches(
+ touches: { identifier: number; clientX?: number; clientY?: number }[]
+) {
return {
- changedTouches: touches.map(
- touch =>
- new Touch({
- target: document.body,
- ...touch
- })
- )
+ 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();
@@ -31,14 +40,16 @@ describe(' ', () => {
/>
);
- fireEvent.mouseDown(container.firstChild);
+ const slider = container.firstElementChild as HTMLElement;
+ fireEvent.mouseDown(slider);
fireEvent.mouseUp(document.body);
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChangeCommitted).toHaveBeenCalledTimes(1);
getByRole('slider').focus();
- fireEvent.keyDown(document.activeElement, {
+ const focusedSlider = document.activeElement as HTMLElement;
+ fireEvent.keyDown(focusedSlider, {
key: 'Home'
});
expect(handleChange).toHaveBeenCalledTimes(2);
@@ -56,10 +67,8 @@ describe(' ', () => {
/>
);
- fireEvent.touchStart(
- container.firstChild,
- createTouches([{ identifier: 1 }])
- );
+ const slider = container.firstElementChild as HTMLElement;
+ fireEvent.touchStart(slider, createTouches([{ identifier: 1 }]));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChangeCommitted).not.toHaveBeenCalled();
@@ -81,7 +90,7 @@ describe(' ', () => {
});
it('defaults to horizontal orientation', () => {
- const { getByRole } = renderWithTheme( );
+ const { getByRole } = renderWithTheme( );
expect(getByRole('slider')).toHaveAttribute(
'aria-orientation',
@@ -91,9 +100,10 @@ describe(' ', () => {
it('should forward mouseDown', () => {
const handleMouseDown = jest.fn();
const { container } = renderWithTheme(
-
+
);
- fireEvent.mouseDown(container.firstChild);
+ const slider = container.firstElementChild as HTMLElement;
+ fireEvent.mouseDown(slider);
expect(handleMouseDown).toHaveBeenCalledTimes(1);
});
describe('prop: step', () => {
@@ -105,28 +115,23 @@ describe(' ', () => {
defaultValue={0}
/>
);
+ const slider = container.firstElementChild as HTMLElement;
// mocking containers size
- container.firstChild.getBoundingClientRect = () => ({
- width: 100,
- height: 20,
- bottom: 20,
- left: 0
- });
const thumb = getByRole('slider');
fireEvent.touchStart(
- container.firstChild,
+ slider,
createTouches([{ identifier: 1, clientX: 22, clientY: 0 }])
);
expect(thumb).toHaveAttribute('aria-valuenow', '20');
thumb.focus();
- fireEvent.keyDown(document.activeElement, {
+ fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'ArrowUp'
});
expect(thumb).toHaveAttribute('aria-valuenow', '30');
- fireEvent.keyDown(document.activeElement, {
+ fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'ArrowDown'
});
expect(thumb).toHaveAttribute('aria-valuenow', '20');
@@ -143,11 +148,10 @@ describe(' ', () => {
disabled
/>
);
+ const slider = container.firstElementChild as HTMLElement;
const thumb = getByRole('slider');
expect(
- window
- .getComputedStyle(container.firstChild, null)
- .getPropertyValue('pointer-events')
+ window.getComputedStyle(slider, null).getPropertyValue('pointer-events')
).toBe('none');
expect(thumb).toHaveAttribute('aria-disabled', 'true');
});
@@ -159,27 +163,27 @@ describe(' ', () => {
const thumb = getByRole('slider');
thumb.focus();
- fireEvent.keyDown(document.activeElement, {
+ fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'Home'
});
expect(thumb).toHaveAttribute('aria-valuenow', '0');
- fireEvent.keyDown(document.activeElement, {
+ fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'End'
});
expect(thumb).toHaveAttribute('aria-valuenow', '100');
- fireEvent.keyDown(document.activeElement, {
+ fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'PageDown'
});
expect(thumb).toHaveAttribute('aria-valuenow', '90');
- fireEvent.keyDown(document.activeElement, {
+ fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'Escape'
});
expect(thumb).toHaveAttribute('aria-valuenow', '90');
- fireEvent.keyDown(document.activeElement, {
+ fireEvent.keyDown(document.activeElement as HTMLElement, {
key: 'PageUp'
});
expect(thumb).toHaveAttribute('aria-valuenow', '100');
@@ -199,10 +203,10 @@ describe(' ', () => {
const thumb = getByRole('slider');
thumb.focus();
- fireEvent.keyDown(document.activeElement, moveRightEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '6');
- fireEvent.keyDown(document.activeElement, moveLeftEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '4');
expect(thumb.style.left).toBe('20%');
@@ -215,19 +219,19 @@ describe(' ', () => {
const thumb = getByRole('slider');
thumb.focus();
- fireEvent.keyDown(document.activeElement, moveRightEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '96');
- fireEvent.keyDown(document.activeElement, moveRightEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '106');
- fireEvent.keyDown(document.activeElement, moveRightEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '108');
- fireEvent.keyDown(document.activeElement, moveLeftEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '96');
- fireEvent.keyDown(document.activeElement, moveLeftEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '86');
});
@@ -238,13 +242,13 @@ describe(' ', () => {
const thumb = getByRole('slider');
thumb.focus();
- fireEvent.keyDown(document.activeElement, moveLeftEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '6');
- fireEvent.keyDown(document.activeElement, moveRightEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '16');
- fireEvent.keyDown(document.activeElement, moveRightEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '26');
});
@@ -255,7 +259,7 @@ describe(' ', () => {
const thumb = getByRole('slider');
thumb.focus();
- fireEvent.keyDown(document.activeElement, moveRightEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '0.3');
});
@@ -271,7 +275,7 @@ describe(' ', () => {
const thumb = getByRole('slider');
thumb.focus();
- fireEvent.keyDown(document.activeElement, moveRightEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '3e-8');
});
@@ -287,7 +291,7 @@ describe(' ', () => {
const thumb = getByRole('slider');
thumb.focus();
- fireEvent.keyDown(document.activeElement, moveLeftEvent);
+ fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent);
expect(thumb).toHaveAttribute('aria-valuenow', '-3e-8');
});
});
@@ -295,7 +299,7 @@ describe(' ', () => {
describe('prop: orientation', () => {
it('when vertical, should render with aria-orientation attribute set to "vertical" ', () => {
const { getByRole } = renderWithTheme(
-
+
);
expect(getByRole('slider')).toHaveAttribute(
@@ -314,16 +318,23 @@ describe(' ', () => {
/>
);
+ const slider = container.firstElementChild as HTMLElement;
+
// mocking containers size
- container.firstChild.getBoundingClientRect = () => ({
- width: 20,
- height: 100,
- bottom: 100,
- left: 0
- });
+ jest
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ .mockImplementation(
+ () =>
+ ({
+ width: 20,
+ height: 100,
+ bottom: 100,
+ left: 0
+ } as DOMRect)
+ );
fireEvent.touchStart(
- container.firstChild,
+ slider,
createTouches([{ identifier: 1, clientX: 0, clientY: 20 }])
);
fireEvent.touchMove(
@@ -332,8 +343,8 @@ describe(' ', () => {
);
expect(handleChange).toHaveBeenCalledTimes(2);
- expect(handleChange.mock.calls[0][1]).toBe(80);
- expect(handleChange.mock.calls[1][1]).toBe(78);
+ expect(handleChange.mock.calls[0][0]).toBe(80);
+ expect(handleChange.mock.calls[1][0]).toBe(78);
});
});
diff --git a/src/Slider/Slider.stories.js b/src/Slider/Slider.stories.tsx
similarity index 88%
rename from src/Slider/Slider.stories.js
rename to src/Slider/Slider.stories.tsx
index 2090e585..ec3c56e5 100644
--- a/src/Slider/Slider.stories.js
+++ b/src/Slider/Slider.stories.tsx
@@ -1,8 +1,8 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
+import { ScrollView, Slider, SliderOnChangeHandler } from 'react95';
import styled from 'styled-components';
-import { Slider, Cutout } from '..';
-
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
padding: 5rem;
@@ -35,12 +35,16 @@ const Wrapper = styled.div`
`;
export default {
- title: 'Slider',
+ 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 (
@@ -66,7 +70,8 @@ export function Default() {
min={0}
max={6}
step={1}
- defaultValue={0}
+ value={state}
+ onChange={onChange}
marks={[
{ value: 0, label: '0°C' },
{ value: 2, label: '2°C' },
@@ -116,7 +121,7 @@ Default.story = {
export function Flat() {
return (
-
+
When you want to add input field on a light background (like scrollable
content), just use the flat variant:
@@ -146,7 +151,7 @@ export function Flat() {
{ value: 6, label: '6°C' }
]}
/>
-
+
);
}
diff --git a/src/Slider/Slider.tsx b/src/Slider/Slider.tsx
new file mode 100644
index 00000000..617b682a
--- /dev/null
+++ b/src/Slider/Slider.tsx
@@ -0,0 +1,615 @@
+// 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 };
diff --git a/src/TabBody/TabBody.js b/src/TabBody/TabBody.js
deleted file mode 100644
index caa87bf6..00000000
--- a/src/TabBody/TabBody.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled from 'styled-components';
-import { createBorderStyles, createBoxStyles } from '../common';
-
-const StyledTabBody = styled.div`
- ${createBoxStyles()}
- ${createBorderStyles()}
- position: relative;
- display: block;
- height: 100%;
- padding: 16px;
- font-size: 1rem;
-`;
-const TabBody = React.forwardRef(function TabBody(props, ref) {
- const { children, ...otherProps } = props;
- return (
-
- {children}
-
- );
-});
-
-TabBody.defaultProps = {
- children: null
-};
-
-TabBody.propTypes = {
- children: propTypes.node
-};
-export default TabBody;
diff --git a/src/Table/Table.js b/src/Table/Table.js
deleted file mode 100644
index 18430a8c..00000000
--- a/src/Table/Table.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled from 'styled-components';
-import { StyledCutout } from '../Cutout/Cutout';
-
-const StyledTable = styled.table`
- display: table;
- width: 100%;
- border-collapse: collapse;
- border-spacing: 0;
- font-size: 1rem;
-`;
-
-const Wrapper = styled(StyledCutout)`
- &:before {
- box-shadow: none;
- }
-`;
-
-const Table = React.forwardRef(function Table(props, ref) {
- const { children, ...otherProps } = props;
-
- return (
-
-
- {children}
-
-
- );
-});
-
-Table.defaultProps = {
- children: null
-};
-
-Table.propTypes = {
- children: propTypes.node
-};
-
-export default Table;
diff --git a/src/Table/Table.mdx b/src/Table/Table.mdx
deleted file mode 100644
index c66413ec..00000000
--- a/src/Table/Table.mdx
+++ /dev/null
@@ -1,76 +0,0 @@
----
-name: Table
-menu: Components
----
-
-import Table from './Table'
-import TableBody from '../TableBody/TableBody'
-import TableHead from '../TableHead/TableHead'
-import TableRow from '../TableRow/TableRow'
-import TableHeadCell from '../TableHeadCell/TableHeadCell'
-import TableDataCell from '../TableDataCell/TableDataCell'
-import Window from '../Window/Window'
-import WindowHeader from '../WindowHeader/WindowHeader'
-import WindowContent from '../WindowContent/WindowContent'
-
-# Table
-
-## Usage
-
-
-
- Pokedex.exe
-
-
-
-
- Type
- Name
- Level
-
-
-
-
-
-
- 🌿
-
-
- Bulbasaur
- 64
-
-
-
-
- 🔥
-
-
- Charizard
- 209
-
-
-
-
- ⚡
-
-
- Pikachu
- 82
-
-
-
-
-
-
-
-## API
-
-### Import
-
-```
-import { Table } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Table/Table.spec.js b/src/Table/Table.spec.tsx
similarity index 82%
rename from src/Table/Table.spec.js
rename to src/Table/Table.spec.tsx
index de6dcdf9..7552c5be 100644
--- a/src/Table/Table.spec.js
+++ b/src/Table/Table.spec.tsx
@@ -2,14 +2,14 @@ import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import Table from './Table';
+import { Table } from './Table';
describe('', () => {
it('renders Table', () => {
const { container } = renderWithTheme();
- const list = container.firstChild;
+ const table = container.firstElementChild;
- expect(list).toBeInTheDocument();
+ expect(table).toBeInTheDocument();
});
it('renders table element', () => {
const { getByRole } = renderWithTheme();
diff --git a/src/Table/Table.stories.js b/src/Table/Table.stories.tsx
similarity index 92%
rename from src/Table/Table.stories.js
rename to src/Table/Table.stories.tsx
index b79c9d2e..0b524b50 100644
--- a/src/Table/Table.stories.js
+++ b/src/Table/Table.stories.tsx
@@ -1,20 +1,25 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
-import styled from 'styled-components';
-
import {
Table,
TableBody,
+ TableDataCell,
TableHead,
- TableRow,
TableHeadCell,
- TableDataCell,
+ TableRow,
Window,
- WindowHeader,
- WindowContent
+ WindowContent,
+ WindowHeader
} from 'react95';
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ padding: 5rem;
+ background: ${({ theme }) => theme.desktopBackground};
+`;
export default {
- title: 'Table',
+ title: 'Controls/Table',
component: Table,
subcomponents: {
Table,
@@ -25,11 +30,7 @@ export default {
TableDataCell
},
decorators: [story => {story()} ]
-};
-const Wrapper = styled.div`
- padding: 5rem;
- background: ${({ theme }) => theme.desktopBackground};
-`;
+} as ComponentMeta;
export function Default() {
return (
@@ -38,7 +39,7 @@ export function Default() {
-
+
Type
Name
Level
diff --git a/src/Table/Table.tsx b/src/Table/Table.tsx
new file mode 100644
index 00000000..9f82c79b
--- /dev/null
+++ b/src/Table/Table.tsx
@@ -0,0 +1,45 @@
+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 };
diff --git a/src/TableBody/TableBody.spec.js b/src/Table/TableBody.spec.tsx
similarity index 74%
rename from src/TableBody/TableBody.spec.js
rename to src/Table/TableBody.spec.tsx
index 10c424f0..53e67289 100644
--- a/src/TableBody/TableBody.spec.js
+++ b/src/Table/TableBody.spec.tsx
@@ -2,13 +2,13 @@ import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import TableBody from './TableBody';
+import { TableBody } from './TableBody';
describe(' ', () => {
- function mountInTable(node) {
+ function mountInTable(node: React.ReactNode) {
const { container, getByTestId } = renderWithTheme();
return {
- tbody: container.querySelector('table').firstChild,
+ tbody: container.querySelector('table')?.firstElementChild,
getByTestId
};
}
@@ -17,7 +17,7 @@ describe(' ', () => {
const { tbody } = mountInTable( );
expect(tbody).toBeInTheDocument();
- expect(tbody.tagName).toBe('TBODY');
+ expect(tbody?.tagName).toBe('TBODY');
});
it('renders children', () => {
diff --git a/src/Table/TableBody.tsx b/src/Table/TableBody.tsx
new file mode 100644
index 00000000..542de3a0
--- /dev/null
+++ b/src/Table/TableBody.tsx
@@ -0,0 +1,30 @@
+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 };
diff --git a/src/TableDataCell/TableDataCell.spec.js b/src/Table/TableDataCell.spec.tsx
similarity index 75%
rename from src/TableDataCell/TableDataCell.spec.js
rename to src/Table/TableDataCell.spec.tsx
index fdd3ff19..df23704b 100644
--- a/src/TableDataCell/TableDataCell.spec.js
+++ b/src/Table/TableDataCell.spec.tsx
@@ -2,10 +2,10 @@ import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import TableDataCell from './TableDataCell';
+import { TableDataCell } from './TableDataCell';
describe(' ', () => {
- function mountInTable(node) {
+ function mountInTable(node: React.ReactNode) {
const { container, getByText } = renderWithTheme(
@@ -14,14 +14,14 @@ describe(' ', () => {
);
return {
- td: container.querySelector('tr').firstChild,
+ td: container.querySelector('tr')?.firstElementChild,
getByText
};
}
it('renders TableDataCell', () => {
const { td } = mountInTable( );
- expect(td.tagName).toBe('TD');
+ expect(td?.tagName).toBe('TD');
});
it('renders children', () => {
diff --git a/src/Table/TableDataCell.tsx b/src/Table/TableDataCell.tsx
new file mode 100644
index 00000000..f97f8b76
--- /dev/null
+++ b/src/Table/TableDataCell.tsx
@@ -0,0 +1,26 @@
+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 };
diff --git a/src/TableHead/TableHead.spec.js b/src/Table/TableHead.spec.tsx
similarity index 74%
rename from src/TableHead/TableHead.spec.js
rename to src/Table/TableHead.spec.tsx
index f1e12827..a3b752d9 100644
--- a/src/TableHead/TableHead.spec.js
+++ b/src/Table/TableHead.spec.tsx
@@ -2,13 +2,13 @@ import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import TableHead from './TableHead';
+import { TableHead } from './TableHead';
describe(' ', () => {
- function mountInTable(node) {
+ function mountInTable(node: React.ReactNode) {
const { container, getByTestId } = renderWithTheme();
return {
- tbody: container.querySelector('table').firstChild,
+ tbody: container.querySelector('table')?.firstElementChild,
getByTestId
};
}
@@ -17,7 +17,7 @@ describe(' ', () => {
const { tbody } = mountInTable( );
expect(tbody).toBeInTheDocument();
- expect(tbody.tagName).toBe('THEAD');
+ expect(tbody?.tagName).toBe('THEAD');
});
it('renders children', () => {
diff --git a/src/Table/TableHead.tsx b/src/Table/TableHead.tsx
new file mode 100644
index 00000000..368c4da5
--- /dev/null
+++ b/src/Table/TableHead.tsx
@@ -0,0 +1,25 @@
+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 };
diff --git a/src/TableHeadCell/TableHeadCell.spec.js b/src/Table/TableHeadCell.spec.tsx
similarity index 87%
rename from src/TableHeadCell/TableHeadCell.spec.js
rename to src/Table/TableHeadCell.spec.tsx
index 6406d206..b159e7fe 100644
--- a/src/TableHeadCell/TableHeadCell.spec.js
+++ b/src/Table/TableHeadCell.spec.tsx
@@ -2,10 +2,10 @@ import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import TableHeadCell from './TableHeadCell';
+import { TableHeadCell } from './TableHeadCell';
describe(' ', () => {
- function mountInTable(node) {
+ function mountInTable(node: React.ReactNode) {
const { container, getByText } = renderWithTheme(
@@ -14,14 +14,14 @@ describe(' ', () => {
);
return {
- th: container.querySelector('tr').firstChild,
+ th: container.querySelector('tr')?.firstElementChild as HTMLElement,
getByText
};
}
it('renders TableHeadCell', () => {
const { th } = mountInTable( );
- expect(th.tagName).toBe('TH');
+ expect(th?.tagName).toBe('TH');
});
it('renders children', () => {
@@ -55,7 +55,7 @@ describe(' ', () => {
);
expect(th).toHaveAttribute('aria-disabled', 'true');
- th.click();
+ th?.click?.();
expect(handleChange).not.toHaveBeenCalled();
});
});
diff --git a/src/Table/TableHeadCell.tsx b/src/Table/TableHeadCell.tsx
new file mode 100644
index 00000000..e653d6b6
--- /dev/null
+++ b/src/Table/TableHeadCell.tsx
@@ -0,0 +1,93 @@
+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 };
diff --git a/src/TableRow/TableRow.spec.js b/src/Table/TableRow.spec.tsx
similarity index 75%
rename from src/TableRow/TableRow.spec.js
rename to src/Table/TableRow.spec.tsx
index 22ccb239..a870e863 100644
--- a/src/TableRow/TableRow.spec.js
+++ b/src/Table/TableRow.spec.tsx
@@ -1,25 +1,24 @@
import React from 'react';
-
import { renderWithTheme } from '../../test/utils';
-import TableRow from './TableRow';
+import { TableRow } from './TableRow';
describe(' ', () => {
- function mountInTable(node) {
+ function mountInTable(node: React.ReactNode) {
const { container, getByTestId } = renderWithTheme(
);
return {
- tr: container.querySelector('tbody').firstChild,
+ tr: container.querySelector('tbody')?.firstElementChild,
getByTestId
};
}
it('renders TableRow', () => {
const { tr } = mountInTable( );
- expect(tr.tagName).toBe('TR');
+ expect(tr?.tagName).toBe('TR');
});
it('renders children', () => {
diff --git a/src/TableRow/TableRow.js b/src/Table/TableRow.tsx
similarity index 51%
rename from src/TableRow/TableRow.js
rename to src/Table/TableRow.tsx
index 479c00e0..b601c093 100644
--- a/src/TableRow/TableRow.js
+++ b/src/Table/TableRow.tsx
@@ -1,9 +1,11 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
+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;
@@ -19,21 +21,16 @@ const StyledTr = styled.tr`
}
`;
-const TableRow = React.forwardRef(function TableRow(props, ref) {
- const { children, ...otherProps } = props;
- return (
-
- {children}
-
- );
-});
-
-TableRow.defaultProps = {
- children: null
-};
+const TableRow = forwardRef(
+ function TableRow({ children, ...otherProps }, ref) {
+ return (
+
+ {children}
+
+ );
+ }
+);
-TableRow.propTypes = {
- children: propTypes.node
-};
+TableRow.displayName = 'TableRow';
-export default TableRow;
+export { TableRow, TableRowProps };
diff --git a/src/TableBody/TableBody.js b/src/TableBody/TableBody.js
deleted file mode 100644
index 6307f206..00000000
--- a/src/TableBody/TableBody.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled from 'styled-components';
-import { insetShadow } from '../common';
-
-const StyledTableBody = styled.tbody`
- background: ${({ theme }) => theme.canvas};
- display: table-row-group;
- box-shadow: ${insetShadow};
- overflow-y: auto;
-`;
-
-const TableBody = React.forwardRef(function TableBody(props, ref) {
- const { children, ...otherProps } = props;
-
- return (
-
- {children}
-
- );
-});
-
-TableBody.defaultProps = {
- children: null
-};
-
-TableBody.propTypes = {
- children: propTypes.node
-};
-
-export default TableBody;
diff --git a/src/TableDataCell/TableDataCell.js b/src/TableDataCell/TableDataCell.js
deleted file mode 100644
index 8c6c9745..00000000
--- a/src/TableDataCell/TableDataCell.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled from 'styled-components';
-
-const StyledTd = styled.td`
- padding: 0 8px;
-`;
-const TableDataCell = React.forwardRef(function TableDataCell(props, ref) {
- const { children, ...otherProps } = props;
- return (
-
- {children}
-
- );
-});
-
-TableDataCell.defaultProps = {
- children: null
-};
-
-TableDataCell.propTypes = {
- children: propTypes.node
-};
-
-export default TableDataCell;
diff --git a/src/TableHead/TableHead.js b/src/TableHead/TableHead.js
deleted file mode 100644
index d1184ae2..00000000
--- a/src/TableHead/TableHead.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled from 'styled-components';
-
-const StyledTableHead = styled.thead`
- display: table-header-group;
-`;
-const TableHead = React.forwardRef(function TableHead(props, ref) {
- const { children, ...otherProps } = props;
- return (
-
- {children}
-
- );
-});
-
-TableHead.defaultProps = {
- children: null
-};
-
-TableHead.propTypes = {
- children: propTypes.node
-};
-
-export default TableHead;
diff --git a/src/TableHeadCell/TableHeadCell.js b/src/TableHeadCell/TableHeadCell.js
deleted file mode 100644
index 213b66d5..00000000
--- a/src/TableHeadCell/TableHeadCell.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled, { css } from 'styled-components';
-import { createBorderStyles, createDisabledTextStyles } from '../common';
-import { noOp } from '../common/utils';
-
-const StyledHeadCell = styled.th`
- 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;
- }
- ${({ isDisabled }) =>
- !isDisabled &&
- css`
- &:active {
- &:before {
- ${createBorderStyles({ invert: true, windowBorders: true })}
- border-left: none;
- border-top: none;
- padding-top: 2px;
- }
-
- & > div {
- position: relative;
- top: 2px;
- }
- }
- `}
-
- color: ${({ theme }) => theme.materialText};
- ${({ isDisabled }) => isDisabled && createDisabledTextStyles()}
- &:hover {
- color: ${({ theme }) => theme.materialText};
- ${({ isDisabled }) => isDisabled && createDisabledTextStyles()}
- }
-`;
-
-const TableHeadCell = React.forwardRef(function TableHeadCell(props, ref) {
- const { disabled, children, onClick, sort, ...otherProps } = props;
- let sortDirection = null;
- if (sort) {
- sortDirection = sort === 'asc' ? 'ascending' : 'descending';
- }
- return (
-
- {children}
-
- );
-});
-
-TableHeadCell.defaultProps = {
- children: null,
- disabled: false,
- onClick: null,
- // onTouchStart below to enable :active style on iOS
- onTouchStart: noOp,
- sort: null
-};
-
-TableHeadCell.propTypes = {
- children: propTypes.node,
- disabled: propTypes.bool,
- onClick: propTypes.func,
- onTouchStart: propTypes.func,
- sort: propTypes.oneOf(['asc', 'desc', null])
-};
-
-export default TableHeadCell;
diff --git a/src/Tab/Tab.spec.js b/src/Tabs/Tab.spec.tsx
similarity index 96%
rename from src/Tab/Tab.spec.js
rename to src/Tabs/Tab.spec.tsx
index e66bfc34..0b2fb15c 100644
--- a/src/Tab/Tab.spec.js
+++ b/src/Tabs/Tab.spec.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import Tab from './Tab';
+import { Tab } from './Tab';
describe(' ', () => {
describe('prop: children', () => {
diff --git a/src/Tab/Tab.js b/src/Tabs/Tab.tsx
similarity index 55%
rename from src/Tab/Tab.js
rename to src/Tabs/Tab.tsx
index 445ee13c..6977e586 100644
--- a/src/Tab/Tab.js
+++ b/src/Tabs/Tab.tsx
@@ -1,11 +1,21 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
+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`
+const StyledTab = styled.button`
${createBoxStyles()}
${createBorderStyles()}
position: relative;
@@ -59,34 +69,25 @@ const StyledTab = styled.button`
}
`;
-const Tab = React.forwardRef(function Tab(props, ref) {
- const { value, onClick, selected, children, ...otherProps } = props;
-
- return (
- onClick(e, value)}
- role='tab'
- ref={ref}
- {...otherProps}
- >
- {children}
-
- );
-});
+const Tab = forwardRef(
+ ({ value, onClick, selected = false, children, ...otherProps }, ref) => {
+ return (
+ ) =>
+ onClick?.(value, e)
+ }
+ ref={ref}
+ role='tab'
+ {...otherProps}
+ >
+ {children}
+
+ );
+ }
+);
-Tab.defaultProps = {
- onClick: () => {},
- selected: false,
- children: null
-};
+Tab.displayName = 'Tab';
-Tab.propTypes = {
- // eslint-disable-next-line react/require-default-props, react/forbid-prop-types
- value: propTypes.any,
- onClick: propTypes.func,
- selected: propTypes.bool,
- children: propTypes.node
-};
-export default Tab;
+export { Tab, TabProps };
diff --git a/src/TabBody/TabBody.spec.js b/src/Tabs/TabBody.spec.tsx
similarity index 90%
rename from src/TabBody/TabBody.spec.js
rename to src/Tabs/TabBody.spec.tsx
index 523b0dd7..9515b1b0 100644
--- a/src/TabBody/TabBody.spec.js
+++ b/src/Tabs/TabBody.spec.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { renderWithTheme } from '../../test/utils';
-import TabBody from './TabBody';
+import { TabBody } from './TabBody';
describe(' ', () => {
describe('prop: children', () => {
diff --git a/src/Tabs/TabBody.tsx b/src/Tabs/TabBody.tsx
new file mode 100644
index 00000000..1afb4f54
--- /dev/null
+++ b/src/Tabs/TabBody.tsx
@@ -0,0 +1,33 @@
+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 };
diff --git a/src/Tabs/Tabs.js b/src/Tabs/Tabs.js
deleted file mode 100644
index bae5fc15..00000000
--- a/src/Tabs/Tabs.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled from 'styled-components';
-
-const StyledTabs = styled.div`
- 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, parts) {
- const result = [];
- for (let i = parts; i > 0; i -= 1) {
- result.push(array.splice(0, Math.ceil(array.length / i)));
- }
- return result;
-}
-
-const Tabs = React.forwardRef(function Tabs(props, ref) {
- const { value, onChange, children, rows, ...otherProps } = props;
-
- 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);
- });
-
- // split tabs into equal rows and assign key to each row
- 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 (
- 1} role='tablist' ref={ref}>
- {tabRows.map(row => (
- {row.tabs}
- ))}
-
- );
-});
-
-Tabs.defaultProps = {
- onChange: () => {},
- children: null,
- rows: 1
-};
-
-Tabs.propTypes = {
- // eslint-disable-next-line react/require-default-props, react/forbid-prop-types
- value: propTypes.any,
- onChange: propTypes.func,
- children: propTypes.node,
- rows: propTypes.number
-};
-export default Tabs;
diff --git a/src/Tabs/Tabs.mdx b/src/Tabs/Tabs.mdx
deleted file mode 100644
index 9df9a189..00000000
--- a/src/Tabs/Tabs.mdx
+++ /dev/null
@@ -1,106 +0,0 @@
----
-name: Tabs
-menu: Components
----
-
-import Tabs from './Tabs'
-import Tab from '../Tab/Tab'
-import TabBody from '../TabBody/TabBody'
-import Fieldset from '../Fieldset/Fieldset'
-import Window from '../Window/Window'
-import WindowHeader from '../WindowHeader/WindowHeader'
-import NumberField from '../NumberField/NumberField'
-import Checkbox from '../Checkbox/Checkbox'
-import WindowContent from '../WindowContent/WindowContent'
-
-# Tabs
-
-## Usage
-
-
- {() => {
- const [activeTab, setActiveTab] = React.useState(0)
- const handleChange = value => {
- setActiveTab(value)
- }
- const a11yProps = (index) => ({
- id: `tab-${index}`,
- 'aria-controls': `tabpanel-${index}`
- });
- const TabPanel = ({ children, value, activeTab, ...other }) => {
- return (
-
- {value === activeTab &&
{children}
}
-
- );
- };
- return (
-
-
-
- 👗
-
- store.exe
-
-
-
-
- Shoes
-
-
- Accesories
-
-
- Clothing
-
-
-
-
-
- Amount:
- null}
- />
- null}
- defaultChecked
- />
-
-
-
- Accesories stuff here
-
-
- Clothing stuff here
-
-
-
-
- );
- }}
-
-
-## API
-
-### Import
-
-```
-import { Tabs } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Tabs/Tabs.spec.js b/src/Tabs/Tabs.spec.tsx
similarity index 95%
rename from src/Tabs/Tabs.spec.js
rename to src/Tabs/Tabs.spec.tsx
index 716d0cff..bdca5928 100644
--- a/src/Tabs/Tabs.spec.js
+++ b/src/Tabs/Tabs.spec.tsx
@@ -1,9 +1,9 @@
-import React from 'react';
import { fireEvent } from '@testing-library/react';
+import React from 'react';
-import { renderWithTheme } from '../../test/utils';
import { Tab } from '..';
-import Tabs from './Tabs';
+import { renderWithTheme } from '../../test/utils';
+import { Tabs } from './Tabs';
describe(' ', () => {
describe('prop: children', () => {
@@ -61,7 +61,7 @@ describe(' ', () => {
fireEvent.click(getAllByRole('tab')[1]);
expect(handleChange).toBeCalledTimes(1);
- expect(handleChange.mock.calls[0][1]).toBe(1);
+ expect(handleChange.mock.calls[0][0]).toBe(1);
});
});
@@ -112,7 +112,7 @@ describe(' ', () => {
const rowElements = getAllByTestId('tab-row');
const selectedTab = container.querySelector('[aria-selected=true]');
- expect(rowElements.pop().contains(selectedTab)).toBe(true);
+ expect(rowElements?.pop()?.contains(selectedTab)).toBe(true);
});
});
});
diff --git a/src/Tabs/Tabs.stories.js b/src/Tabs/Tabs.stories.tsx
similarity index 84%
rename from src/Tabs/Tabs.stories.js
rename to src/Tabs/Tabs.stories.tsx
index bf52be01..147139b8 100644
--- a/src/Tabs/Tabs.stories.js
+++ b/src/Tabs/Tabs.stories.tsx
@@ -1,35 +1,43 @@
+import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
-import styled from 'styled-components';
-
import {
- Tabs,
+ Anchor,
+ Checkbox,
+ GroupBox,
+ NumberInput,
Tab,
TabBody,
+ Tabs,
Window,
- WindowHeader,
WindowContent,
- Fieldset,
- NumberField,
- Checkbox,
- Anchor
+ WindowHeader
} from 'react95';
+import styled from 'styled-components';
-export default {
- title: 'Tabs',
- component: Tabs,
- subcomponents: { Tab, TabBody },
- decorators: [story => {story()} ]
-};
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 = (e, value) => setState({ activeTab: value });
+ const handleChange = (
+ value: number,
+ event: React.MouseEvent
+ ) => {
+ console.log({ value, event });
+ setState({ activeTab: value });
+ };
const { activeTab } = state;
return (
@@ -44,9 +52,9 @@ export function Default() {
{activeTab === 0 && (
-
+
Amount:
-
+
null}
defaultChecked
/>
-
+
)}
{activeTab === 1 && (
@@ -83,7 +91,7 @@ export function MultiRow() {
activeTab: 'Shoes'
});
- const handleChange = (e, value) => setState({ activeTab: value });
+ const handleChange = (value: string) => setState({ activeTab: value });
const { activeTab } = state;
return (
diff --git a/src/Tabs/Tabs.tsx b/src/Tabs/Tabs.tsx
new file mode 100644
index 00000000..b0c95582
--- /dev/null
+++ b/src/Tabs/Tabs.tsx
@@ -0,0 +1,110 @@
+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 };
diff --git a/src/TextField/TextField.js b/src/TextField/TextField.js
deleted file mode 100644
index f7250457..00000000
--- a/src/TextField/TextField.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-
-import styled, { css } from 'styled-components';
-import {
- createDisabledTextStyles,
- createFlatBoxStyles,
- createScrollbars
-} from '../common';
-import { blockSizes } from '../common/system';
-import { StyledCutout } from '../Cutout/Cutout';
-
-const sharedWrapperStyles = css`
- display: flex;
- align-items: center;
- width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
- min-height: ${blockSizes.md};
-`;
-
-const Wrapper = styled(StyledCutout).attrs({
- 'data-testid': 'variant-default'
-})`
- ${sharedWrapperStyles}
- background: ${({ theme, isDisabled }) =>
- isDisabled ? theme.material : theme.canvas};
-`;
-
-const FlatWrapper = styled.div.attrs({
- 'data-testid': 'variant-flat'
-})`
- ${createFlatBoxStyles()}
- ${sharedWrapperStyles}
- position: relative;
-`;
-
-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 TextField = React.forwardRef(function TextField(props, ref) {
- const {
- className,
- disabled,
- fullWidth,
- multiline,
- onChange,
- shadow,
- style,
- type,
- variant,
- ...otherProps
- } = props;
- const WrapperComponent = variant === 'flat' ? FlatWrapper : Wrapper;
- const Input = multiline ? StyledTextArea : StyledTextInput;
- return (
-
-
-
- );
-});
-TextField.defaultProps = {
- className: '',
- disabled: false,
- fullWidth: null,
- multiline: false,
- onChange: () => {},
- shadow: true,
- style: {},
- type: 'text',
- variant: 'default'
-};
-
-TextField.propTypes = {
- className: propTypes.string,
- disabled: propTypes.bool,
- fullWidth: propTypes.bool,
- multiline: propTypes.bool,
- onChange: propTypes.func,
- shadow: propTypes.bool,
- style: propTypes.object,
- type: propTypes.string,
- variant: propTypes.oneOf(['default', 'flat'])
-};
-export default TextField;
diff --git a/src/TextField/TextField.mdx b/src/TextField/TextField.mdx
deleted file mode 100644
index fedc0119..00000000
--- a/src/TextField/TextField.mdx
+++ /dev/null
@@ -1,112 +0,0 @@
----
-name: TextField
-menu: Components
----
-
-import TextField from './TextField';
-import Cutout from '../Cutout/Cutout';
-
-# TextField
-
-## Usage
-
-#### Default
-
-
- console.log(e.target.value)} />
-
-
-#### No shadow
-
-
- console.log(e.target.value)}
- />
-
-
-#### Disabled
-
-
- console.log(e.target.value)}
- disabled
- />
-
-
-#### Custom width
-
-
- console.log(e.target.value)}
- />
-
-
-#### Flat
-
-
-
-
- When you want to add input field on a light background (like scrollable
- content), just use the flat variant:
-
-
-
- Name:
-
- console.log(e.target.value)}
- />
-
-
-
-
-#### Flat disabled
-
-
-
-
- When you want to add input field on a light background (like scrollable
- content), just use the flat variant:
-
-
-
- Name:
-
- console.log(e.target.value)}
- disabled
- />
-
-
-
-
-## API
-
-### Import
-
-```
-import { TextField } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/TextField/TextField.spec.js b/src/TextInput/TextInput.spec.tsx
similarity index 72%
rename from src/TextField/TextField.spec.js
rename to src/TextInput/TextInput.spec.tsx
index 68d43346..ad647f81 100644
--- a/src/TextField/TextField.spec.js
+++ b/src/TextInput/TextInput.spec.tsx
@@ -4,11 +4,11 @@ import React from 'react';
import { fireEvent } from '@testing-library/react';
import { renderWithTheme } from '../../test/utils';
-import TextField from './TextField';
+import { TextInput } from './TextInput';
-describe(' ', () => {
+describe(' ', () => {
it('should render an inside the div', () => {
- const { container } = renderWithTheme( );
+ const { container } = renderWithTheme( );
const input = container.querySelector('input');
expect(input).toHaveAttribute('type', 'text');
expect(input).not.toHaveAttribute('required');
@@ -21,7 +21,7 @@ describe(' ', () => {
const handleKeyUp = jest.fn();
const handleKeyDown = jest.fn();
const { getByRole } = renderWithTheme(
- ', () => {
input.focus();
expect(handleFocus).toHaveBeenCalledTimes(1);
- fireEvent.keyDown(document.activeElement, { key: 'a' });
+ 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, { key: 'a' });
+ fireEvent.keyUp(document.activeElement as HTMLInputElement, { key: 'a' });
expect(handleKeyUp).toHaveBeenCalledTimes(1);
input.blur();
@@ -50,7 +50,7 @@ describe(' ', () => {
});
it('should considered [] as controlled', () => {
- const { getByRole } = renderWithTheme( );
+ const { getByRole } = renderWithTheme( );
const input = getByRole('textbox');
expect(input).toHaveProperty('value', '');
@@ -59,21 +59,21 @@ describe(' ', () => {
});
it('should forwardRef to native input', () => {
- const inputRef = React.createRef();
- const { getByRole } = renderWithTheme( );
+ 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 { container } = renderWithTheme( );
const textarea = container.querySelector('textarea');
expect(textarea).not.toBe(null);
});
it('should forward rows prop', () => {
- const { container } = renderWithTheme( );
+ const { container } = renderWithTheme( );
const textarea = container.querySelector('textarea');
expect(textarea).toHaveAttribute('rows', '3');
});
@@ -81,13 +81,13 @@ describe(' ', () => {
describe('prop: disabled', () => {
it('should render a disabled ', () => {
- const { container } = renderWithTheme( );
+ const { container } = renderWithTheme( );
const input = container.querySelector('input');
expect(input).toHaveAttribute('disabled');
});
it('should be overridden by props', () => {
- const { getByRole, rerender } = renderWithTheme( );
- rerender( );
+ const { getByRole, rerender } = renderWithTheme( );
+ rerender( );
const input = getByRole('textbox');
expect(input).not.toHaveAttribute('disabled');
});
@@ -95,19 +95,21 @@ describe(' ', () => {
describe('prop: variant', () => {
it('should be "default" by default', () => {
- const { getByTestId } = renderWithTheme( );
+ const { getByTestId } = renderWithTheme( );
expect(getByTestId('variant-default')).toBeInTheDocument();
});
it('should handle "flat" variant', () => {
- const { getByTestId } = renderWithTheme( );
+ 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).width).toBe('100%');
+ const { container } = renderWithTheme( );
+ expect(
+ window.getComputedStyle(container.firstChild as HTMLInputElement).width
+ ).toBe('100%');
});
});
});
diff --git a/src/TextField/TextField.stories.js b/src/TextInput/TextInput.stories.tsx
similarity index 78%
rename from src/TextField/TextField.stories.js
rename to src/TextInput/TextInput.stories.tsx
index 2a06b4d9..c7638eb1 100644
--- a/src/TextField/TextField.stories.js
+++ b/src/TextInput/TextInput.stories.tsx
@@ -1,11 +1,12 @@
+import { ComponentMeta } from '@storybook/react';
import React, { useState } from 'react';
-
+import { Button, ScrollView, TextInput } from 'react95';
import styled from 'styled-components';
-import { TextField, Button, Cutout } from 'react95';
-
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 => console.log(e.target.value);
+const onChange = (
+ e: React.ChangeEvent
+) => console.log(e.target.value);
const Wrapper = styled.div`
background: ${({ theme }) => theme.material};
@@ -18,23 +19,24 @@ const Wrapper = styled.div`
`;
export default {
- title: 'TextField',
- component: TextField,
+ title: 'Controls/TextInput',
+ component: TextInput,
decorators: [story => {story()} ]
-};
+} as ComponentMeta;
export function Default() {
const [state, setState] = useState({
value: ''
});
- const handleChange = e => setState({ value: e.target.value });
+ const handleChange = (e: React.ChangeEvent) =>
+ setState({ value: e.target.value });
const reset = () => setState({ value: '' });
return (
-
-
+
-
+
-
+
When you want to add input field on a light background (like scrollable
content), just use the flat variant:
-
-
-
-
-
+
);
}
diff --git a/src/TextInput/TextInput.tsx b/src/TextInput/TextInput.tsx
new file mode 100644
index 00000000..11f92999
--- /dev/null
+++ b/src/TextInput/TextInput.tsx
@@ -0,0 +1,158 @@
+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 };
diff --git a/src/Toolbar/Toolbar.js b/src/Toolbar/Toolbar.js
deleted file mode 100644
index b9099f2a..00000000
--- a/src/Toolbar/Toolbar.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import propTypes from 'prop-types';
-import styled from 'styled-components';
-
-const StyledToolbar = styled.div`
- position: relative;
- display: flex;
- align-items: center;
- padding: ${props => (props.noPadding ? '0' : '4px')};
-`;
-
-const Toolbar = React.forwardRef(function Toolbar(props, ref) {
- const { children, noPadding, ...otherProps } = props;
- return (
-
- {children}
-
- );
-});
-
-Toolbar.defaultProps = {
- children: null,
- noPadding: false
-};
-
-Toolbar.propTypes = {
- children: propTypes.node,
- noPadding: propTypes.bool
-};
-
-export default Toolbar;
diff --git a/src/Toolbar/Toolbar.spec.js b/src/Toolbar/Toolbar.spec.tsx
similarity index 95%
rename from src/Toolbar/Toolbar.spec.js
rename to src/Toolbar/Toolbar.spec.tsx
index 2bc2f68d..dfe060a4 100644
--- a/src/Toolbar/Toolbar.spec.js
+++ b/src/Toolbar/Toolbar.spec.tsx
@@ -1,7 +1,7 @@
-import React from 'react';
import { render } from '@testing-library/react';
+import React from 'react';
-import Toolbar from './Toolbar';
+import { Toolbar } from './Toolbar';
describe(' ', () => {
it('should render', () => {
diff --git a/src/Toolbar/Toolbar.tsx b/src/Toolbar/Toolbar.tsx
new file mode 100644
index 00000000..032897cd
--- /dev/null
+++ b/src/Toolbar/Toolbar.tsx
@@ -0,0 +1,29 @@
+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 };
diff --git a/src/Tooltip/Tooltip.js b/src/Tooltip/Tooltip.js
deleted file mode 100644
index 3a190c3c..00000000
--- a/src/Tooltip/Tooltip.js
+++ /dev/null
@@ -1,191 +0,0 @@
-import React, { useState } from 'react';
-import propTypes from 'prop-types';
-import styled from 'styled-components';
-
-import { shadow } from '../common';
-
-const positioningStyles = {
- top: `top: -4px;
- left: 50%;
- transform: translate(-50%, -100%);`,
- bottom: `bottom: -4px;
- left: 50%;
- transform: translate(-50%, 100%);`,
- left: `left: -4px;
- top: 50%;
- transform: translate(-100%, -50%);`,
- right: `right: -4px;
- top: 50%;
- transform: translate(100%, -50%);`
-};
-
-const Tip = styled.span`
- position: absolute;
-
- z-index: 1;
- display: ${props => (props.show ? 'block' : 'none')};
- padding: 4px;
- border: 2px solid ${({ theme }) => theme.borderDarkest};
- background: ${({ theme }) => theme.tooltip};
- box-shadow: ${shadow};
- text-align: center;
- font-size: 1rem;
- ${props => positioningStyles[props.position]}
-`;
-
-const Wrapper = styled.div`
- position: relative;
- display: inline-block;
- white-space: nowrap;
-`;
-
-const Tooltip = React.forwardRef(function Tooltip(props, ref) {
- const {
- children,
- className,
- disableFocusListener,
- disableMouseListener,
- enterDelay,
- leaveDelay,
- onBlur,
- onClose,
- onFocus,
- onMouseEnter,
- onMouseLeave,
- onOpen,
- style,
- text,
- ...otherProps
- } = props;
-
- const [show, setShow] = useState(false);
- const [openTimer, setOpenTimer] = useState(null);
- const [closeTimer, setCloseTimer] = useState(null);
-
- const isUsingFocus = !disableFocusListener;
- const isUsingMouse = !disableMouseListener;
-
- const handleOpen = evt => {
- clearTimeout(openTimer);
- clearTimeout(closeTimer);
-
- const timer = setTimeout(() => {
- setShow(true);
-
- if (onOpen) {
- onOpen(evt);
- }
- }, enterDelay);
-
- setOpenTimer(timer);
- };
-
- const handleEnter = evt => {
- evt.persist();
-
- if (evt.type === 'focus' && onFocus) {
- onFocus(evt);
- } else if (evt.type === 'mouseenter' && onMouseEnter) {
- onMouseEnter(evt);
- }
-
- handleOpen(evt);
- };
-
- const handleClose = evt => {
- clearTimeout(openTimer);
- clearTimeout(closeTimer);
-
- const timer = setTimeout(() => {
- setShow(false);
-
- if (onClose) {
- onClose(evt);
- }
- }, leaveDelay);
-
- setCloseTimer(timer);
- };
-
- const handleLeave = evt => {
- evt.persist();
-
- if (evt.type === 'blur' && onBlur) {
- onBlur(evt);
- } else if (evt.type === 'mouseleave' && onMouseLeave) {
- onMouseLeave(evt);
- }
-
- handleClose(evt);
- };
-
- // set callbacks for onBlur and onFocus, unless disableFocusListener is true
- const blurCb = isUsingFocus ? handleLeave : undefined;
- const focusCb = isUsingFocus ? handleEnter : undefined;
-
- // set callbacks for onMouseEnter and onMouseLeave, unless disableMouseListener is true
- const mouseEnterCb = isUsingMouse ? handleEnter : undefined;
- const mouseLeaveCb = isUsingMouse ? handleLeave : undefined;
-
- // set the wrapper's tabIndex for focus events, unless disableFocusListener is true
- const tabIndex = isUsingFocus ? '0' : undefined;
-
- return (
-
-
- {text}
-
- {children}
-
- );
-});
-
-Tooltip.defaultProps = {
- className: '',
- disableFocusListener: false,
- disableMouseListener: false,
- enterDelay: 1000,
- leaveDelay: 0,
- onBlur: undefined,
- onClose: undefined,
- onFocus: undefined,
- onMouseEnter: undefined,
- onMouseLeave: undefined,
- onOpen: undefined,
- style: {},
- position: 'top'
-};
-
-Tooltip.propTypes = {
- children: propTypes.node.isRequired,
- className: propTypes.string,
- disableFocusListener: propTypes.bool,
- disableMouseListener: propTypes.bool,
- enterDelay: propTypes.number,
- leaveDelay: propTypes.number,
- onBlur: propTypes.func,
- onClose: propTypes.func,
- onFocus: propTypes.func,
- onMouseEnter: propTypes.func,
- onMouseLeave: propTypes.func,
- onOpen: propTypes.func,
- style: propTypes.shape({}),
- text: propTypes.string.isRequired,
- position: propTypes.oneOf(['top', 'bottom', 'left', 'right'])
-};
-
-export default Tooltip;
diff --git a/src/Tooltip/Tooltip.mdx b/src/Tooltip/Tooltip.mdx
deleted file mode 100644
index b809b0df..00000000
--- a/src/Tooltip/Tooltip.mdx
+++ /dev/null
@@ -1,29 +0,0 @@
----
-name: Tooltip
-menu: Components
----
-
-import Tooltip from './Tooltip';
-import Button from '../Button/Button';
-
-# Tooltip
-
-## Usage
-
-
-
- Whad do I do?
-
-
-
-## API
-
-### Import
-
-```
-import { Tooltip } from 'react95'
-```
-
-### Props
-
-
diff --git a/src/Tooltip/Tooltip.spec.js b/src/Tooltip/Tooltip.spec.tsx
similarity index 96%
rename from src/Tooltip/Tooltip.spec.js
rename to src/Tooltip/Tooltip.spec.tsx
index 7f1c0745..d8602526 100644
--- a/src/Tooltip/Tooltip.spec.js
+++ b/src/Tooltip/Tooltip.spec.tsx
@@ -1,9 +1,11 @@
-import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
+import React from 'react';
-import Tooltip from './Tooltip';
+import { Tooltip, TooltipProps } from './Tooltip';
-const getProps = (props = {}) => ({
+const getProps = (
+ props: Partial = {}
+): Omit => ({
className: props.className,
disableFocusListener: props.disableFocusListener,
disableMouseListener: props.disableMouseListener,
@@ -19,7 +21,7 @@ const getProps = (props = {}) => ({
text: 'I am the tooltip'
});
-const renderTooltip = props => (
+const renderTooltip = (props: Omit) => (
Kid
diff --git a/src/Tooltip/Tooltip.stories.js b/src/Tooltip/Tooltip.stories.tsx
similarity index 75%
rename from src/Tooltip/Tooltip.stories.js
rename to src/Tooltip/Tooltip.stories.tsx
index c2f5b0fc..186af133 100644
--- a/src/Tooltip/Tooltip.stories.js
+++ b/src/Tooltip/Tooltip.stories.tsx
@@ -1,18 +1,19 @@
+import { ComponentMeta } from '@storybook/react';
import React from 'react';
+import { Button, Tooltip } from 'react95';
import styled from 'styled-components';
-import { Tooltip, Button } from 'react95';
-
-export default {
- title: 'Tooltip',
- component: Tooltip,
- decorators: [story => {story()} ]
-};
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 (
diff --git a/src/Tooltip/Tooltip.tsx b/src/Tooltip/Tooltip.tsx
new file mode 100644
index 00000000..60cf5558
--- /dev/null
+++ b/src/Tooltip/Tooltip.tsx
@@ -0,0 +1,197 @@
+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;
+ onOpen?: (
+ event: React.FocusEvent