├── .DS_Store ├── .autorc ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .glitch-assets ├── .prettierrc ├── .sample.env ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── lib ├── animation-container.js ├── avatar.js ├── badge.js ├── block.js ├── button-group.js ├── button.js ├── callback-proxy.js ├── checkbox-button.js ├── checkbox.js ├── editable-project-domain.js ├── hooks │ ├── use-debounced-value.js │ ├── use-optimistic-value.js │ ├── use-passively-trimmed-input.js │ └── use-unique-id.js ├── icon-button.js ├── icon.js ├── icons │ └── searchGalaxy.js ├── index.js ├── keyboard-navigation.js ├── live-announcer.js ├── loader.js ├── mark.js ├── notification.js ├── optimistic-inputs.js ├── overlay.js ├── popover.js ├── progress.js ├── remote-component.js ├── results-list.js ├── search-results.js ├── stories.js ├── story-utils.js ├── system.js ├── text-input.js ├── theme-preview.js ├── themes.js ├── toggle.js ├── tooltip.js └── visually-hidden.js ├── package-lock.json ├── package.json ├── public └── index.html ├── rollup.config.js ├── server ├── changelog.js ├── check-changelog.js ├── index.js ├── rollup.js └── watch.js ├── sh ├── merge.sh ├── publish.sh ├── setup.sh └── update.sh ├── shrinkwrap.yaml ├── test └── remote-component │ ├── index.html │ ├── index.js │ └── server.js └── watch.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glitchdotcom/shared-components/d23d9eac31b00d0170cf8c0ad4247b41442d66bd/.DS_Store -------------------------------------------------------------------------------- /.autorc: -------------------------------------------------------------------------------- 1 | { 2 | "baseBranch": "master", 3 | "plugins": [ 4 | "npm", 5 | "all-contributors", 6 | "conventional-commits", 7 | "first-time-contributor", 8 | "released" 9 | ] 10 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "es6": true, "browser": true }, 3 | "extends": ["eslint:recommended", "plugin:jsx-a11y/recommended", "plugin:react/recommended"], 4 | "parser": "babel-eslint", 5 | "ignorePatterns": ["node_modules/", "server/", "test/", "sh/", "test/", "build/", "rollup.config.js"], 6 | "rules": { "react/no-unescaped-entities": 0, "react/prop-types": 0 }, 7 | "settings": { "react": { "version": "detect" } } 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: [push] 4 | 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')" 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Prepare repository 13 | run: git fetch --unshallow --tags 14 | 15 | - name: Use Node.js 16.x 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 16.x 19 | 20 | - name: Cache node modules 21 | uses: actions/cache@v4 22 | with: 23 | path: node_modules 24 | key: npm-deps-${{ hashFiles('package-lock.json') }} 25 | restore-keys: | 26 | npm-deps-${{ hashFiles('package-lock.json') }} 27 | 28 | - name: Build release 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 31 | NODE_AUTH_TOKEN: ${{ secrets.GH_TOKEN }} 32 | NPM_TOKEN: ${{ secrets.GH_TOKEN }} 33 | run: | 34 | npm ci 35 | npm run rollup 36 | 37 | - name: Create Release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 40 | NODE_AUTH_TOKEN: ${{ secrets.GH_TOKEN }} 41 | NPM_TOKEN: ${{ secrets.GH_TOKEN }} 42 | run: | 43 | npm run release 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 150, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } -------------------------------------------------------------------------------- /.sample.env: -------------------------------------------------------------------------------- 1 | PORT=5555 -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This guide is written for Glitch team members making changes to our shared-components project. 4 | 5 | ## Table of Contents 6 | - [Making changes to shared components](#making-changes-to-shared-components) 7 | * [Adding Emojis](#adding-emojis) 8 | - [Viewing shared components in app context](#viewing-shared-components-in-app-context) 9 | * [Remote Components](#remote-components) 10 | - [Code Review](#code-review) 11 | - [Publishing and Deploying](#publishing-and-deploying) 12 | * [Becoming a maintainer on NPM](#becoming-a-maintainer-on-npm) 13 | - [Additional Resources](#additional-resources) 14 | 15 | ### Making changes to shared components 16 | 17 | #### Running the project 18 | This package renders its own documentation and development environment. You can edit this project by either creating a remix of shared-components ([remix link](https://glitch.com/edit/#!/remix/shared-components)) or working with it locally (Clone the repo, `npm install`, `npm start`). When running locally, you may wish to add a .env file so that the project consistently starts on the same port. See .sample.env for an example. 19 | 20 | #### Adding a new component 21 | 1. Add the component you need in the appropriate `lib/type-of-thing.js` file. 22 | 2. Add some stories. 23 | - A Story is a component exported from your `lib/` file whose name starts with `Story`. 24 | - A Story with no underscore in the name will appear in the side navigation of the documentation page. (`StoryButton`) 25 | - A Story with an underscore in the name will _not_ appear in the side navigation, but can still be deep-linked to. ([`StoryTextInput_and_TextArea_variants](https://shared-components.glitch.me/#StoryTextInput_and_TextArea_variants)) 26 | - If you've added stories to a new `lib/` file, add an import for that file at the top of `lib/stories.js` and add the variable you imported to the `modules` array. 27 | - All stories that are exported from files included in `stories.js` will appear on the documentation site. (the root webpage of your running app, or shared-components.glitch.me) 28 | 3. **important!** Export your new component from `lib/index.js`. This is how your component gets into the NPM shared-components package. 29 | 30 | #### Adding Emojis 31 | All emoji assets live directly in the shared-components app's .glitch-assets. To add a new emoji: 32 | 1. Make sure that the changes has been approved by someone in #design 33 | 2. Resize and scale the emoji bitmap. All emojis should be 64x64 pngs that have been compressed using [Pngyu](https://nukesaq88.github.io/Pngyu/). Each image should be on the order of <~5kb in size. 34 | 3. Upload the image directly to [shared-component](https://glitch.com/edit/#!/shared-components) .glitch-assets (NOTE: the image is uploaded to shared-components, not your remix!) 35 | 4. In your remix, add the emoji alphabetically to `icons` in [icon.js](https://glitch.com/edit/#!/plump-chime?path=lib/icon.js:162:15). The name should match that on [emojipedia](https://emojipedia.org/) and uses camelCase when needed. 36 | 5. Follow the rest of the steps below to finish submitting your PR. 37 | 38 | ### Viewing shared components in app context 39 | While the components in shared-components should be the kind of components you can build in isolation of any particular consumer, you may wish to see it in the context of your application. 40 | 41 | #### Remote components 42 | One way to do that is to use this package's helper `createRemoteComponent` that loads a development version of the library from a URL. For example: 43 | 44 | ```js 45 | import { Icon, createRemoteComponent } from '@glitchdotcom/shared-components' 46 | 47 | const DevIcon = createRemoteComponent('https://sour-environment.glitch.me/module.js', 'Icon'); 48 | ``` 49 | 50 | In the above case, `` will render the Icon component as its defined in the version of shared-components on npm, but `` will render the Icon component as its currently defined in the `sour-environment` remix of shared-components. If you change how Icon renders in this remix, it will be reflected in how DevIcon renders in your application. 51 | 52 | Note that at this time, this only works for React components, not themes or other imports. 53 | 54 | You can see a demo of this in `/test/remote-component/index.js`. 55 | Create a remix of shared-components ([remix link](https://glitch.com/edit/#!/remix/shared-components)) and make all your changes within the remix . 56 | 57 | Note: 58 | - this does not currently work with SSR in the community site. The current work around is to go in Glitch-Community/server/routes.js and change this line: 59 | ``` 60 | const renderPage = require('./render'); 61 | ``` 62 | to 63 | ``` 64 | const renderPage = () => ({ html: null, helmet: null, styleTags: null }); 65 | ``` 66 | Another note: if you plan on using this component in multiple places you'll have to add the createRemoteComponent every place the component is used. This might be tricky if you're editing a component that's used widely, so you may wish to try the following process below instead. 67 | 68 | #### Testing a version of shared-components in the community site 69 | You may decide you'd rather work locally or you'd rather test an entire version of shared-components before publishing. There are a number of different ways to do this but here is one possible route that may work for you: 70 | 71 | 1. cd into shared-components 72 | 1. run `npm link` 73 | 1. run `npm run rollup:watch` 74 | 1. in your editor, in the community site project: 75 | 1. in aliases.js, add: 76 | ``` 77 | react: path.resolve('./node_modules/react'), 78 | 'styled-components': path.resolve('./node_modules/styled-components'), 79 | ``` 80 | 81 | We do this because otherwise shared-components will use its own version of react and styled-components, and you'll get an [error message mentioning invalid hooks](https://reactjs.org/warnings/invalid-hook-call-warning.html) if you don't add the react alias. In theory any package shared-components uses that is also used by community will need a similar alias to ensure we're only using one dependency. 82 | 83 | 1. in a new terminal tab (`rollup:watch` should still be running in the shared-components tab), cd into community 84 | 1. run `npm uninstall @glitchdotcom/shared-components` 85 | 1. run `npm link @glitchdotcom/shared-components` 86 | 1. After making changes to a shared component that you'd like to see reflected in your community site build, restart the community server 87 | - you don't have to do anything in the terminal to rebuild shared-components; `rollup:watch` is handling that for you 88 | - there are supposedly ways to make you not have to restart community, by `touch`ing an application file after the rollup new build is available, but Cassey couldn't get them working - Webpack would rebuild, but it seemed to cache the node_modules parts, so new changes from shared-components weren't picked up. 89 | - see [NPM link troubleshooting guide](https://engineering.mixmax.com/blog/troubleshooting-npm-link) for more tips 90 | 91 | ## Cleaning up from `npm link` 92 | Do this when you're done working with a local copy of shared-components inside a local version of community. **Don't skip this step!** 93 | 1. in community, in your terminal: 94 | 1. run `npm unlink @glitchdotcom/shared-components` 95 | 1. in shared-components, in your terminal: 96 | 1. stop the rollup watch process, if you haven't yet 97 | 1. run `npm unlink` 98 | 1. back in community 99 | 1. reinstall shared-components from NPM (`npm install @glitchdotcom/shared-components`) 100 | 101 | Note: this process that was just outlined is kind of a pain and not particularly sustainable. If you find a better alternative feel free to update this documentation! 102 | 103 | Another alternative to this process would be to [publish a prerelease version](#publishing-and-deploying). This is particularly convenient if you need to share this in-progress version with other teammates. 104 | 105 | ### Code Review 106 | While shared-components is a cross-team collaboration, any stylistic changes should be reviewed by design to ensure we're keeping a cohesive feel across the site. When making a pull request, do add a member of the design team for review. 107 | 108 | If you've been working from a glitch remix of shared-components rather than locally you can use the following commands in your local terminal to setup a branch on github: 109 | 110 | `./sh/setup.sh my-remix` 111 | 112 | If you want to update an existing branch on github from work done on glitch.com, use the `update.sh` script: 113 | 114 | `./sh/update.sh my-remix` 115 | 116 | If you wish to update a glitch remix with changes from github, use the glitch terminal: 117 | `git pull origin branch-name-on-github` 118 | 119 | ### Publishing and Deploying 120 | 1. First ensure that your PR is tagged with either `patch`, `minor` or `major`, this will determine the version bump 121 | 2. Once your PR has been approved, merge it on github 122 | 3. A [github action](https://github.com/glitchdotcom/shared-components/actions) will fire, creating a new version of the shared-components package and apply updates to CHANGELOG.md 123 | 124 | ### Other Labels 125 | - `skip-release` will skip the github action and not create a new release when the PR is merged 126 | 127 | ### Additional Resources 128 | * [Styled Components Docs](https://www.styled-components.com/docs/basics#getting-started) 129 | * [How we built a component library that people actually enjoy using](https://medium.com/styled-components/how-to-build-a-great-component-library-a40d974a412d) 130 | * [Styled Components: Enforcing Best Practices In Component-Based Systems](https://www.smashingmagazine.com/2017/01/styled-components-enforcing-best-practices-component-based-systems/) 131 | * [Thinking in styled-components](https://itnext.io/thinking-in-styled-components-e230ea37c52c) 132 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Justin Falcone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shared-components 2 | This is a library of React components used on Glitch's community site and editor. 3 | 4 | ### Install from NPM 5 | 6 | This package is installed in the github packages repo, so you have to do a little 7 | configuration to set up your NPM to download the design system. 8 | 9 | In your project, ensure there is a `.npmrc` file containing: 10 | `registry=https://npm.pkg.github.com/glitchdotcom` 11 | 12 | Generate an [access token](https://github.com/settings/tokens) with the permissions `repo`, `write:packages` and `read:packages`. 13 | 14 | In your terminal, login npm to your github account with your username and the access token as your password: 15 | `npm login --registry=https://npm.pkg.github.com/` 16 | 17 | Then you can install the design system: 18 | `npm install @glitchdotcom/shared-components` 19 | 20 | ## Usage 21 | For documentation of available components, see [shared-components.glitch.me](https://shared-components.glitch.me). 22 | 23 | ### Browser support 24 | This works as-is in evergreen browsers, but it uses features which may require polyfills, transpilation, or other fallbacks: 25 | - css custom properties (aka "css variables") 26 | - ES2018 features (e.g. async/await, object spread) 27 | - `
` and `` HTML elements 28 | 29 | ### In production applications 30 | In production applications, you will likely want to use the following babel plugins: 31 | - [babel-plugin-styled-components](https://www.styled-components.com/docs/tooling#babel-plugin) 32 | - [babel-plugin-transform-react-remove-prop-types](https://github.com/oliviertassinari/babel-plugin-transform-react-remove-prop-types#readme) 33 | 34 | ## Contributing 35 | For information on making changes to shared-components, refer to [CONTRIBUTING.md](https://github.com/glitchdotcom/shared-components/blob/master/CONTRIBUTING.md) 36 | -------------------------------------------------------------------------------- /lib/animation-container.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled, { keyframes } from 'styled-components'; 4 | // stories 5 | import { Button } from './button'; 6 | import { code, CodeExample, PropsDefinition, Prop } from './story-utils'; 7 | 8 | const nullAnimation = keyframes``; 9 | 10 | export const slideUp = keyframes` 11 | to { 12 | transform: translateY(-50px); 13 | opacity: 0; 14 | } 15 | `; 16 | export const slideDown = keyframes` 17 | to { 18 | transform: translateY(50px); 19 | opacity: 0; 20 | } 21 | `; 22 | export const fadeOut = keyframes` 23 | to { 24 | opacity: 0; 25 | } 26 | `; 27 | 28 | const AnimationWrap = styled.div` 29 | animation-name: ${({ animation }) => animation}; 30 | @media (prefers-reduced-motion: reduce) { 31 | animation-name: ${({ reducedMotionAnimation }) => reducedMotionAnimation}; 32 | } 33 | animation-duration: var(--animation-duration, 0.1s); 34 | animation-timing-function: ease-out; 35 | animation-fill-mode: forwards; 36 | `; 37 | 38 | export const AnimationContainer = ({ animation, reducedMotionAnimation, duration, children, onAnimationEnd, ...props }) => { 39 | const [active, setActive] = React.useState(false); 40 | const ref = React.useRef(); 41 | 42 | const handleAnimationEnd = (event) => { 43 | if (event.target === ref.current) onAnimationEnd(event); 44 | }; 45 | 46 | return ( 47 | 58 | {children(() => setActive(true))} 59 | 60 | ); 61 | }; 62 | 63 | AnimationContainer.propTypes = { 64 | animation: PropTypes.any, 65 | reducedMotionAnimation: PropTypes.any, 66 | children: PropTypes.func.isRequired, 67 | onAnimationEnd: PropTypes.func.isRequired, 68 | }; 69 | 70 | const ExampleBlock = styled.div` 71 | padding: var(--space-1); 72 | border: 1px solid var(--colors-border); 73 | `; 74 | 75 | const Grid = styled.div` 76 | display: grid; 77 | grid-gap: var(--space-1); 78 | grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); 79 | margin: var(--space-1) auto; 80 | `; 81 | 82 | const ProjectItem = styled.div` 83 | border-radius: var(--rounded); 84 | padding: var(--space-1); 85 | color: var(--colors-tertiary-text); 86 | background-color: var(--colors-tertiary-background); 87 | `; 88 | 89 | export const StoryAnimationContainer = () => { 90 | const [projects, setProjects] = React.useState([{ id: 1, domain: 'power-passenger', pinned: true }, { id: 2, domain: 'deface-the-moon' }]); 91 | const pinnedProjects = projects.filter((p) => p.pinned); 92 | const recentProjects = projects.filter((p) => !p.pinned); 93 | 94 | const pinProject = (project) => { 95 | setProjects((projects) => projects.map((p) => (p.domain === project.domain ? { ...project, pinned: true } : p))); 96 | }; 97 | const unpinProject = (project) => { 98 | setProjects((projects) => projects.map((p) => (p.domain === project.domain ? { ...project, pinned: false } : p))); 99 | }; 100 | 101 | return ( 102 | <> 103 |

The AnimationContainer component renders a container that adds animations to callbacks.

104 | 105 | {code` 106 | featureProject(project)}> 107 | {(startAnimation) => } 108 | 109 | `} 110 | 111 | 112 | 113 | The CSS animation to run. Can either be the name of an animation declared in a stylesheet or an object returned from{' '} 114 | 115 | styled-components' keyframes helper 116 | 117 | . 118 | 119 | 120 | The animation to run if the user has "prefers-reduced-motion" set, or if no value is provided for the "animation" prop. Read the{' '} 121 | WCAG guidelines for more information on accessibility 122 | in animations. 123 | 124 | The duration of the animation. Defaults to "0.1s". 125 | 126 | A render prop, which passes in a callback function that starts the animation. 127 | 128 | 129 | A callback function, called when the animation is completed. If no animation is provided, this runs immediately. 130 | 131 | 132 | 133 |

Pinned Projects

134 |

(if "reduce motion" is active, these will dissapear without animating out.)

135 | 136 | {pinnedProjects.map((project) => ( 137 | unpinProject(project)}> 138 | {(animateAndUnpin) => ( 139 | 140 |

{project.domain}

141 | 144 |
145 | )} 146 |
147 | ))} 148 |
149 |

Recent Projects

150 |

(if "reduce motion" is active, these will fade out.)

151 | 152 | {recentProjects.map((project) => ( 153 | pinProject(project)}> 154 | {(animateAndPin) => ( 155 | 156 |

{project.domain}

157 | 160 |
161 | )} 162 |
163 | ))} 164 |
165 |
166 | 167 | ); 168 | }; 169 | -------------------------------------------------------------------------------- /lib/avatar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled, { css } from 'styled-components'; 4 | import { CodeExample, PropsDefinition, Prop } from './story-utils'; 5 | import { TextInput } from './text-input'; 6 | 7 | const Image = React.forwardRef(({ src, defaultSrc, alt, ...props }, ref) => { 8 | const [activeSrc, setActiveSrc] = React.useState(src || defaultSrc); 9 | React.useEffect(() => { 10 | setActiveSrc(src || defaultSrc); 11 | }, [src, defaultSrc]); 12 | 13 | const onError = () => { 14 | if (defaultSrc && activeSrc !== defaultSrc) { 15 | setActiveSrc(defaultSrc); 16 | } 17 | }; 18 | 19 | return {alt}; 20 | }); 21 | 22 | Image.displayName = 'Image'; 23 | 24 | const variants = { 25 | roundrect: css` 26 | border-radius: var(--rounded); 27 | `, 28 | circle: css` 29 | border-radius: 100%; 30 | `, 31 | square: css``, 32 | }; 33 | 34 | export const Avatar = styled(Image).attrs(() => ({ 'data-module': 'Avatar' }))` 35 | display: block; 36 | width: 100%; 37 | height: auto; 38 | ${({ variant }) => variants[variant]} 39 | `; 40 | 41 | Avatar.propTypes = { 42 | src: PropTypes.string, 43 | defaultSrc: PropTypes.string.isRequired, 44 | alt: PropTypes.string.isRequired, 45 | variant: PropTypes.oneOf(Object.keys(variants)).isRequired, 46 | }; 47 | 48 | export const StoryAvatar = () => ( 49 | <> 50 |

The Avatar component renders an avatar-shaped image with a fallback src.

51 | {``} 52 | 53 | The source for the avatar image. 54 | 55 | The fallback source for the avatar image, if "src" is not present or returns an error. 56 | 57 | 58 | The alt text for the avatar image. 59 | 60 | 61 | The shape of the avatar image: "circle" or "roundrect" or "square". 62 | 63 | 64 | 65 | ); 66 | 67 | const Container = styled.div` 68 | color: var(--colors-tertiary-text); 69 | background-color: var(--colors-tertiary-background); 70 | border-radius: var(--rounded); 71 | padding: var(--space-1); 72 | margin: var(--space-1) 0; 73 | `; 74 | 75 | const Flex = styled(Container)` 76 | display: flex; 77 | width: 300px; 78 | > * { 79 | flex: 1 1 auto; 80 | text-align: center; 81 | } 82 | > * + * { 83 | margin-left: var(--space-1); 84 | } 85 | `; 86 | 87 | const DEFAULT_PROJECT_AVATAR_URL = 'https://cdn.glitch.com/c53fd895-ee00-4295-b111-7e024967a033%2Ffallback-project-avatar.svg?1528812220123'; 88 | const project = { 89 | domain: 'veil-can', 90 | avatarUrl: 'https://cdn.glitch.com/project-avatar/e3c4a224-de97-4253-b0d0-384c1b7be699.png?1564496385010', 91 | }; 92 | 93 | export const StoryAvatar_sizes = () => ( 94 | <> 95 |

Avatars are block-level elements and are sized to fit their container.

96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | ); 104 | 105 | export const StoryAvatar_variants = () => ( 106 | 107 |
108 |

roundrect

109 | 110 |
111 |
112 |

circle

113 | 114 |
115 |
116 |

square

117 | 118 |
119 |
120 | ); 121 | 122 | const AvatarContainer = styled.div` 123 | width: 100px; 124 | margin: var(--space-2); 125 | `; 126 | 127 | export const StoryAvatar_defaultSrc = () => { 128 | const [src, setSrc] = React.useState(null); 129 | return ( 130 | <> 131 |

If src is not present or the image at the URL does not load, Avatar shows the image at defaultSrc instead.

132 |
133 | 134 |
135 | 136 | 137 | 138 | 139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /lib/badge.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled, { css } from 'styled-components'; 4 | import { Button } from './button'; 5 | import { CodeExample, PropsDefinition, Prop } from './story-utils'; 6 | 7 | const variants = { 8 | normal: css` 9 | color: var(--colors-background); 10 | background-color: var(--colors-secondary); 11 | `, 12 | inactive: css` 13 | color: var(--colors-background); 14 | background-color: var(--colors-placeholder); 15 | `, 16 | notice: css` 17 | color: var(--colors-notice-text); 18 | background-color: var(--colors-notice-background); 19 | `, 20 | success: css` 21 | color: var(--colors-success-text); 22 | background-color: var(--colors-success-background); 23 | `, 24 | warning: css` 25 | color: var(--colors-warning-text); 26 | background-color: var(--colors-warning-background); 27 | `, 28 | error: css` 29 | color: var(--colors-error-text); 30 | background-color: var(--colors-error-background); 31 | `, 32 | }; 33 | 34 | const BadgeBase = styled.span.attrs(() => ({ 'data-module': 'Badge' }))` 35 | display: inline-block; 36 | vertical-align: top; 37 | font-size: 0.75em; 38 | font-family: var(--font-sans); 39 | font-weight: 600; 40 | padding: 0.2em 0.375em 0.05em; 41 | border-radius: var(--rounded); 42 | white-space: nowrap; 43 | ${({ variant }) => variants[variant]}; 44 | ${({ collapsed }) => 45 | collapsed && 46 | css` 47 | border-radius: 50%; 48 | vertical-align: baseline; 49 | height: 1em; 50 | width: 1em; 51 | padding: 0; 52 | `} 53 | `; 54 | 55 | export const Badge = ({ children, variant, collapsed, ...props }) => ( 56 | 57 | {collapsed ? null : children} 58 | 59 | ); 60 | 61 | Badge.propTypes = { 62 | children: PropTypes.node.isRequired, 63 | variant: PropTypes.oneOf(Object.keys(variants)), 64 | collapsed: PropTypes.bool, 65 | }; 66 | 67 | Badge.defaultProps = { 68 | variant: 'normal', 69 | collapsed: false, 70 | }; 71 | 72 | const Container = styled.div` 73 | & > * { 74 | margin: 0 var(--space-1) var(--space-1) 0; 75 | } 76 | `; 77 | 78 | export const StoryBadge = () => ( 79 | <> 80 |

The Badge component renders small, highlighted inline content, e.g. search result count or a status message inside a menu button.

81 | {`Error`} 82 | 83 | 84 | The badge palette: "normal", "inactive", "notice", "success", "warning", "error" (default "normal") -- see below for examples. 85 | 86 | 87 | Whether the badge is collapsed or not. true or false (default false). 88 | 89 | 90 |

variant colors

91 |

(Note that the actual variant names are in lowercase.)

92 | 93 | {['Normal', 'Inactive', 'Notice', 'Success', 'Warning', 'Error'].map((label) => ( 94 | 95 | {label} 96 | 97 | ))} 98 | 99 |

Badge sizes

100 |

Badges are fitted to the size of their surrounding text.

101 |

102 | Projects 16 103 |

104 |

When a badge is collapsed, only the color is visible.

105 | 106 | 109 |   110 | 113 |   114 | 120 | 121 | 122 | 125 |   126 | 129 |   130 | 136 | 137 | 138 | ); 139 | -------------------------------------------------------------------------------- /lib/block.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import { IconButton } from './icon-button'; 4 | import { CodeExample, PropsDefinition, Prop } from './story-utils'; 5 | 6 | const sectionVariants = { 7 | info: css` 8 | color: var(--colors-primary); 9 | background-color: var(--colors-secondaryBackground); 10 | border-top: 1px solid var(--colors-border); 11 | `, 12 | actions: css` 13 | color: var(--colors-primary); 14 | background-color: var(--colors-background); 15 | border-top: 1px solid var(--colors-border); 16 | `, 17 | warning: css` 18 | color: var(--colors-warning-text); 19 | background-color: var(--colors-warning-background); 20 | border-top: 1px solid var(--colors-border); 21 | `, 22 | infobanner: css` 23 | color: var(--colors-primary); 24 | background-color: var(--colors-background); 25 | font-size: 14px; 26 | margin-top: 6px; 27 | margin-bottom: 24px; 28 | `, 29 | actionsbanner: css` 30 | text-align: right; 31 | `, 32 | }; 33 | 34 | const titleVariants = { 35 | normal: css` 36 | color: var(--colors-primary); 37 | background-color: var(--colors-secondaryBackground); 38 | font-size: var(--fontSizes-small); 39 | `, 40 | banner: css` 41 | color: var(--colors-primary); 42 | background-color: var(--colors-background); 43 | font-size: 14px; 44 | `, 45 | }; 46 | 47 | const titleContentVariants = { 48 | normal: css` 49 | font-size: var(--fontSizes-small); 50 | `, 51 | banner: css` 52 | font-size: 16px; 53 | `, 54 | }; 55 | 56 | // used in popovers and overlays 57 | 58 | const TitleWrap = styled.header` 59 | display: flex; 60 | align-items: baseline; 61 | padding: var(--space-1); 62 | ${({ variant }) => titleVariants[variant]}; 63 | `; 64 | 65 | const TitleContent = styled.h2` 66 | flex: 1 1 auto; 67 | margin: 0; 68 | padding: 0; 69 | ${({ variant }) => titleContentVariants[variant]}; 70 | `; 71 | 72 | TitleWrap.defaultProps = { 73 | variant: 'normal', 74 | }; 75 | 76 | TitleContent.defaultProps = { 77 | variant: 'normal', 78 | }; 79 | 80 | export const Title = ({ children, onBack, onBackRef, variant, onClose, onCloseRef, ...props }) => ( 81 | 82 | {onBack && } 83 | {children} 84 | {onClose && } 85 | 86 | ); 87 | 88 | const Section = styled.section` 89 | padding: var(--space-1); 90 | margin: 0; 91 | ${({ variant }) => sectionVariants[variant]}; 92 | &:first-child { 93 | border-top: none; 94 | } 95 | `; 96 | 97 | export const Info = (props) =>
; 98 | 99 | export const Actions = (props) =>
; 100 | 101 | export const DangerZone = (props) =>
; 102 | 103 | const Container = styled.div` 104 | margin: var(--space-1) 0; 105 | font-size: var(--fontSizes-small); 106 | border-radius: var(--rounded); 107 | overflow: hidden; 108 | border: 1px solid var(--colors-border); 109 | box-shadow: var(--popShadow); 110 | width: 400px; 111 | `; 112 | 113 | export const Story_Blocks_for_Overlay_and_Popover_content = () => ( 114 | <> 115 |

The Title component is used for rendering headers in Overlay and Popover components.

116 | {`Your Projects`} 117 | 118 | 119 | A callback function typically called to render the previous view in a multi-page popover; when present, this renders a "chevronLeft" icon 120 | button. 121 | 122 | A callback function typically called to close the popover or overlay; when present, this renders an "x" icon button. 123 | A ref to the "onBack" button. 124 | A ref to the "onClose" button. 125 | 126 | console.log('onBack')}>Your Projects 127 | 128 | 129 |

130 | The Info, Actions, and DangerZone components are used for rendering sections in Overlay and Popover components. They have no component-specific 131 | props. 132 |

133 | 134 | 135 |

The Info section is used for explanatory text or form fields.

136 |
137 | 138 |

The Actions section is used for buttons and results lists.

139 |
140 | 141 |

The DangerZone section is used for destructive actions (e.g. deleting projects).

142 |
143 |
144 | 145 | ); 146 | -------------------------------------------------------------------------------- /lib/button-group.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { Button } from './button'; 5 | import { onArrowKeys } from './keyboard-navigation'; 6 | import { code, CodeExample, PropsDefinition, Prop } from './story-utils'; 7 | import { Icon } from './icon'; 8 | 9 | const ButtonWrap = styled.div` 10 | display: flex; 11 | flex-direction: row; 12 | position: relative; 13 | `; 14 | 15 | export const ButtonSegment = styled(Button)` 16 | flex: 0 0 auto; 17 | border-radius: 0; 18 | border-right-style: none; 19 | 20 | &[aria-checked='true'], 21 | &:active { 22 | color: var(--colors-selected-text); 23 | background-color: var(--colors-selected-background); 24 | z-index: 1; 25 | } 26 | 27 | &:first-child { 28 | border-radius: var(--rounded) 0 0 var(--rounded); 29 | } 30 | 31 | &:last-child { 32 | border-radius: 0 var(--rounded) var(--rounded) 0; 33 | border-right-style: solid; 34 | } 35 | `; 36 | 37 | export const ButtonGroup = ({ children, size, variant, ...props }) => ( 38 | 39 | {React.Children.map(children, (child) => React.cloneElement(child, { size, variant }))} 40 | 41 | ); 42 | ButtonGroup.propTypes = { 43 | children: PropTypes.node.isRequired, 44 | }; 45 | 46 | const SoftIcon = styled(Icon)` 47 | color: var(--colors-placeholder); 48 | button:active & { 49 | color: inherit; 50 | } 51 | `; 52 | 53 | export const StoryButtonGroup_and_ButtonSegment = () => ( 54 | <> 55 |

The ButtonGroup and ButtonSegment components render a set of related and connected buttons.

56 | 57 | {code` 58 | 59 | 60 | 61 | 62 | 63 | `} 64 | 65 | 66 | The button style that is applied to all buttons in the group. 67 | The button size that is applied to all buttons in the group. 68 | 69 | 70 | console.log('prevItem')}> 71 | 72 | 73 | console.log('nextItem')}> 74 | 75 | 76 | console.log('closeSearch')}> 77 | 78 | 79 | 80 | 81 | ); 82 | 83 | const handleKeyDown = (options, refs, index, onChange) => (e) => { 84 | const nextIndex = onArrowKeys(e, index, options); 85 | if (nextIndex === null) return; 86 | onChange(options[nextIndex].id, e); 87 | refs.current[nextIndex].focus(); 88 | }; 89 | 90 | export const SegmentedButton = ({ value, options, onChange, size, variant, ...props }) => { 91 | const refs = React.useRef([]); 92 | return ( 93 | 94 | {options.map(({ id, label, ...buttonProps }, i) => ( 95 | { 97 | refs.current[i] = el; 98 | }} 99 | key={id} 100 | active={value === id} 101 | onClick={(e) => onChange(id, e)} 102 | size={size} 103 | variant={variant} 104 | // a11y, see https://www.w3.org/TR/2016/WD-wai-aria-practices-1.1-20160317/examples/radio/radio.html 105 | role="radio" 106 | tabIndex={value === id ? 0 : -1} 107 | aria-checked={value === id} 108 | onKeyDown={handleKeyDown(options, refs, i, onChange)} 109 | {...buttonProps} 110 | > 111 | {label} 112 | 113 | ))} 114 | 115 | ); 116 | }; 117 | SegmentedButton.propTypes = { 118 | value: PropTypes.any.isRequired, 119 | options: PropTypes.arrayOf( 120 | PropTypes.shape({ 121 | id: PropTypes.any.isRequired, 122 | label: PropTypes.node.isRequired, 123 | }).isRequired, 124 | ).isRequired, 125 | onChange: PropTypes.func.isRequired, 126 | }; 127 | 128 | const options = [{ id: 'foo', label: 'FooBar' }, { id: 'bar', label: 'agogo' }, { id: 'baz', label: 'BagLager' }]; 129 | 130 | const Wrap = styled.div` 131 | margin: var(--space-2) 0; 132 | `; 133 | 134 | export const StorySegmentedButton = () => { 135 | const [valueBig, onChangeBig] = React.useState('foo'); 136 | const [valueSmall, onChangeSmall] = React.useState('bar'); 137 | return ( 138 | <> 139 |

The SegmentedButton component renders a set of radio buttons styled as a button group.

140 | {``} 141 | 142 | The button style that is applied to all buttons in the group. 143 | The button size that is applied to all buttons in the group. 144 | 145 | The id of the selected option. 146 | 147 | A callback function, which is called with the group's new selected value on change events. 148 | 149 | A list of values to display in the button group, where each object has the following props: 150 |
151 |
152 | id (required) 153 |
154 |
The value of the option when selected.
155 |
156 | label (required) 157 |
158 |
The content of the button segment representing the option.
159 |
160 |
161 |
162 | 163 | onChangeBig(id)} options={options} /> 164 | 165 | 166 | onChangeSmall(id)} options={options} /> 167 | 168 | 169 | ); 170 | }; 171 | -------------------------------------------------------------------------------- /lib/button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled, { css } from 'styled-components'; 4 | import { Icon } from './icon'; 5 | import { sizes } from './system'; 6 | import { CodeExample, PropsDefinition, Prop } from './story-utils'; 7 | 8 | const BaseButton = styled.button` 9 | appearance: none; 10 | color: inherit; 11 | background-color: transparent; 12 | border: 0; 13 | border-radius: 0; 14 | padding: 0; 15 | margin: 0; 16 | font-family: inherit; 17 | font-size: 100%; 18 | line-height: 1.15; 19 | text-transform: none; 20 | text-align: left; 21 | cursor: pointer; 22 | `; 23 | BaseButton.propTypes = { 24 | children: PropTypes.node.isRequired, 25 | onClick: PropTypes.func.isRequired, 26 | type: PropTypes.string, 27 | }; 28 | BaseButton.defaultProps = { 29 | type: 'button', 30 | }; 31 | 32 | const StyledButton = styled.span` 33 | display: inline-block; 34 | border-radius: var(--rounded); 35 | font-family: var(--fonts-sans); 36 | font-weight: 600; 37 | line-height: 1; 38 | position: relative; 39 | white-space: nowrap; 40 | &:disabled, 41 | button:disabled &, 42 | a:disabled & { 43 | opacity: 0.5; 44 | pointer-events: none; 45 | } 46 | // Most buttons will have capital letters, but many will not have descenders. 47 | // As a result, even padding on buttons frequently looks unbalanced, 48 | // so we apply extra padding to the top to correct this. 49 | // Note that this is font-specific and proportional to font size, 50 | // and at smaller sizes this is complicated by pixel rounding. 51 | padding: 0.375em 0.5em 0.1875em; 52 | text-decoration: none; 53 | 54 | ${({ variant }) => variants[variant]} 55 | ${({ size }) => sizes[size]} 56 | ${({ textWrap }) => 57 | textWrap && 58 | css` 59 | white-space: normal; 60 | `} 61 | `; 62 | 63 | const focus = (...args) => css` 64 | &:focus, 65 | button:focus &, 66 | a:focus & { 67 | box-shadow: 0 0 0 1px white, 0 0 0 3px var(--colors-focused); 68 | outline-color: transparent; 69 | color: var(--colors-selected-text); 70 | ${css(...args)} 71 | } 72 | `; 73 | 74 | const hover = (...args) => css` 75 | &:hover, 76 | button:hover &, 77 | a:hover & { 78 | ${css(...args)} 79 | text-decoration: none; 80 | } 81 | &:active, 82 | button:active &, 83 | a:active & { 84 | color: var(--colors-selected-text); 85 | background-color: var(--colors-selected-background); 86 | } 87 | `; 88 | 89 | const variants = { 90 | primary: css` 91 | color: var(--colors-primary); 92 | background-color: var(--colors-background); 93 | border: 2px solid var(--colors-primary); 94 | 95 | ${focus` 96 | background-color: var(--colors-selected-background); 97 | `} 98 | ${hover` 99 | background-color: var(--colors-secondaryBackground); 100 | `} 101 | `, 102 | secondary: css` 103 | color: var(--colors-secondary); 104 | background-color: var(--colors-background); 105 | border: 1px solid var(--colors-secondary); 106 | 107 | ${focus` 108 | background-color: var(--colors-selected-background); 109 | `} 110 | ${hover` 111 | background-color: var(--colors-secondaryBackground); 112 | `} 113 | `, 114 | cta: css` 115 | color: var(--colors-primary); 116 | background-color: var(--colors-cta); 117 | border: 2px solid var(--colors-primary); 118 | box-shadow: 4px 4px 0 var(--colors-primary); 119 | margin-right: 4px; 120 | 121 | ${hover` 122 | box-shadow: 2px 2px 0 var(--colors-primary); 123 | `} 124 | `, 125 | warning: css` 126 | color: var(--colors-secondary); 127 | background-color: var(--colors-background); 128 | border: 1px solid var(--colors-secondary); 129 | 130 | ${focus` 131 | background-color: var(--colors-warning-background); 132 | `} 133 | ${hover` 134 | color: var(--colors-warning-text); 135 | background-color: var(--colors-warning-background); 136 | `} 137 | `, 138 | }; 139 | 140 | export const UnstyledButton = styled(BaseButton).attrs(() => ({ 'data-module': 'UnstyledButton' }))``; 141 | export const Button = styled(StyledButton).attrs(() => ({ 'data-module': 'Button' }))``; 142 | 143 | Button.propTypes = { 144 | variant: PropTypes.oneOf(Object.keys(variants)), 145 | size: PropTypes.oneOf(Object.keys(sizes)), 146 | textWrap: PropTypes.bool, 147 | }; 148 | Button.defaultProps = { 149 | variant: 'primary', 150 | size: 'normal', 151 | textWrap: false, 152 | as: BaseButton, 153 | }; 154 | 155 | const Container = styled.div` 156 | margin: var(--space-1) auto; 157 | & > * { 158 | margin: 0 var(--space-1) var(--space-1) 0; 159 | } 160 | `; 161 | 162 | export const StoryButton_unstyled = () => ( 163 | <> 164 |

