diff --git a/.github/workflows/build_doc_prs.yaml b/.github/workflows/build_doc_prs.yaml index 88a3ac5fa1..bbe84c159e 100644 --- a/.github/workflows/build_doc_prs.yaml +++ b/.github/workflows/build_doc_prs.yaml @@ -12,6 +12,10 @@ jobs: steps: - uses: actions/checkout@v2 - name: Build docs + env: + UNLEASH_FEEDBACK_TARGET_URL: ${{ secrets.DOCS_FEEDBACK_TARGET_URL }} + UNLEASH_PROXY_CLIENT_KEY: ${{ secrets.UNLEASH_PROXY_CLIENT_KEY_DEVELOPMENT }} + UNLEASH_PROXY_URL: ${{ secrets.UNLEASH_PROXY_URL_DEVELOPMENT }} run: | # Build the site cd website && yarn && yarn build diff --git a/.github/workflows/generate-docs.yaml b/.github/workflows/generate-docs.yaml index 0bba02af07..941b9430ef 100644 --- a/.github/workflows/generate-docs.yaml +++ b/.github/workflows/generate-docs.yaml @@ -25,3 +25,6 @@ jobs: GH_EMAIL: 'ivarconr@gmail.com' GH_TOKEN: ${{ secrets.GH_TOKEN }} DEPLOYMENT_BRANCH: 'main' + UNLEASH_FEEDBACK_TARGET_URL: ${{ secrets.DOCS_FEEDBACK_TARGET_URL }} + UNLEASH_PROXY_CLIENT_KEY: ${{ secrets.UNLEASH_PROXY_CLIENT_KEY_PRODUCTION }} + UNLEASH_PROXY_URL: ${{ secrets.UNLEASH_PROXY_URL_PRODUCTION }} diff --git a/website/.storybook/main.js b/website/.storybook/main.js new file mode 100644 index 0000000000..a964a6a81e --- /dev/null +++ b/website/.storybook/main.js @@ -0,0 +1,78 @@ +module.exports = { + stories: [ + '../src/**/*.stories.mdx', + '../src/**/*.stories.@(js|jsx|ts|tsx)', + ], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + 'storybook-addon-root-attribute/register', + ], + framework: '@storybook/react', + staticDirs: [ + { + from: '../static', + to: '/', + }, + ], + webpackFinal: async (config) => { + const path = require('path'); + + config.resolve.alias = { + ...config.resolve.alias, + '@site': path.resolve(__dirname, '../'), + '@docusaurus': path.resolve( + __dirname, + '../', + 'node_modules', + '@docusaurus', + 'core', + 'lib', + 'client', + 'exports', + ), + '@generated': path.resolve(__dirname, '../', '.docusaurus'), + }; + + let cssRules = []; + const rules = config.module.rules.map((rule) => { + if (rule.test.toString() === '/\\.css$/') { + cssRules.push(JSON.parse(JSON.stringify(rule))); + + return { + ...rule, + exclude: /\.module\.css$/, + }; + } else return rule; + }); + + cssRules.forEach((r) => { + const moduleRule = { + ...r, + test: /\.module\.css$/, + use: r.use.map((use) => { + if ( + typeof use === 'object' && + use.loader.includes('/css-loader/') + ) { + use.options = { + ...use.options, + modules: true, + }; + } + return use; + }), + }; + rules.push(moduleRule); + }); + + return { + ...config, + module: { + ...config.module, + rules, + }, + }; + }, +}; diff --git a/website/.storybook/preview.js b/website/.storybook/preview.js new file mode 100644 index 0000000000..194008022b --- /dev/null +++ b/website/.storybook/preview.js @@ -0,0 +1,34 @@ +import '../src/css/custom.css'; +import '../node_modules/infima/dist/css/default/default.css'; +import '../node_modules/@docusaurus/theme-classic/lib/admonitions.css'; +import { withRootAttribute } from 'storybook-addon-root-attribute'; + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + layout: 'fullscreen', + + // add docusaurus theming to storybook iframes + rootAttribute: { + root: 'html', + attribute: 'data-theme', + defaultState: { + name: 'Light', + value: 'light', + }, + states: [ + { + name: 'Dark', + value: 'dark', + }, + ], + tooltip: true, + }, +}; + +export const decorators = [withRootAttribute]; diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 108a0272a1..91854f10ed 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -10,6 +10,13 @@ module.exports = { organizationName: 'Unleash', // Usually your GitHub org/user name. projectName: 'unleash.github.io', // Usually your repo name. trailingSlash: false, + customFields: { + // expose env vars etc here + unleashProxyUrl: process.env.UNLEASH_PROXY_URL, + unleashProxyClientKey: process.env.UNLEASH_PROXY_CLIENT_KEY, + unleashFeedbackTargetUrl: process.env.UNLEASH_FEEDBACK_TARGET_URL, + environment: process.env.NODE_ENV, + }, themeConfig: { defaultMode: 'light', disableSwitch: true, @@ -159,7 +166,7 @@ module.exports = { { from: '/advanced/impression_data', to: '/advanced/impression-data', - } + }, ], createRedirects: function (toPath) { if ( diff --git a/website/package.json b/website/package.json index a67a482584..2480968541 100644 --- a/website/package.json +++ b/website/package.json @@ -11,7 +11,9 @@ "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids" + "write-heading-ids": "docusaurus write-heading-ids", + "storybook": "start-storybook -p 6006", + "build-storybook": "build-storybook" }, "dependencies": { "@docusaurus/core": "2.0.0-beta.15", @@ -25,6 +27,7 @@ "file-loader": "6.2.0", "react": "17.0.2", "react-dom": "17.0.2", + "unleash-proxy-client": "^1.11.0", "url-loader": "4.1.1" }, "resolutions": { @@ -47,5 +50,19 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@babel/core": "^7.17.4", + "@docusaurus/module-type-aliases": "^2.0.0-beta.16", + "@storybook/addon-actions": "^6.4.19", + "@storybook/addon-essentials": "^6.4.19", + "@storybook/addon-interactions": "^6.4.19", + "@storybook/addon-links": "^6.4.19", + "@storybook/react": "^6.4.19", + "@storybook/testing-library": "^0.0.9", + "@tsconfig/docusaurus": "^1.0.4", + "babel-loader": "^8.2.3", + "storybook-addon-root-attribute": "^1.0.2", + "typescript": "^4.6.2" } } diff --git a/website/src/components/UserFeedback/UserFeedback.stories.jsx b/website/src/components/UserFeedback/UserFeedback.stories.jsx new file mode 100644 index 0000000000..fc6132fb02 --- /dev/null +++ b/website/src/components/UserFeedback/UserFeedback.stories.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { initialData, FeedbackWrapper } from './index'; + +export default { + title: 'User feedback component', + component: FeedbackWrapper, +}; + +const Template = (args) => ; + +export const Step1 = Template.bind({}); +Step1.args = { + open: true, + seedData: { + currentStep: 1, + }, +}; + +export const Step2 = Template.bind({}); +Step2.args = { + seedData: { + currentStep: 2, + }, + open: true, +}; + +export const Step3 = Template.bind({}); +Step3.args = { + seedData: { + currentStep: 3, + }, + open: true, +}; + +export const Step4 = Template.bind({}); +Step4.args = { + seedData: { + currentStep: 4, + }, + open: true, +}; + +export const WithLocalStorage = Template.bind({}); +WithLocalStorage.args = { + open: true, +}; + +export const Closed = Template.bind({}); diff --git a/website/src/components/UserFeedback/index.tsx b/website/src/components/UserFeedback/index.tsx new file mode 100644 index 0000000000..cdb86f2ec7 --- /dev/null +++ b/website/src/components/UserFeedback/index.tsx @@ -0,0 +1,531 @@ +import React from 'react'; +import styles from './styles.module.css'; +import CloseIcon from '@site/src/icons/close'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; + +const join = (...cs: string[]) => cs.join(' '); + +type CustomerType = 'open source' | 'paying'; + +type FormData = { + score: number; + comment: undefined | string; + customerType: undefined | CustomerType; +}; + +type InitialData = { + currentStep: number; + data: { + score: undefined | number; + comment: undefined | string; + customerType: undefined | CustomerType; + }; + closedOrCompleted: boolean; +}; + +type CompleteData = InitialData & { + initialized: number; +}; + +const clearedData: InitialData = { + currentStep: 1, + data: { + score: undefined, + comment: undefined, + customerType: undefined, + }, + closedOrCompleted: false, +}; + +const localstorageKey = 'user-feedback-v1'; +const populateData = (initialData: InitialData): CompleteData => { + // if we get seed data, use that. Otherwise, check if the last entry in + // localstorage was completed. If not, use that as base. + + const getSeedData = () => { + if (initialData) { + return initialData; + } + + const userFeedbackLog = getUserDataRecord(); + + if (userFeedbackLog) { + const mostRecentTimestamp = Math.max( + ...Object.keys(userFeedbackLog).map(parseInt), + ); + const mostRecent = userFeedbackLog[mostRecentTimestamp]; + if (mostRecent && !mostRecent.closedOrCompleted) { + return mostRecent; + } + } + + return {}; + }; + + const seedData = getSeedData(); + + return { + currentStep: 1, + ...seedData, + data: { + score: undefined, + comment: undefined, + customerType: undefined, + ...seedData?.data, + }, + initialized: Date.now(), + }; +}; + +const getUserDataRecord = () => + JSON.parse(localStorage.getItem(localstorageKey)); + +const storeData = (data: CompleteData) => { + const existingData = getUserDataRecord(); + localStorage.setItem( + localstorageKey, + JSON.stringify({ + ...existingData, + [data.initialized]: data, + }), + ); +}; + +type Message = + | { kind: 'close' } + | { kind: 'completed' } + | { kind: 'reset' } + | { kind: 'set score'; data: number } + | { kind: 'set comment'; data: string } + | { kind: 'set customer type'; data: CustomerType } + | { kind: 'step forward' } + | { kind: 'step back' }; + +const stateReducer = (state: CompleteData, message: Message) => { + switch (message.kind) { + case 'close': + return { ...state, closedOrCompleted: true }; + case 'completed': + return { ...state, closedOrCompleted: true }; + case 'reset': + return { ...populateData(clearedData), closedOrCompleted: false }; + case 'set score': + return { + ...state, + data: { ...state.data, score: message.data }, + }; + case 'set comment': + return { + ...state, + data: { ...state.data, comment: message.data }, + }; + case 'set customer type': + return { + ...state, + data: { ...state.data, customerType: message.data }, + }; + case 'step forward': + return { + ...state, + currentStep: Math.min(state.currentStep + 1, 4), + }; + case 'step back': + return { + ...state, + currentStep: Math.max(state.currentStep - 1, 1), + }; + } +}; + +type Props = { + seedData?: InitialData; + open?: boolean; +}; + +export const FeedbackWrapper: React.FC = ({ seedData, open }) => { + const { + siteConfig: { customFields }, + } = useDocusaurusContext(); + + const feedbackTargetUrl: string | undefined = + (customFields?.unleashFeedbackTargetUrl as string | undefined) ?? + (typeof process !== 'undefined' && + process?.env?.UNLEASH_FEEDBACK_TARGET_URL); + + const [feedbackIsOpen, setFeedbackIsOpen] = React.useState(open); + const [manuallyOpened, setManuallyOpened] = React.useState(open); + + const [state, dispatch] = React.useReducer( + stateReducer, + seedData, + populateData, + ); + + const close = () => dispatch({ kind: 'close' }); + if (feedbackIsOpen) { + storeData(state); + } + + const stepForward = () => { + dispatch({ kind: 'step forward' }); + }; + const stepBack = () => { + dispatch({ kind: 'step back' }); + }; + const setScore = (score: number) => + dispatch({ kind: 'set score', data: score }); + const setComment = (comment: string) => + dispatch({ kind: 'set comment', data: comment }); + const setCustomerType = (customerType: CustomerType) => + dispatch({ kind: 'set customer type', data: customerType }); + + const submitFeedback = (data: FormData) => { + if (feedbackTargetUrl) { + fetch(feedbackTargetUrl, { + method: 'post', + body: JSON.stringify({ + data: { + ...data, + openedManually: manuallyOpened, + currentPage: location.pathname, + }, + }), + headers: { + 'content-type': 'application/json', + }, + }) + .then(async (res) => + res.ok + ? console.log('Success! Feedback was registered.') + : console.warn( + `Oh, no! The feedback registration failed: ${await res.text()}`, + ), + ) + .catch((e) => + console.error( + 'Oh, no! The feedback registration failed:', + e, + ), + ); + } else { + console.warn( + 'No target url specified for feedback. Not doing anything.', + ); + } + dispatch({ kind: 'completed' }); + stepForward(); + }; + + const visuallyHidden = (stepNumber: number) => + state.currentStep !== stepNumber; + const isHidden = (stepNumber: number) => + !feedbackIsOpen || visuallyHidden(stepNumber); + + const Step1 = () => { + const hidden = isHidden(1); + const [newValue, setNewValue] = React.useState(state.data.score); + return ( +
{ + e.preventDefault(); + setScore(newValue); + stepForward(); + }} + aria-hidden={hidden} + > +
+

