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 ( + + + + ); +} 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 @@

Components - Demo app - - Website - + React Native - Slack - PayPal donation 💰

@@ -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 - -

- NPM - React95 version - React95 license - -

- -

- - Components - +import { Meta } from '@storybook/addon-docs'; + + + +# Welcome to React95 + + + NPM + +  + + React95 version + +  + + React95 license + + +

+ Components  -  Demo app  -  @@ -28,23 +33,14 @@ route: / PayPal donation 💰

-

- 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) 💅. ![hero](https://user-images.githubusercontent.com/28541613/81947711-28b05580-9601-11ea-964a-c3a6de998496.png) ### 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 - - - )} - -
- ); - } - 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 - - - - - - - - - - - - -## 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 - - - - - -#### Disabled - - - - - -#### Full Width - - - - - -#### Square - - - - - -#### Active - - - - - -#### Different sizes - - -
- - - -
-
- -#### Flat - - - - - -

- When you want to use Buttons on a light background (like scrollable - content), just use the flat variant: -

-
- - - - - -
-
-
-
-
- -## 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(' +
+ +
+ +
+ +
+ +
+ +
+ + +
+ ); +} + +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: +

+
+ + + + + +
+
+
+
+ ); +} + +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 - -
{open && ( - setOpen(false)} > - Copy link - - Facebook - Twitter - Instagram - - + Copy link + + Facebook + Twitter + Instagram + + MySpace - - + + )}
- + kiwi - - -
- ); -} - -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: -

-
- - - - - -
-
+
); } -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 ( -
-
- -
- -
- -
- -
- ); - }} -
- -#### 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 ( - +
-
+ ); } 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} + + + + + + + + ); + } +); + +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 ( - + - + ); 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