165 | The UnstyledButton component renders buttons that have the behaviors of {``} 179 | 180 | The button style: "primary", "secondary", "cta", "warning" (default "primary") -- see below for examples. 181 | The size of the text in the button: "tiny", "small", "normal", "big", "bigger", or "huge" (default "normal"). 182 | 183 | 184 | ); 185 | 186 | export const StoryButton_variants_and_sizes = () => ( 187 | <> 188 |

(Note that the actual variant / size names are in lowercase, but button text should be written in title case.)

189 | 190 | {['Primary', 'Secondary', 'CTA', 'Warning'].map((label) => ( 191 | 194 | ))} 195 | 196 | 197 | {['Tiny', 'Small', 'Normal', 'Big', 'Bigger', 'Huge'].map((label) => ( 198 | 201 | ))} 202 | 203 | 204 | ); 205 | 206 | const noop = () => {}; 207 | 208 | export const StoryButton_with_Icon = () => ( 209 | <> 210 |

Buttons can contain icons, loaders, and other inline graphics in addition to text.

211 | {``} 212 | 213 | 214 | 217 | 220 | 223 | 224 | 225 | ); 226 | 227 | const ProjectLink = styled.a` 228 | display: block; 229 | width: 300px; 230 | border-radius: var(--rounded); 231 | color: var(--colors-tertiary-text); 232 | background-color: var(--colors-tertiary-background); 233 | padding: var(--space-1); 234 | margin: var(--space-1) 0; 235 | text-decoration: none; 236 | `; 237 | 238 | export const StoryButton_as_link = () => ( 239 | <> 240 |

241 | Button styles can be applied to other elements, such as {``} or {`

`}. We can override the button's element 242 | with the "as" prop available on all styled components. 243 |

244 | {``} 245 | 246 | 249 | 250 | 251 | ); 252 | 253 | const ShadowButton = styled(Button)` 254 | ${hover` 255 | background-color: var(--colors-secondaryBackground); 256 | box-shadow: 4px 4px 0 var(--colors-primary); 257 | `} 258 | `; 259 | 260 | export const StoryButton_custom_styles = () => ( 261 | <> 262 |

Buttons can have custom styles via the "className" prop or by wrapping as a styled component.

263 | console.log('Button with Shadow')}>Button with Shadow 264 | 265 | ); 266 | 267 | export const StoryButton_as_decorative_element = () => ( 268 | <> 269 |

270 | Sometimes we use a button as a decorative element inside a link or other, larger interactive element. In this case, we can use the "as" prop to 271 | use a non-interactive element for the button. This decorative button will have its hover styles applied when you hover over the parent{' '} 272 | element. 273 |

274 | {``} 275 | 276 | 277 |

Feature: button component

278 |
279 | 280 | ); 281 | -------------------------------------------------------------------------------- /lib/callback-proxy.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const useCallbackProxy = (callback) => { 4 | const ref = React.useRef(callback); 5 | React.useEffect(() => { 6 | ref.current = callback; 7 | }, [callback]); 8 | const proxiedCallback = React.useCallback( 9 | (...args) => { 10 | ref.current(...args); 11 | }, 12 | [ref], 13 | ); 14 | if (!callback) return null; 15 | return proxiedCallback; 16 | }; 17 | -------------------------------------------------------------------------------- /lib/checkbox-button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { Button } from './button'; 5 | import { code, CodeExample, PropsDefinition, Prop } from './story-utils'; 6 | 7 | const Checkbox = styled.input` 8 | margin: 0 var(--space-1) 0 0; 9 | padding: 0; 10 | vertical-align: top; 11 | `; 12 | 13 | export const CheckboxButton = React.forwardRef(({ children, onChange, value, ...props }, ref) => ( 14 | 18 | )); 19 | 20 | CheckboxButton.displayName = 'CheckboxButton'; 21 | 22 | CheckboxButton.propTypes = { 23 | children: PropTypes.node.isRequired, 24 | onChange: PropTypes.func.isRequired, 25 | value: PropTypes.bool.isRequired, 26 | }; 27 | 28 | export const StoryCheckboxButton = () => { 29 | const [value, onChange] = React.useState(false); 30 | return ( 31 | <> 32 |

The CheckboxButton component renders a checkbox & label styled as a button.

33 | 34 | {code` 35 | setRefreshAppOnChanges(isChecked)}> 36 | Refresh App on Changes 37 | 38 | <`} 39 | 40 | 41 | 42 | Whether the button is checked. true or false. 43 | 44 | 45 | A callback function, which is called with the input's new value on change events. 46 | 47 | 48 |

All other props (e.g. 'variant', 'size') are passed to the button container.

49 | onChange(val)}> 50 | Refresh App on Changes 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /lib/checkbox.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | import { code, CodeExample, PropsDefinition, Prop } from './story-utils'; 6 | 7 | const CheckboxInput = styled.input` 8 | margin: 0 var(--space-1) 0 0; 9 | padding: 0; 10 | `; 11 | 12 | const CheckboxLabel = styled.label` 13 | display: flex; 14 | align-items: baseline; 15 | `; 16 | 17 | export const Checkbox = React.forwardRef(({ children, onChange, value, ...props }, ref) => ( 18 | 19 | onChange(evt.target.checked, evt)} ref={ref} /> 20 | {children} 21 | 22 | )); 23 | 24 | Checkbox.displayName = 'Checkbox'; 25 | Checkbox.propTypes = { 26 | children: PropTypes.node.isRequired, 27 | onChange: PropTypes.func.isRequired, 28 | value: PropTypes.bool.isRequired, 29 | }; 30 | 31 | export const StoryCheckbox = () => { 32 | const [value, onChange] = React.useState(false); 33 | return ( 34 | <> 35 |

The Checkbox component renders a checkbox & label.

36 | 37 | {code` 38 | setRefreshAppOnChanges(isChecked)}> 39 | Refresh App on Changes 40 | 41 | <`} 42 | 43 | 44 | 45 | Whether the button is checked. true or false. 46 | 47 | 48 | A callback function, which is called with the input's new value on change events. 49 | 50 | 51 | 52 | onChange(val)}> 53 | Refresh App on Changes 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /lib/editable-project-domain.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { OptimisticTextInput } from './optimistic-inputs'; 4 | import { CodeExample, PropsDefinition, Prop } from './story-utils'; 5 | import { Button } from './button'; 6 | 7 | /* 8 | Editable Project Domain (aka project name): 9 | - does not control permissions/authorization 10 | - has internal state: 11 | - this lets a user type out invalid and in-progress names without necessarily updating the page state 12 | - expects to be within external state that can be updated with onChange and onBlur: 13 | - onChange should be used to persist changes everytime the user types (also potentially updating the url in the browser so as to more gracefully handle refreshes) 14 | - onBlur should be used to update state globally (no need to cause global rerenders until we're ready) 15 | */ 16 | export const EditableProjectDomain = ({ initialName, onChange, onBlur }) => { 17 | const [headingState, setHeadingState] = useState(initialName); 18 | useEffect(() => { 19 | setHeadingState(initialName); 20 | }, [initialName]); 21 | const handleOnChange = async (newName) => { 22 | setHeadingState(newName); 23 | if (onChange) { 24 | await onChange(newName); 25 | } 26 | }; 27 | const handleOnBlur = () => { 28 | if (onBlur) { 29 | onBlur(headingState); 30 | } 31 | }; 32 | 33 | return ( 34 | 41 | ); 42 | }; 43 | EditableProjectDomain.propTypes = { 44 | initialName: PropTypes.string, 45 | onChange: PropTypes.func, 46 | onBlur: PropTypes.func, 47 | }; 48 | EditableProjectDomain.defaultProps = { 49 | initialName: '', 50 | onChange: null, 51 | onBlur: null, 52 | }; 53 | 54 | export const StoryEditableProjectDomain = () => { 55 | const [mockError, setMockError] = useState(false); 56 | return ( 57 | <> 58 |

59 | The EditableProjectDomain Component is a souped up OptimisticTextInput designed for when users want to edit their project domain. As with all 60 | OptimisticTextInputs if an error is thrown in the onChange or onBlur, it'll show a default error state (right now that's a little firetruck). 61 | To test that out use the button below and then type. 62 |

63 | 64 | The project domain as it is currently saved in state 65 | 66 | A callback function, which is called with the input's new value on change events. Should be used to persist changes everytime the user types 67 | (also potentially updating the url in the browser so as to more gracefully handle refreshes) 68 | 69 | 70 | A callback function, which is called with the user navigates away from the input. Should be used to update state throughout the page 71 | globally (no need to cause global rerenders until we're ready) 72 | 73 | 74 | {` console.log("saved to the backend")} 77 | onBlur={(() => console.log("we're done here, update everywhere"))} 78 | />`} 79 | 80 |
81 | { 84 | setMockError(false); 85 | if (mockError) { 86 | const fakeError = new Error(); 87 | fakeError.response = { 88 | data: { 89 | message: 'That name is already taken, please try a different one', 90 | }, 91 | }; 92 | throw fakeError; 93 | } 94 | console.log('saved to the backend'); 95 | }} 96 | onBlur={() => console.log("we're done here, update everywhere")} 97 | /> 98 |
99 | 100 |
101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /lib/hooks/use-debounced-value.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const useDebouncedValue = (value, timeout) => { 4 | const [debouncedValue, setDebouncedValue] = React.useState(value); 5 | 6 | React.useEffect(() => { 7 | const id = window.setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, timeout); 10 | return () => window.clearTimeout(id); 11 | }, [value]); 12 | 13 | return debouncedValue; 14 | }; 15 | 16 | export default useDebouncedValue; -------------------------------------------------------------------------------- /lib/hooks/use-optimistic-value.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import useDebouncedValue from './use-debounced-value'; 4 | 5 | /* 6 | 7 | Use Optimistic Value: 8 | 9 | - takes in an initial value for the input (this representes the real value the server last gave us) 10 | - takes in a way to update the server 11 | 12 | on change: 13 | - we show them what they are typing (or editing in case of checkbox, etc), and OPTIMISTICALLY assume it all went according to plan 14 | - if the server hits an error: 15 | - we display that error to the user 16 | - and we continue to show what the user's input even though it's not saved 17 | - if the server succeeds: 18 | - we pass along the response so that it can be stored in top level state later and passed back in again as props as the initial "real" value 19 | 20 | on blur: 21 | - if the user was in an errored state: 22 | - we show the last saved good state and remove the error 23 | 24 | */ 25 | 26 | export default function useOptimisticValue(realValue, onChange, onBlur) { 27 | // value undefined means that the field is unchanged from the 'real' value 28 | const [state, setState] = React.useState({ value: undefined, error: null }); 29 | 30 | // as the user types we save that as state.value, later as the user saves, we reset the state.value to undefined and instead show whatever value is passed in 31 | const optimisticOnChange = (newValue) => setState({ value: newValue, error: null }); 32 | 33 | // always show what the server knows, unless the user is currently typing something or we're loading an in-flight request 34 | let optimisticValue = realValue; 35 | if (state.value !== undefined) { 36 | optimisticValue = state.value; 37 | } 38 | 39 | const debouncedValue = useDebouncedValue(state.value, 500); 40 | 41 | React.useEffect(() => { 42 | const ifUserHasTypedSinceLastSave = debouncedValue !== undefined; 43 | 44 | if (ifUserHasTypedSinceLastSave) { 45 | // if the value changes during the async action then ignore the result 46 | const setStateIfStillRelevant = (newState) => setState((prevState) => (prevState.value === debouncedValue ? newState : prevState)); 47 | 48 | // this scope can't be async/await because it's an effect 49 | onChange(debouncedValue).then( 50 | () => { 51 | setStateIfStillRelevant({ value: undefined, error: null }); 52 | }, 53 | (error) => { 54 | const message = (error && error.response && error.response.data && error.response.data.message) || 'Sorry, we had trouble saving. Try again later?'; 55 | setStateIfStillRelevant({ value: debouncedValue, error: message }); 56 | }, 57 | ); 58 | } 59 | }, [debouncedValue]); 60 | 61 | const optimisticOnBlur = (event) => { 62 | // if you have already shown the user an error you can go ahead and hide it and revert back to last saved value 63 | if (state.error) { 64 | setState({ error: null, value: undefined }); 65 | } 66 | if (onBlur) { 67 | onBlur(event); 68 | } 69 | }; 70 | 71 | return [optimisticValue, optimisticOnChange, optimisticOnBlur, state.error]; 72 | } 73 | -------------------------------------------------------------------------------- /lib/hooks/use-passively-trimmed-input.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | // always show untrimmed version to user, always send out trimmed version to server, onBlur show what was sent to the server 4 | export default function usePassivelyTrimmedInput(rawInput, asyncUpdate, onBlur) { 5 | const [untrimmedValue, setUntrimmedValue] = useState(rawInput); 6 | 7 | const displayedInputValue = rawInput === untrimmedValue.trim() ? untrimmedValue : rawInput; 8 | 9 | const wrapAsyncUpdateWithTrimmedValue = (value) => { 10 | setUntrimmedValue(value); 11 | return asyncUpdate(value.trim()); 12 | }; 13 | 14 | const wrapOnBlur = (event) => { 15 | setUntrimmedValue(rawInput.trim()); 16 | if (onBlur) { 17 | onBlur(event); 18 | } 19 | }; 20 | 21 | return [displayedInputValue, wrapAsyncUpdateWithTrimmedValue, wrapOnBlur]; 22 | } 23 | -------------------------------------------------------------------------------- /lib/hooks/use-unique-id.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | let counter = 0; 4 | 5 | export const useUniqueId = () => { 6 | const [uniqueId] = useState(() => { 7 | counter += 1; 8 | return counter; 9 | }); 10 | return `unique-${uniqueId}`; 11 | }; 12 | 13 | export const resetUniqueId = () => { 14 | counter = 0; 15 | }; 16 | 17 | export default useUniqueId; 18 | -------------------------------------------------------------------------------- /lib/icon-button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { UnstyledButton } from './button'; 5 | import { Icon } from './icon'; 6 | import { CodeExample, PropsDefinition, Prop } from './story-utils'; 7 | 8 | const IconButtonWrap = styled.span` 9 | padding: var(--opticalPadding); 10 | border-radius: var(--rounded); 11 | display: inline-block; 12 | line-height: 1; 13 | &:hover { 14 | background-color: var(--colors-hover); 15 | } 16 | `; 17 | const ButtonIcon = styled(Icon)` 18 | color: var(--colors-secondary); 19 | &:hover { 20 | color: var(--colors-primary); 21 | } 22 | `; 23 | 24 | export const IconButton = React.forwardRef(({ icon, label, iconProps, ...props }, ref) => ( 25 | 26 | 27 | 28 | )); 29 | 30 | IconButton.displayName = 'IconButton'; 31 | 32 | IconButton.propTypes = { 33 | icon: PropTypes.string.isRequired, 34 | label: PropTypes.string.isRequired, 35 | iconProps: PropTypes.object, 36 | }; 37 | IconButton.defaultProps = { 38 | type: 'button', 39 | as: UnstyledButton, 40 | }; 41 | 42 | export const StoryIconButton = () => ( 43 | <> 44 |

The IconButton component renders an Icon as an accessible button with a label.

45 | {``} 46 | 47 | 48 | The name of the icon to render. 49 | 50 | 51 | The ARIA label of the button. 52 | 53 | Additional props to pass to the Icon component. 54 | 55 |

All other props are passed to the button wrapper.

56 | console.log('Close notification')} /> 57 |   58 | console.log('Project options')} /> 59 | 60 | ); 61 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | export { AnimationContainer, slideUp, slideDown } from './animation-container'; 2 | export { Avatar } from './avatar'; 3 | export { Badge } from './badge'; 4 | export { Title, Info, Actions, DangerZone } from './block'; 5 | export { ButtonGroup, ButtonSegment, SegmentedButton } from './button-group'; 6 | export { UnstyledButton, Button } from './button'; 7 | export { Checkbox } from './checkbox'; 8 | export { CheckboxButton } from './checkbox-button'; 9 | export { IconButton } from './icon-button'; 10 | export { Icon } from './icon'; 11 | export { LiveAnnouncer, LiveAnnouncerConsumer, useLiveAnnouncer } from './live-announcer'; 12 | export { Loader } from './loader'; 13 | export { Mark } from './mark'; 14 | export { Notification, NotificationsProvider, NotificationsConsumer, useNotifications } from './notification'; 15 | export { Overlay, useOverlay, mergeRefs } from './overlay'; 16 | export { OptimisticTextInput, OptimisticInput } from './optimistic-inputs'; 17 | export { Popover } from './popover'; 18 | export { Progress } from './progress'; 19 | export { createRemoteComponent } from './remote-component'; 20 | export { ResultsList, ResultItem, ResultName, ResultInfo, ResultDescription } from './results-list'; 21 | export { SearchResults } from './search-results'; 22 | export { RootStyle, LocalStyle } from './system'; 23 | export { TextInput, TextArea, WrappingTextInput } from './text-input'; 24 | export { EditableProjectDomain } from './editable-project-domain'; 25 | export { lightTheme, darkTheme } from './themes'; 26 | export { Toggle } from './toggle'; 27 | export { TooltipContainer } from './tooltip'; 28 | export { VisuallyHidden } from './visually-hidden'; 29 | export { useDebouncedValue } from './hooks/use-debounced-value'; 30 | export { useUniqueId, resetUniqueId } from './hooks/use-unique-id'; 31 | -------------------------------------------------------------------------------- /lib/keyboard-navigation.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useCallbackProxy } from './callback-proxy'; 3 | 4 | export const useEscape = (open, onClose) => { 5 | const memoOnClose = useCallbackProxy(onClose); 6 | 7 | React.useEffect(() => { 8 | if (!open) return undefined; 9 | 10 | const handler = (e) => { 11 | if (e.key === 'Escape') { 12 | e.preventDefault(); 13 | memoOnClose(e); 14 | } 15 | }; 16 | window.addEventListener('keydown', handler); 17 | return () => window.removeEventListener('keydown', handler); 18 | }, [open, memoOnClose]); 19 | }; 20 | 21 | export const useFocusTrap = () => { 22 | const first = React.useRef(); 23 | const last = React.useRef(); 24 | React.useEffect(() => { 25 | const handler = (e) => { 26 | if (e.key === 'Tab' && !e.shiftKey && e.target === last.current) { 27 | e.preventDefault(); 28 | first.current.focus(); 29 | } 30 | if (e.key === 'Tab' && e.shiftKey && e.target === first.current) { 31 | e.preventDefault(); 32 | last.current.focus(); 33 | } 34 | }; 35 | window.addEventListener('keydown', handler); 36 | return () => window.removeEventListener('keydown', handler); 37 | }, []); 38 | return { first, last }; 39 | }; 40 | 41 | export const onArrowKeys = (e, index, options) => { 42 | if (!options.length) return null; 43 | 44 | let offset = 0; 45 | if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { 46 | offset = -1; 47 | } else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { 48 | offset = 1; 49 | } 50 | if (offset === 0) return null; 51 | e.preventDefault(); 52 | return (index + offset + options.length) % options.length; 53 | }; 54 | -------------------------------------------------------------------------------- /lib/live-announcer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { VisuallyHidden } from './visually-hidden'; 3 | 4 | // see https://almerosteyn.com/2017/09/aria-live-regions-in-react 5 | 6 | const LiveAnnouncerContext = React.createContext(); 7 | export const LiveAnnouncerConsumer = LiveAnnouncerContext.Consumer; 8 | export const useLiveAnnouncer = ({ message, live }) => { 9 | const announce = React.useContext(LiveAnnouncerContext); 10 | React.useEffect(() => { 11 | if (announce) announce({ message, live }); 12 | }, [announce, message, live]); 13 | }; 14 | 15 | const MessageBlock = ({ live, message = '' }) => ( 16 |
17 | {message} 18 |
19 | ); 20 | 21 | const MessageRelay = ({ live, message }) => { 22 | const [counter, setCounter] = React.useState(0); 23 | const isOdd = counter & 1; 24 | React.useEffect(() => { 25 | setCounter((value) => value + 1); 26 | }, [message]); 27 | 28 | return ( 29 | <> 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export const LiveAnnouncer = ({ children }) => { 37 | const [politeMessage, announcePolite] = React.useState(''); 38 | const [assertiveMessage, announceAssertive] = React.useState(''); 39 | 40 | const contextValue = React.useCallback(({ message, live }) => { 41 | if (live === 'polite') announcePolite(message); 42 | if (live === 'assertive') announceAssertive(message); 43 | }, []); 44 | 45 | return ( 46 | <> 47 | 48 | 49 | 50 | 51 | {children} 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /lib/loader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | import { CodeExample } from './story-utils'; 4 | 5 | const Container = styled.span` 6 | display: inline-block; 7 | width: 0.75em; 8 | height: auto; 9 | object-fit: contain; 10 | `; 11 | 12 | const Mask = styled.span` 13 | overflow: hidden; 14 | display: inline-block; 15 | width: 100%; 16 | height: 0; 17 | padding-bottom: 100%; 18 | position: relative; 19 | border-radius: 100%; 20 | background-color: #000; 21 | transform: translate3d(0, 0, 0); 22 | `; 23 | 24 | const Circle = styled.span` 25 | position: absolute; 26 | mix-blend-mode: exclusion; 27 | height: var(--diameter); 28 | width: var(--diameter); 29 | border-radius: 100%; 30 | animation-timing-function: ease-out; 31 | animation-iteration-count: infinite; 32 | `; 33 | 34 | const Earth = styled(Circle)` 35 | @media (prefers-reduced-motion: reduce) { 36 | margin-left: 0; 37 | animation-direction: alternate; 38 | animation-name: ${keyframes` 39 | from { 40 | opacity: 0; 41 | } 42 | to { 43 | opacity: 1; 44 | } 45 | `}; 46 | } 47 | --diameter: 100%; 48 | background-color: pink; 49 | top: 50%; 50 | margin-top: -50%; 51 | margin-left: -50%; 52 | animation-duration: 3s; 53 | animation-name: ${keyframes` 54 | from { 55 | left: -60%; 56 | } 57 | to { 58 | left: 145%; 59 | } 60 | `}; 61 | `; 62 | 63 | const Moon = styled(Circle)` 64 | @media (prefers-reduced-motion: reduce) { 65 | display: none 66 | } 67 | --diameter: 100%; 68 | background-color: #fe84d4; 69 | top: 50%; 70 | margin-top: -50%; 71 | margin-left: -50%; 72 | animation-duration 2s; 73 | animation-name: ${keyframes` 74 | from { 75 | left: -40%; 76 | } 77 | to { 78 | left: 150%; 79 | } 80 | `}; 81 | `; 82 | 83 | const Asteroid = styled(Circle)` 84 | @media (prefers-reduced-motion: reduce) { 85 | display: none; 86 | } 87 | --diameter: 30%; 88 | background-color: MediumSpringGreen; 89 | top: 100%; 90 | margin-top: -50%; 91 | margin-left: -70%; 92 | animation-duration: 1.5s; 93 | animation-name: ${keyframes` 94 | from { 95 | left: -70%; 96 | } 97 | to { 98 | left: 170%; 99 | } 100 | `}; 101 | `; 102 | 103 | const AsteroidDust = styled(Circle)` 104 | @media (prefers-reduced-motion: reduce) { 105 | display: none; 106 | } 107 | --diameter: 25%; 108 | background-color: #b46bd2; 109 | top: 100%; 110 | margin-top: -70%; 111 | margin-left: -70%; 112 | animation-duration: 1.3s; 113 | animation-name: ${keyframes` 114 | from { 115 | left: -55%; 116 | } 117 | to { 118 | left: 170%; 119 | } 120 | `}; 121 | `; 122 | 123 | export const Loader = (props) => ( 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | ); 133 | 134 | export const StoryLoader = () => ( 135 | <> 136 |

The Loader component renders an inline loading indicator. Loaders have no component-specific props.

137 | {``} 138 |

By default, loaders match the size of their surrounding text.

139 |

140 | Loading… 141 |

142 |

143 | Loading… 144 |

145 |
146 | Loading… 147 |
148 |

Loader sizes can be overridden with css classes, inline styles, or styled components.

149 | {``} 150 | 151 | 152 | ); 153 | -------------------------------------------------------------------------------- /lib/mark.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { CodeExample, PropsDefinition, Prop } from './story-utils'; 5 | 6 | const MarkWrap = styled.span` 7 | display: inline-block; 8 | transform: rotate(-1deg); 9 | position: relative; 10 | left: calc(var(--rounded) * -1); 11 | z-index: 0; 12 | color: var(--text-color); 13 | background-color: var(--mark-color); 14 | padding: var(--rounded) calc(var(--rounded) * 2); 15 | border-radius: var(--rounded); 16 | `; 17 | const MarkText = styled.span` 18 | position: relative; 19 | display: inline-block; 20 | transform: rotate(1deg); 21 | `; 22 | 23 | export const Mark = ({ color, textColor, children, ...props }) => ( 24 | 25 | {children} 26 | 27 | ); 28 | Mark.propTypes = { 29 | color: PropTypes.string.isRequired, 30 | textColor: PropTypes.string, 31 | children: PropTypes.node.isRequired, 32 | }; 33 | Mark.defaultProps = { 34 | textColor: '#222', 35 | }; 36 | 37 | export const StoryMark = () => ( 38 | <> 39 |

The Mark component renders dark text with a colorful background stripe that resembles a highlighter.

40 | {`Spark your next project`} 41 | 42 | 43 | The color of the background strip, in any CSS color format. 44 | 45 | 46 | The text color, defaulting to #222. Note that text color is not inherited from the theme. 47 | 48 | 49 |

50 | Spark your next project 51 |

52 |

53 | Just start typing 54 |

55 |

56 | 57 | Code together 58 | 59 |

60 | 61 | ); 62 | -------------------------------------------------------------------------------- /lib/notification.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled, { css, keyframes } from 'styled-components'; 4 | import { LiveAnnouncer, useLiveAnnouncer } from './live-announcer'; 5 | import { UnstyledButton, Button } from './button'; 6 | import { Icon } from './icon'; 7 | import { Progress } from './progress'; 8 | import { code, CodeExample, PropsDefinition, Prop } from './story-utils'; 9 | 10 | const variants = { 11 | notice: css` 12 | color: var(--colors-notice-text); 13 | background-color: var(--colors-notice-background); 14 | `, 15 | success: css` 16 | color: var(--colors-success-text); 17 | background-color: var(--colors-success-background); 18 | `, 19 | error: css` 20 | color: var(--colors-error-text); 21 | background-color: var(--colors-error-background); 22 | `, 23 | onboarding: css` 24 | color: var(--colors-onboarding-text); 25 | background-color: var(--colors-onboarding-background); 26 | `, 27 | }; 28 | 29 | const CloseButton = styled(UnstyledButton)` 30 | flex: 0 0 auto; 31 | font-size: var(--fontSizes-small); 32 | margin-bottom: -1rem; 33 | `; 34 | 35 | const closingAnimation = keyframes` 36 | 0% { 37 | opacity: 1; 38 | } 39 | 100% { 40 | opacity: 0; 41 | } 42 | `; 43 | 44 | export const NotificationBase = styled.aside` 45 | display: flex; 46 | align-items: flex-start; 47 | font-size: var(--fontSizes-tiny); 48 | font-weight: 600; 49 | padding: var(--space-2) var(--space-2); 50 | border-radius: var(--rounded); 51 | animation-duration: 0.1s; 52 | animation-iteration-count: 1; 53 | animation-direction: forward; 54 | animation-fill-mode: forwards; 55 | animation-timing-function: ease-out; 56 | 57 | ${({ variant }) => variants[variant]} 58 | ${({ closing, timeout, persistent }) => { 59 | if (closing) { 60 | return css` 61 | animation-name: ${closingAnimation}; 62 | animation-delay: 0; 63 | &:focus, 64 | &:hover { 65 | animation-name: ${closingAnimation}; 66 | } 67 | `; 68 | } 69 | if (!persistent) { 70 | return css` 71 | animation-name: ${closingAnimation}; 72 | animation-delay: ${timeout}ms; 73 | &:focus, 74 | &:hover { 75 | animation-name: none; 76 | } 77 | `; 78 | } 79 | return null; 80 | }} 81 | & + & { 82 | margin-top: var(--space-1); 83 | } 84 | `; 85 | 86 | const NotificationContent = styled.div` 87 | flex: 1 1 auto; 88 | margin-right: var(--space-1); 89 | `; 90 | 91 | export const Notification = ({ message, live, children, variant, timeout, onClose, persistent, ...props }) => { 92 | const ref = React.useRef(); 93 | const [closing, setClosing] = React.useState(false); 94 | 95 | useLiveAnnouncer({ message: `${variant}: ${message}`, live }); 96 | 97 | const handleAnimationEnd = (event) => { 98 | if (onClose && event.target === ref.current) onClose(); 99 | }; 100 | return ( 101 | 111 | {children || message} 112 | {onClose && ( 113 | { 115 | event.stopPropagation; 116 | setClosing(true); 117 | }} 118 | aria-label="Dismiss notification" 119 | > 120 | 121 | 122 | )} 123 | 124 | ); 125 | }; 126 | 127 | Notification.propTypes = { 128 | message: PropTypes.string.isRequired, 129 | live: PropTypes.oneOf(['polite', 'assertive']), 130 | children: PropTypes.node, 131 | variant: PropTypes.oneOf(Object.keys(variants)), 132 | timeout: PropTypes.number, 133 | onClose: PropTypes.func, 134 | persistent: PropTypes.bool, 135 | }; 136 | 137 | Notification.defaultProps = { 138 | variant: 'notice', 139 | live: 'polite', 140 | timeout: 2500, 141 | }; 142 | 143 | export const StoryNotification = () => ( 144 | <> 145 |

The Notification component renders an accessible notification.

146 | {``} 147 | 148 | 149 | The text of the notification. 150 | 151 | An optional rich-content version of the notification that can include formatting and interactive elements. 152 | The aria-live behavior of the notification: "polite" or "assertive" (default "polite"). 153 | The style of notification: "notice", "success", "error", "onboarding" (default "notice"). 154 | 155 | The time in milliseconds before the notification automatically fades out and calls its "onClose" prop. If no value is provided, the 156 | notification will close after the default period specified in the default props. 157 | 158 | 159 | A callback function, called when the close button is clicked or the notification times out. This props is provided by the{' '} 160 | createNotification callback. If no value is provided, the notification will not render a close button. 161 | 162 | 163 | A boolean value. If true, any timeout value is ignored and the Notification will remain until removed either programmatically or by a user 164 | action. If no value is provided, the Notification will be removed after the timeout period. 165 | 166 | 167 |

168 | 169 | ); 170 | 171 | let currentID = 0; 172 | const uniqueID = (prefix) => { 173 | currentID += 1; 174 | return `${prefix}-${currentID}`; 175 | }; 176 | 177 | const NotificationContext = React.createContext(); 178 | export const NotificationsConsumer = NotificationContext.Consumer; 179 | export const useNotifications = () => React.useContext(NotificationContext); 180 | 181 | const NotificationsContainer = styled.div` 182 | position: fixed; 183 | z-index: var(--z-notifications); 184 | top: 0; 185 | right: 0; 186 | max-width: 18rem; 187 | min-width: 12rem; 188 | padding: var(--space-1); 189 | `; 190 | 191 | export const NotificationsProvider = ({ children, ...props }) => { 192 | const [notifications, setNotifications] = React.useState([]); 193 | const removeNotification = (id) => { 194 | setNotifications((prevNotifications) => prevNotifications.filter((n) => n.id !== id)); 195 | }; 196 | 197 | const contextValue = React.useMemo(() => { 198 | const createNotification = (Component) => { 199 | const notification = { id: uniqueID('notification'), Component }; 200 | setNotifications((prevNotifications) => [...prevNotifications, notification]); 201 | return notification; 202 | }; 203 | 204 | const createErrorNotification = (message = 'Something went wrong. Try refreshing?') => { 205 | return createNotification((props) => ); 206 | }; 207 | 208 | return { createNotification, createErrorNotification, removeNotification }; 209 | }, []); 210 | 211 | return ( 212 | 213 | {children} 214 | {notifications.length > 0 && ( 215 | 216 | {notifications.map(({ id, Component }) => ( 217 | removeNotification(id)} /> 218 | ))} 219 | 220 | )} 221 | 222 | ); 223 | }; 224 | 225 | const useProgressMock = () => { 226 | const [{ value }, setState] = React.useState({ value: 0, cancelled: false }); 227 | const cancel = () => setState((prev) => ({ ...prev, cancelled: true })); 228 | React.useEffect(() => { 229 | let handle; 230 | const update = () => { 231 | setState((prev) => { 232 | if (prev.cancelled || prev.value >= 100) return prev; 233 | handle = window.setTimeout(update, 100); 234 | return { 235 | ...prev, 236 | value: Math.min(100, prev.value + Math.random() * 10), 237 | }; 238 | }); 239 | }; 240 | update(); 241 | return () => window.clearTimeout(handle); 242 | }, []); 243 | 244 | return { value, cancel }; 245 | }; 246 | 247 | const UploadNotification = ({ onClose }) => { 248 | const { value, cancel } = useProgressMock(); 249 | const cancelAndClose = () => { 250 | cancel(); 251 | onClose(); 252 | }; 253 | 254 | if (value < 100) { 255 | return ( 256 | 257 | 258 | {value}% 259 | 260 |   Uploading… 261 | 262 | ); 263 | } 264 | 265 | return ( 266 | 267 | 268 | {value}% 269 | 270 |   Uploaded! 271 | 272 | ); 273 | }; 274 | 275 | const Content = () => { 276 | const { createNotification, createErrorNotification, removeNotification } = useNotifications(); 277 | const [lastNotificationId, setLastNotificationId] = React.useState(null); 278 | 279 | return ( 280 | <> 281 | 289 |   290 | 298 | 299 | )); 300 | setLastNotificationId(notification.id); 301 | }} 302 | > 303 | Create Success Notification 304 | 305 |   306 | 314 |   315 | 325 |   326 | 329 | 330 | )); 331 | setLastNotificationId(notification.id); 332 | }} 333 | > 334 | Create Onboarding Notification 335 | 336 | 345 | 346 | ); 347 | }; 348 | 349 | export const StoryNotificationsProvider_and_useNotifications = () => ( 350 | <> 351 |

The NotificationsProvider component renders a floating container for notifications, and provides a context for creating notifications.

352 | {``} 353 |

The context provides an object with the properties "createNotification" and "createErrorNotification".

354 | {code` 355 | const { createNotification, createErrorNotification } = useNotifications() 356 | 357 | createNotification((props) => ( 358 | 363 |

Added power-passenger to collection linen-collection

364 | 367 |
368 | ) 369 | 370 | createErrorNotification('Something went wrong. Try refreshing?') 371 | `}
372 |

context props

373 |
374 |
createNotification
375 |
A function that takes a component or render prop that renders a notification, and displays it in the floating notification container.
376 |
createErrorNotification
377 |
A function that takes an error message, and displays it as a transient error notification in the floating notification container.
378 |
379 | 380 | 381 | 382 | 383 | ); 384 | -------------------------------------------------------------------------------- /lib/optimistic-inputs.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { TextInput, WrappingTextInput } from './text-input'; 4 | 5 | import { code, CodeExample, PropsDefinition, Prop } from './story-utils'; 6 | import usePassivelyTrimmedInput from './hooks/use-passively-trimmed-input'; 7 | import useOptimisticValue from './hooks/use-optimistic-value'; 8 | 9 | export const OptimisticTextInput = ({ value, onChange, onBlur, ...props }) => ( 10 | 17 | ); 18 | 19 | OptimisticTextInput.propTypes = { 20 | label: PropTypes.string.isRequired, 21 | value: PropTypes.string.isRequired, 22 | onChange: PropTypes.func.isRequired, 23 | onBlur: PropTypes.func, 24 | }; 25 | 26 | export const StoryOptimisticTextInput = () => { 27 | const [successInputState, setSuccessInputState] = useState(''); 28 | const [failedInputState, setFailedInputState] = useState(''); 29 | return ( 30 | <> 31 |

32 | The OptimisticTextInput component renders a controlled text input{' '} 33 | whose validity depends on a server call. It relies on useOptimsticValue which works like this: 34 |

35 |
    36 |
  • takes in an initial value for the input (this representes the real value the server last gave us)
  • 37 |
  • takes in a way to update the server
  • 38 |
39 | on change: 40 |
    41 |
  • we show them what they are typing (or editing in case of checkbox, etc), and OPTIMISTICALLY assume it all went according to plan
  • 42 |
  • 43 | if the server hits an error: 44 |
      45 |
    • we display that error to the user
    • 46 |
    • and we continue to show what the user's input even though it's not saved
    • 47 |
    48 |
  • 49 |
  • 50 | if the server succeeds: 51 |
      52 |
    • 53 | we pass along the response so that it can be stored in top level state later and passed back in again as props as the initial "real" 54 | value 55 |
    • 56 |
    57 |
  • 58 |
  • 59 | on blur: 60 |
      if the user was in an errored state, we show the last saved good state and remove the error
    61 |
  • 62 |
63 | 64 | {code` 65 | const [inputState, setInputState] = useState(); 66 | 67 | { 71 | // value changed; validate it with the server 72 | await validateWithApi(); // should return a promise that either resolves or fails with an API error 73 | setInputState(newValue); 74 | }} 75 | /> 76 | { 80 | // value changed; validate it with the server 81 | // lets pretend this one is going to fail; the rejection should look like this 82 | await Promise.reject({response: {data: {message: "It's an error"}}}); 83 | setInputState(newValue); 84 | }} 85 | /> 86 | `} 87 | 88 | 89 | 90 | Label for the text input 91 | 92 | 93 | Value of the text input. 94 | 95 | 96 | Function to call when the value changes. 97 | 98 | 99 | 100 | { 104 | // value changed; validate it with the server 105 | await Promise.resolve(); 106 | setSuccessInputState(newValue); 107 | }} 108 | /> 109 | { 113 | // value changed; validate it with the server 114 | // lets pretend this one failed 115 | await Promise.reject({ response: { data: { message: "It's an error" } } }); 116 | setFailedInputState(newValue); 117 | }} 118 | /> 119 | 120 | ); 121 | }; 122 | 123 | export const OptimisticInput = ({ Component, value, onChange, onBlur, ...props }) => { 124 | const [untrimmedValue, onChangeWithTrimmedInputs, onBlurWithTrimmedInputs] = usePassivelyTrimmedInput(value, onChange, onBlur); 125 | const [optimisticValue, optimisticOnChange, optimisticOnBlur, optimisticError] = useOptimisticValue( 126 | untrimmedValue, 127 | onChangeWithTrimmedInputs, 128 | onBlurWithTrimmedInputs, 129 | ); 130 | 131 | return ; 132 | }; 133 | 134 | OptimisticInput.propTypes = { 135 | value: PropTypes.string.isRequired, 136 | onChange: PropTypes.func.isRequired, 137 | Component: PropTypes.elementType.isRequired, 138 | onBlur: PropTypes.func, 139 | }; 140 | 141 | export default OptimisticInput; 142 | 143 | export const Story_OptimisticInput = () => { 144 | const [successInputState, setSuccessInputState] = useState(''); 145 | const [failedInputState, setFailedInputState] = useState(''); 146 | return ( 147 | <> 148 |

149 | Use this component to make any kind of input you want into one that behaves like Optimistic Text Input. 150 |

151 | 152 | {code` 153 | const [inputState, setInputState] = useState(); 154 | 155 | { 160 | // value changed; validate it with the server 161 | await validateWithApi(); // should return a promise that either resolves or fails with an API error 162 | setInputState(newValue); 163 | }} 164 | /> 165 | { 170 | // value changed; validate it with the server 171 | // lets pretend this one is going to fail; the rejection should look like this 172 | await Promise.reject({response: {data: {message: "It's an error"}}}); 173 | setInputState(newValue); 174 | }} 175 | /> 176 | `} 177 | 178 | 179 | 180 | Component to use as the base. Should accept props including at least value, error, onChange, and{' '} 181 | onBlur. 182 | 183 | 184 | Label for the text input 185 | 186 | 187 | Value of the text input. 188 | 189 | 190 | Function to call when the value changes. 191 | 192 | 193 | 194 |
195 | { 200 | // value changed; validate it with the server 201 | await Promise.resolve(); 202 | setSuccessInputState(newValue); 203 | }} 204 | /> 205 | { 210 | // value changed; validate it with the server 211 | // lets pretend this one failed 212 | await Promise.reject({ response: { data: { message: "It's an error" } } }); 213 | setFailedInputState(newValue); 214 | }} 215 | /> 216 |
217 | 218 | ); 219 | }; 220 | -------------------------------------------------------------------------------- /lib/overlay.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled, { createGlobalStyle, css } from 'styled-components'; 4 | import { Button } from './button'; 5 | import { Title, Info, Actions } from './block'; 6 | import { useEscape, useFocusTrap } from './keyboard-navigation'; 7 | import { code, CodeExample, PropsDefinition, Prop } from './story-utils'; 8 | 9 | const OverlayWrap = styled.div` 10 | position: fixed; 11 | top: 0; 12 | left: 0; 13 | width: 100%; 14 | height: 100%; 15 | padding: var(--space-4) var(--space-1); 16 | margin: 0; 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | z-index: var(--z-overlay); 21 | background-color: rgba(255, 255, 255, 0.5); 22 | overflow-x: auto; 23 | -webkit-overflow-scrolling: touch; 24 | `; 25 | 26 | const OverlayContent = styled.dialog.attrs(() => ({ 27 | 'data-module': 'OverlayContent', 28 | open: true, 29 | 'aria-modal': true, 30 | }))` 31 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 32 | background-color: var(--colors-background); 33 | color: var(--colors-primary); 34 | border-radius: var(--rounded); 35 | overflow: auto; 36 | padding: 0; 37 | ${({ variant }) => variants[variant]}; 38 | `; 39 | 40 | const variants = { 41 | normal: css` 42 | position: relative; 43 | border: 2px var(--colors-primary) solid; 44 | box-shadow: var(--popShadow); 45 | font-size: var(--fontSizes-small); 46 | margin: 0; 47 | width: 100%; 48 | max-width: 640px; 49 | `, 50 | banner: css` 51 | position: absolute; 52 | border: 1px var(--colors-border) solid; 53 | margin: 0 auto; 54 | padding: 16px; 55 | top: 0px; 56 | max-width: 500px; 57 | `, 58 | }; 59 | 60 | const FixedBodyStyle = createGlobalStyle` 61 | body { 62 | position: fixed; 63 | width: 100%; 64 | top: -${({ scrollY }) => scrollY}px; 65 | } 66 | `; 67 | 68 | // Toggling between fixed and static position on the body resets the scroll position, 69 | // so we need to store the original position, offset the body when the overlay is open, and reset it when its closed. 70 | const FixedBody = () => { 71 | const [scrollY, setScrollY] = React.useState(null); 72 | React.useEffect(() => { 73 | const prevScrollPosition = window.scrollY; 74 | setScrollY(prevScrollPosition); 75 | return () => { 76 | window.setTimeout(() => window.scrollTo(0, prevScrollPosition), 0); 77 | }; 78 | }, []); 79 | return scrollY === null ? null : ; 80 | }; 81 | 82 | const useFocusOnMount = (open) => { 83 | const ref = React.useRef(); 84 | React.useEffect(() => { 85 | if (open) ref.current.focus(); 86 | }, [open]); 87 | return ref; 88 | }; 89 | 90 | export const mergeRefs = (...refs) => (el) => { 91 | for (const ref of refs) { 92 | ref.current = el; 93 | } 94 | }; 95 | 96 | OverlayContent.defaultProps = { 97 | variant: 'normal', 98 | }; 99 | // https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal 100 | export const Overlay = ({ open, onClose, children, variant, contentProps, ...props }) => { 101 | useEscape(open, onClose); 102 | const { first, last } = useFocusTrap(); 103 | const focusedOnMount = useFocusOnMount(open); 104 | 105 | const onClickBackground = (e) => { 106 | if (e.currentTarget === e.target) { 107 | onClose(e); 108 | } 109 | }; 110 | 111 | if (!open) return null; 112 | 113 | return ( 114 | 115 | 116 | 117 | {children({ onClose, first, last, focusedOnMount })} 118 | 119 | 120 | ); 121 | }; 122 | 123 | Overlay.propTypes = { 124 | open: PropTypes.bool.isRequired, 125 | children: PropTypes.func.isRequired, 126 | onClose: PropTypes.func.isRequired, 127 | contentProps: PropTypes.object, 128 | variant: PropTypes.oneOf(Object.keys(variants)), 129 | }; 130 | 131 | const ButtonContainer = styled.div` 132 | margin: 0 auto; 133 | & > * { 134 | margin: 0 var(--space-1) var(--space-1) 0; 135 | } 136 | `; 137 | 138 | export const useOverlay = () => { 139 | const [open, setOpen] = React.useState(false); 140 | const toggleRef = React.useRef(); 141 | const onOpen = () => setOpen(true); 142 | const onClose = () => { 143 | setOpen(false); 144 | toggleRef.current.focus(); 145 | }; 146 | return { open, onOpen, onClose, toggleRef }; 147 | }; 148 | 149 | export const StoryOverlay = () => { 150 | const normalOverlay = useOverlay(); 151 | const bannerOverlay = useOverlay(); 152 | 153 | return ( 154 | <> 155 |

156 | The Overlay component renders an accessible modal. See blocks to use with Overlays 157 | . 158 |

159 | 160 | {code` 161 | 162 | {({ onClose, first, last, focusedOnMount }) => ( 163 | <> 164 | 165 | <Actions> 166 | <Button ref={mergeRefs(last, focusedOnMount)} onClick={submitAndClose}>OK</Button> 167 | </Actions> 168 | </> 169 | )} 170 | </Overlay> 171 | `} 172 | </CodeExample> 173 | <PropsDefinition> 174 | <Prop name="open" required> 175 | Whether the overlay is visible. <code>true</code> or <code>false</code>. 176 | </Prop> 177 | <Prop name="onClose" required> 178 | A callback function called to close the overlay. 179 | </Prop> 180 | <Prop name="variant">The overlay's visual style: "normal" (default) or "banner."</Prop> 181 | <Prop name="children" required> 182 | A render prop, which passes in an object with the following properties: 183 | <dl> 184 | <dt>onClose</dt> 185 | <dd>The same "onClose" as above.</dd> 186 | <dt>first</dt> 187 | <dd> 188 | A ref to the <em>first</em> focusable element in the overlay (for trapping focus). 189 | </dd> 190 | <dt>last</dt> 191 | <dd> 192 | A ref to the <em>last</em> focusable element in the overlay (for trapping focus). 193 | </dd> 194 | <dt>focusedOnMount</dt> 195 | <dd>A ref to the focusable element that will be focused when the overlay is opened.</dd> 196 | </dl> 197 | <p> 198 | If some of these refs refer to the same object, you can combine them with the <code>mergeRefs</code> function. 199 | </p> 200 | </Prop> 201 | </PropsDefinition> 202 | <ButtonContainer> 203 | <Button onClick={normalOverlay.onOpen} ref={normalOverlay.toggleRef}> 204 | Show Normal Overlay 205 | </Button> 206 | <Button onClick={bannerOverlay.onOpen} ref={bannerOverlay.toggleRef}> 207 | Show Banner Overlay 208 | </Button> 209 | </ButtonContainer> 210 | {/* Normal Overlay */} 211 | <Overlay open={normalOverlay.open} onClose={normalOverlay.onClose}> 212 | {({ onClose, first, last, focusedOnMount }) => ( 213 | <> 214 | <Title onClose={onClose} onCloseRef={first}> 215 | Example Overlay 216 | 217 | 218 |

219 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce aliquet faucibus augue quis laoreet. Ut cursus venenatis nisl, ut 220 | ullamcorper lectus. Nunc nec lacus sem. Donec nulla arcu, dignissim id feugiat id, varius nec leo. Donec sit amet ultrices magna. 221 | Proin quis metus quis metus vulputate posuere et eget quam. Ut nunc ante, convallis ac ornare nec, porttitor id lectus. Suspendisse 222 | orci urna, placerat a pulvinar at, porta quis ante. Suspendisse eleifend mauris in tincidunt hendrerit. Praesent sagittis dui eu metus 223 | consectetur, in sagittis dui euismod. Aenean massa libero, pellentesque eu metus ut, varius ultricies orci. Vestibulum sit amet magna 224 | aliquam, semper tortor vitae, vehicula eros. Aliquam quis elementum dui. Integer in nisl quis est aliquet commodo vitae in eros. 225 | Integer et metus dapibus sem viverra pretium. Quisque et elit nisl. 226 |

227 |
228 | 229 | 232 |   233 | 236 | 237 | 238 | )} 239 |
240 | {/* Banner Overlay */} 241 | 242 | {({ onClose, first, last, focusedOnMount }) => ( 243 | <> 244 | 245 | Example Banner Overlay 246 | 247 | 248 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce aliquet faucibus augue quis laoreet. Ut cursus venenatis nisl, ut 249 | ullamcorper lectus. Nunc nec lacus sem. Donec nulla arcu, dignissim id feugiat id, varius nec leo. Donec sit amet ultrices magna. Proin 250 | quis metus quis metus vulputate posuere et eget quam. Ut nunc ante, convallis ac ornare nec, porttitor id lectus. 251 | 252 | 253 | 254 | 257 |   258 | 261 | 262 | 263 | 264 | )} 265 | 266 | 267 | ); 268 | }; 269 | 270 | export const Story_useOverlay_hook = () => ( 271 | <> 272 |

The useOverlay hook provides helpers for accessibly managing the relationship between an Overlay and the button that opens it.

273 | 274 | {code` 275 | const { open, onOpen, onClose, toggleRef } = useOverlay(); 276 | // ... 277 | 278 | // ... 279 | 280 | {(overlayProps) => } 281 | 282 | `} 283 | 284 | 285 | ); 286 | -------------------------------------------------------------------------------- /lib/popover.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled, { css } from 'styled-components'; 4 | import { Button } from './button'; 5 | import { Title, Info, Actions, DangerZone } from './block'; 6 | import { useCallbackProxy } from './callback-proxy'; 7 | import { useEscape } from './keyboard-navigation'; 8 | import { code, CodeExample, PropsDefinition, Prop } from './story-utils'; 9 | 10 | const debounce = (fn, timeout) => { 11 | let handle; 12 | return (...args) => { 13 | if (handle) window.clearTimeout(handle); 14 | handle = window.setTimeout(() => fn(...args), timeout); 15 | }; 16 | }; 17 | 18 | const usePositionAdjustment = () => { 19 | const [offset, setOffset] = React.useState({ top: 0, left: 0 }); 20 | const ref = React.useRef(); 21 | React.useLayoutEffect(() => { 22 | const setPosition = () => { 23 | if (ref.current) { 24 | const rect = ref.current.getBoundingClientRect(); 25 | if (rect) { 26 | if (rect.left < 0) { 27 | setOffset((prevOffset) => ({ ...prevOffset, left: -rect.left })); 28 | } else if (rect.right > window.innerWidth) { 29 | setOffset((prevOffset) => ({ ...prevOffset, left: window.innerWidth - rect.right })); 30 | } else { 31 | setOffset((prevOffset) => ({ ...prevOffset, left: 0 })); 32 | } 33 | } 34 | } 35 | }; 36 | const debounced = debounce(setPosition, 300); 37 | window.addEventListener('resize', debounced); 38 | setPosition(); 39 | return () => window.removeEventListener('resize', debounced); 40 | }, []); 41 | return { ref, offset }; 42 | }; 43 | 44 | const PopoverWrap = styled.div` 45 | position: absolute; 46 | width: auto; 47 | max-width: 350px; 48 | ${({ align }) => alignments[align]} 49 | `; 50 | 51 | const alignments = { 52 | left: css` 53 | left: 0; 54 | `, 55 | right: css` 56 | right: 0; 57 | `, 58 | topLeft: css` 59 | left: 0; 60 | bottom: 100%; 61 | `, 62 | topRight: css` 63 | right: 0; 64 | bottom: 100%; 65 | `, 66 | }; 67 | 68 | const PopoverInner = styled.div` 69 | position: relative; 70 | overflow: hidden; 71 | background-color: var(--colors-background); 72 | font-size: var(--fontSizes-small); 73 | border-radius: var(--rounded); 74 | text-align: left; 75 | max-width: 100%; 76 | 77 | border: 1px solid var(--colors-border); 78 | box-shadow: var(--popShadow); 79 | z-index: 10; 80 | `; 81 | 82 | const PopoverContent = ({ align, children, ...props }) => { 83 | const { ref, offset } = usePositionAdjustment(); 84 | return ( 85 | 86 | 87 | {children} 88 | 89 | 90 | ); 91 | }; 92 | 93 | const PopoverContainer = styled.div` 94 | position: relative; 95 | display: inline-block; 96 | `; 97 | 98 | const useClickOutside = (open, onClickOutside) => { 99 | const ref = React.useRef(); 100 | const onClickOutsideProxy = useCallbackProxy(onClickOutside); 101 | React.useEffect(() => { 102 | if (!open) return; 103 | const handler = (e) => { 104 | if (document.body.contains(e.target) && !ref.current.contains(e.target)) { 105 | onClickOutsideProxy(e); 106 | } 107 | }; 108 | window.addEventListener('click', handler); 109 | return () => { 110 | window.removeEventListener('click', handler); 111 | }; 112 | }, [open, onClickOutsideProxy]); 113 | return ref; 114 | }; 115 | 116 | const useStack = (defaultState) => { 117 | const [stack, setStack] = React.useState([defaultState]); 118 | const top = stack[stack.length - 1]; 119 | const push = (value) => setStack((prev) => prev.concat([value])); 120 | const pop = () => setStack((prev) => prev.slice(0, -1)); 121 | const clear = () => setStack([defaultState]); 122 | return { top, push, pop, clear }; 123 | }; 124 | 125 | export const Popover = ({ align, renderLabel, views = {}, initialView, startOpen, children, contentProps, ...props }) => { 126 | const [open, setOpen] = React.useState(startOpen); 127 | const focusedOnMount = React.useRef(); 128 | const toggleRef = React.useRef(); 129 | const { top: activeView, push: setActiveView, pop: onBack, clear } = useStack(initialView); 130 | const activeViewFunc = views[activeView] || children; 131 | const onOpen = () => { 132 | setOpen(true); 133 | clear(); 134 | }; 135 | const onClose = () => { 136 | setOpen(false); 137 | clear(); 138 | }; 139 | React.useEffect(() => { 140 | if (open && focusedOnMount.current) focusedOnMount.current.focus(); 141 | }, [open, activeView]); 142 | 143 | const rootRef = useClickOutside(open, onClose); 144 | 145 | const onToggle = () => { 146 | if (open) { 147 | onClose(); 148 | } else { 149 | onOpen(); 150 | } 151 | }; 152 | // if a user has tabbed inside a popover and presses escape they should return back to the popover button 153 | const returnFocusOnClose = () => { 154 | onClose() 155 | toggleRef.current.focus(); 156 | } 157 | 158 | useEscape(open, returnFocusOnClose); 159 | 160 | // see https://www.w3.org/TR/wai-aria-practices-1.1/#menubutton 161 | return ( 162 | 163 | {renderLabel({ onClick: onToggle, ref: toggleRef })} 164 | {open && ( 165 | 166 | {activeViewFunc({ setActiveView, onClose, onBack, focusedOnMount })} 167 | 168 | )} 169 | 170 | ); 171 | }; 172 | 173 | Popover.propTypes = { 174 | align: PropTypes.oneOf(Object.keys(alignments)).isRequired, 175 | renderLabel: PropTypes.func.isRequired, 176 | views: PropTypes.objectOf(PropTypes.func.isRequired), 177 | initialView: PropTypes.string, 178 | children: PropTypes.func.isRequired, 179 | contentProps: PropTypes.object, 180 | startOpen: PropTypes.bool, 181 | }; 182 | 183 | const WidePopover = styled.div` 184 | width: 22rem; 185 | max-width: 100%; 186 | `; 187 | 188 | export const StoryPopover = () => ( 189 | <> 190 |

The Popover component renders accessible and nestable popovers. See blocks to use with Popovers.

191 | 192 | {code` 193 | } 196 | > 197 | {({ onClose }) => ( 198 | 199 | )} 200 | 201 | `} 202 | 203 | 204 | 205 | The edge of the label that the button aligns with. Options are "left", "right", "topLeft", "topRight". 206 | 207 | 208 | A render prop, which passing in an object with the following properties: 209 |
210 |
onClick
211 |
A callback function to toggle the popover.
212 |
ref
213 |
A ref for the toggle button, so that it is focused when the popover closes.
214 |
215 |
216 | An object mapping the names of secondary popover pages to functions that render them. 217 | If provided, the popover will open in this view instead of the one in the "children" prop. 218 | 219 | A render prop, which passes in an object with the following properties: 220 |
221 |
setActiveView
222 |
A callback function to set the active view of the popover.
223 |
onClose
224 |
A callback function called to close the popover.
225 |
onBack
226 |
A callback function called to render the previous view.
227 |
focusedOnMount
228 |
A ref to the focusable element that will be focused when the overlay is opened.
229 |
230 |

These properties are also passed to the functions in the "views" object.

231 |
232 | Additional props passed to the popover toggle button. 233 | Additional props passed to the popover content wrapper. 234 | Should the popover be open when the component is first rendered 235 |
236 | }> 237 | {({ onClose, focusedOnMount }) => ( 238 | 239 | 240 | Delete @glitch 241 | 242 | 243 |

244 | Deleting this team will remove this team page. No projects will be deleted, but only current project members will be able to edit them. 245 |

246 |
247 | 248 | 251 | 252 |
253 | )} 254 |
255 | 256 | ); 257 | 258 | const ActionsStack = styled(Actions)` 259 | > * + * { 260 | margin-top: var(--space-1); 261 | display: block; 262 | } 263 | `; 264 | 265 | /* eslint-disable react/display-name */ 266 | export const StoryPopover_with_Multiple_Views = () => ( 267 | <> 268 |

Popover components can render multi-page popovers using the "views" prop.

269 | 270 | {code` 271 | } 273 | align="right" 274 | views={{ 275 | switchProject: ({ onClose, onBack }) => ( 276 | 277 | ), 278 | }}> 279 | {({ onClose, setActiveView }) => ( 280 | 281 | )} 282 | 283 | `} 284 | 285 | } 288 | views={{ 289 | foo: ({ onClose, onBack, focusedOnMount }) => ( 290 | 291 | 292 | Foo 293 | 294 | Foo content goes here 295 | 296 | ), 297 | bar: ({ setActiveView, onClose, onBack, focusedOnMount }) => ( 298 | 299 | 300 | Bar 301 | 302 | Bar content goes here 303 | 304 | 307 | 308 | 309 | ), 310 | }} 311 | > 312 | {({ setActiveView, onClose, focusedOnMount }) => ( 313 | 314 | Multi-Page Popover 315 | 316 | 319 | 320 | 321 | 322 | )} 323 | 324 | 325 | ); 326 | /* eslint-enable react/display-name */ 327 | -------------------------------------------------------------------------------- /lib/progress.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { VisuallyHidden } from './visually-hidden'; 5 | import { CodeExample, PropsDefinition, Prop } from './story-utils'; 6 | 7 | const ProgressBase = styled.progress` 8 | appearance: none; 9 | display: block; 10 | height: 0.75em; 11 | width: 100%; 12 | border: 1px solid currentColor; 13 | background-color: transparent; // firefox 14 | border-radius: var(--rounded); 15 | 16 | // ugly prefixes sadly required 17 | &::-webkit-progress-bar { 18 | background-color: transparent; 19 | } 20 | &::-webkit-progress-value { 21 | background-color: currentColor; 22 | border-radius: calc(var(--rounded) - 2px); 23 | } 24 | &::-moz-progress-bar { 25 | background-color: currentColor; 26 | border-radius: calc(var(--rounded) - 2px); 27 | } 28 | `; 29 | 30 | export const Progress = ({ children, ...props }) => ( 31 | <> 32 | {children} 33 |