+ + On a scale from 1 to 5 where 1 is very unsatisfied + and 5 is very satisfied, + {' '} + How would you rate your overall satisfaction with the + Unleash documentation? +

+ +
+ + + {[1, 2, 3, 4, 5].map((n, i) => ( + + { + const value = parseInt( + e.target.value, + ); + setNewValue(value); + }} + autoFocus={ + manuallyOpened + ? state.data.score + ? state.data.score === n + : i === 0 + : false + } + /> + + + ))} + + +
+
+ +
+
+
+ ); + }; + + const Step2 = () => { + const hidden = isHidden(2); + const textareaId = 'feedback-comment-input'; + const saveComment = () => + setComment( + (document.getElementById(textareaId) as HTMLTextAreaElement) + .value, + ); + + return ( +
{ + e.preventDefault(); + saveComment(); + stepForward(); + }} + > +
+ + + +
+ + + +
+
+
+ ); + }; + + const Step3 = () => { + const hidden = isHidden(3); + const [value, setValue] = React.useState( + state.data.customerType, + ); + + return ( +
{ + e.preventDefault(); + setCustomerType(value); + + // To ensure that we get the correct customer type included. + // We can't rely on the reducer to set it because it won't + // happen until the component re-renders, causing customer + // type to have an old or empty value. + const finalState = stateReducer(state, { + kind: 'set customer type', + data: value, + }); + submitFeedback(finalState.data); + }} + > +
+ + Finally, are you a paying customer or an open source + customer of Unleash? + +
+ {[ + ['a', 'paying', 'paying'], + ['an', 'open source', 'opensource'], + ].map(([article, customerType, key], i) => ( + + { + setValue(customerType as CustomerType); + }} + /> + + + ))} +
+ +
+ + +
+
+
+ ); + }; + + const Step4 = () => { + const hidden = isHidden(4); + return ( +
+

Thank you! 🙌

+ +
+ ); + }; + + return ( +
+ + +
+
+ +
+
+ + + + +
+
+
+ ); +}; + +export default FeedbackWrapper; diff --git a/website/src/components/UserFeedback/styles.module.css b/website/src/components/UserFeedback/styles.module.css new file mode 100644 index 0000000000..7c39eb2f43 --- /dev/null +++ b/website/src/components/UserFeedback/styles.module.css @@ -0,0 +1,244 @@ +.user-feedback-container { + --outline-style: 2px solid var(--ifm-color-primary); + --row-gap: 1rem; + --element-horizontal-gap: 1rem; + + --animation-duration: 0.25s; + --fade-out-transition: opacity var(--animation-duration); + --fade-in-transition: opacity var(--animation-duration) + calc(var(--animation-duration) / 2); +} + +@media screen and (prefers-reduced-motion: reduced) { + .user-feedback-container { + --animation-duration: 0; + } +} + +.user-feedback { + width: 100%; + position: fixed; + background: var(--ifm-background-color); + bottom: 0; + border: var(--ifm-global-border-width) solid var(--unleash-color-gray); + border-radius: var(--ifm-global-radius) var(--ifm-global-radius) 0 0; + box-shadow: var(--ifm-global-shadow-lw); + padding: var(--ifm-spacing-vertical) var(--ifm-spacing-horizontal); + text-align: center; + transition: var(--fade-in-transition); +} + +.user-feedback fieldset { + border: none; + margin: 0; + padding: 0; + width: 100%; +} + +:is(.user-feedback, .user-feedback fieldset) > * + * { + margin-top: var(--row-gap); +} + +.user-feedback button { + border: none; + border-radius: var(--ifm-global-radius); + padding: var(--ifm-spacing-vertical) calc(var(--ifm-spacing-horizontal) / 2); +} + +.user-feedback form > * + * { + margin-top: var(--row-gap); +} + +.hidden { + display: none; +} + +.user-feedback-container * { + outline-offset: 4px; +} + +.user-feedback-container *:focus-visible { + outline: var(--outline-style); +} + +.satisfaction-input-container { + display: flex; + flex-flow: wrap; + place-content: center; + align-items: center; + gap: var(--element-horizontal-gap); +} + +.satisfaction-input-inputs { + display: flex; + flex-flow: wrap; + place-content: center; + align-items: center; + gap: var(--element-horizontal-gap); +} + +.satisfaction-input-visual-label { + display: none; +} + +@media screen and (min-width: 800px) { + .satisfaction-input-visual-label { + display: inline; + } +} + +@media screen and (max-width: 400px) { + .satisfaction-input-inputs { + gap: calc(var(--element-horizontal-gap) / 2); + } +} + +.user-satisfaction-score-label { + display: grid; + place-content: center; + height: 3em; + width: 3em; + border: var(--ifm-global-border-width) solid currentColor; + border-radius: 50%; +} + +.user-satisfaction-score-input:focus-visible + .user-satisfaction-score-label { + outline: var(--outline-style); +} + +.user-satisfaction-score-label:hover { + color: var(--ifm-color-primary); +} + +.user-satisfaction-score-input:checked + label { + color: var(--ifm-color-primary); + background: var(--ifm-color-primary); + color: var(--ifm-color-primary-contrast-background); + border-color: var(--ifm-color-primary); +} + +.button-container { + margin-top: var(--row-gap); + display: flex; + flex-direction: row-reverse; + justify-content: end; + gap: var(--element-horizontal-gap); +} + +button.close-button { + background: none; + border: none; + border-radius: 50%; + padding: 0; + aspect-ratio: 1; + height: 1em; + color: var(--ifm-font-color-base); +} + +.close-button:hover { + color: var(--ifm-color-primary); +} + +.close-button:active { + color: var(--ifm-color-primary-darker); +} + +.close-button-row { + display: flex; + justify-content: end; +} + +.close-button svg { + fill: currentColor; +} + +.primary-button, +.user-feedback button[type='submit'] { + background-color: var(--ifm-color-primary); + color: var(--ifm-background-color); + padding-inline: calc(var(--ifm-spacing-horizontal) * 4); +} + +.primary-button:hover, +.user-feedback button[type='submit']:hover { + background-color: var(--ifm-color-primary-lighter); +} + +.primary-button:hover, +.user-feedback button[type='submit']:active { + background-color: var(--ifm-color-primary-dark); +} + +.button-secondary { + color: var(--ifm-color-primary); + background: none; +} + +.button-secondary:active { + color: var(--ifm-color-primary-darker); +} + +.button-secondary:hover { + color: var(--ifm-color-primary-lightest); +} + +.user-feedback textarea { + display: block; + width: 100%; + background-color: var(--ifm-background-color); + color: currentColor; + border-radius: var(--ifm-global-radius); + border: var(--ifm-global-border-width) solid var(--ifm-color-emphasis-400); + font-style: normal; + font-family: inherit; + padding: var(--ifm-spacing-vertical) var(--ifm-spacing-horizontal); +} + +.customer-type-inputs { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: var(--ifm-spacing-horizontal); + accent-color: var(--ifm-color-primary); +} + +.open-feedback-button { + padding-block: var(--ifm-spacing-vertical); + padding-inline: var(--ifm-spacing-horizontal); + border-radius: 0 var(--ifm-global-radius) var(--ifm-global-radius) 0; + border: none; + position: fixed; + bottom: 25vh; + right: 0; + transition: var(--fade-in-transition); + transform: rotate(180deg); + writing-mode: vertical-lr; +} + +/* note: Chrome doesn't support writing-mode on buttons, so we need to add a + span for the text and change the writing-mode there. Simultaneously, Firefox + does some weird stuff with the padding of the text if writing-mode isn't + specified on the button itself, so we need to set that too. */ +.open-feedback-button > span { + writing-mode: vertical-lr; +} + +.invisible, +.open-feedback-button[disabled] { + opacity: 0; + transition: var(--fade-out-transition); + pointer-events: none; +} + +.form-section-container { + display: grid; + align-items: center; + max-width: 850px; + margin: auto; +} + +.form-section-container > * { + grid-column: 1; + grid-row: 1; + transition: var(--fade-in-transition); +} diff --git a/website/src/css/custom.css b/website/src/css/custom.css index dd92dbc69f..e0fcf975db 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -20,21 +20,22 @@ footer { } html[data-theme='light'] { - --ifm-color-primary: #39535b; - --ifm-color-primary-dark: #334b52; - --ifm-color-primary-darker: #30474d; - --ifm-color-primary-darkest: #283a40; - --ifm-color-primary-light: #3f5b64; - --ifm-color-primary-lighter: #425f69; - --ifm-color-primary-lightest: #4a6c76; + --ifm-color-primary-lightest: #8783d2; + --ifm-color-primary-lighter: #7b76ce; + --ifm-color-primary-light: #6f6ac9; + --ifm-color-primary: var(--unleash-color-purple); + --ifm-color-primary-dark: #5953be; + --ifm-color-primary-darker: #4f4ab7; + --ifm-color-primary-darkest: #4540b0; - --ifm-link-color: var(--unleash-color-purple); --ifm-menu-color-background-active: var(--unleash-color-gray); --ifm-menu-color-background-hover: var(--unleash-color-gray); --unleash-color-admonition-background: var(--unleash-color-gray); --unleash-color-admonition-border: #999; --unleash-color-admonition-text: #2b2b2b; + + --ifm-background-color: #fff; } html[data-theme='dark'] { @@ -62,6 +63,18 @@ html[data-theme='dark'] { --docsearch-primary-color: var(--ifm-color-primary-darkest); } +.visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: auto; + margin: 0; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + white-space: nowrap; +} + main img { background: var(--unleash-img-background-color); display: block; diff --git a/website/src/icons/close.tsx b/website/src/icons/close.tsx new file mode 100644 index 0000000000..4bb6d4de54 --- /dev/null +++ b/website/src/icons/close.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import styles from './styles.module.css'; + +const svg = () => ( + + + +); + +const Icon = svg; + +export default Icon; diff --git a/website/src/icons/styles.module.css b/website/src/icons/styles.module.css new file mode 100644 index 0000000000..6bcf15829d --- /dev/null +++ b/website/src/icons/styles.module.css @@ -0,0 +1,5 @@ +.icon { + fill: currentColor; + border-radius: 50%; + aspect-ratio: 1; +} diff --git a/website/src/stories/Button.jsx b/website/src/stories/Button.jsx new file mode 100644 index 0000000000..15dde39209 --- /dev/null +++ b/website/src/stories/Button.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './button.css'; + +/** + * Primary UI component for user interaction + */ +export const Button = ({ primary, backgroundColor, size, label, ...props }) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); +}; + +Button.propTypes = { + /** + * Is this the principal call to action on the page? + */ + primary: PropTypes.bool, + /** + * What background color to use + */ + backgroundColor: PropTypes.string, + /** + * How large should the button be? + */ + size: PropTypes.oneOf(['small', 'medium', 'large']), + /** + * Button contents + */ + label: PropTypes.string.isRequired, + /** + * Optional click handler + */ + onClick: PropTypes.func, +}; + +Button.defaultProps = { + backgroundColor: null, + primary: false, + size: 'medium', + onClick: undefined, +}; diff --git a/website/src/stories/Button.stories.jsx b/website/src/stories/Button.stories.jsx new file mode 100644 index 0000000000..61f6e19e14 --- /dev/null +++ b/website/src/stories/Button.stories.jsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { Button } from './Button'; + +// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +export default { + title: 'Example/Button', + component: Button, + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: { + backgroundColor: { control: 'color' }, + }, +}; + +// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args +const Template = (args) =>