Merge pull request #77 from snowball-tools/ayungavis/T-4834-button

[T-4834: feat] Button component
This commit is contained in:
Wahyu Kurniawan 2024-02-20 23:23:38 +07:00 committed by GitHub
commit 69198307fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 565 additions and 30 deletions

39
packages/frontend/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,39 @@
{
// eslint extension options
"eslint.enable": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"css.customData": [".vscode/tailwind.json"],
// prettier extension setting
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.rulers": [80],
"editor.codeActionsOnSave": [
"source.addMissingImports",
"source.fixAll",
"source.organizeImports"
],
// Show in vscode "Problems" tab when there are errors
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
// Use absolute import for typescript files
"typescript.preferences.importModuleSpecifier": "non-relative",
// IntelliSense for taiwind variants
"tailwindCSS.experimental.classRegex": [
["tv\\((([^()]*|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
]
}

View File

@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@fontsource/inter": "^5.0.16",
"@material-tailwind/react": "^2.1.7",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@ -29,6 +30,7 @@
"react-router-dom": "^6.20.1",
"react-scripts": "5.0.1",
"react-timer-hook": "^3.0.7",
"tailwind-variants": "^0.2.0",
"typescript": "^4.9.5",
"usehooks-ts": "^2.10.0",
"vertical-stepper-nav": "^1.0.2",

View File

@ -1,9 +1,8 @@
import React, { useState } from 'react';
import { Card, Collapse, Typography } from '@material-tailwind/react';
import { Environment, EnvironmentVariable } from 'gql-client/dist/src/types';
import EditEnvironmentVariableRow from './EditEnvironmentVariableRow';
import { Environment, EnvironmentVariable } from 'gql-client';
interface DisplayEnvironmentVariablesProps {
environment: Environment;

View File

@ -0,0 +1,162 @@
import { tv } from 'tailwind-variants';
import type { VariantProps } from 'tailwind-variants';
/**
* Defines the theme for a button component.
*/
export const buttonTheme = tv(
{
base: [
'h-fit',
'inline-flex',
'items-center',
'justify-center',
'whitespace-nowrap',
'focus-ring',
'disabled:cursor-not-allowed',
],
variants: {
size: {
lg: ['gap-2', 'py-3.5', 'px-5', 'text-base', 'tracking-[-0.011em]'],
md: ['gap-2', 'py-3.25', 'px-5', 'text-sm', 'tracking-[-0.006em]'],
sm: ['gap-1', 'py-2', 'px-3', 'text-xs'],
xs: ['gap-1', 'py-1', 'px-2', 'text-xs'],
},
fullWidth: {
true: 'w-full',
},
shape: {
default: '',
rounded: 'rounded-full',
},
iconOnly: {
true: '',
},
variant: {
primary: [
'text-elements-on-primary',
'border',
'border-transparent',
'bg-controls-primary',
'shadow-button',
'hover:bg-controls-primary-hovered',
'focus-visible:bg-controls-primary-hovered',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
secondary: [
'text-elements-on-secondary',
'border',
'border-transparent',
'bg-controls-secondary',
'hover:bg-controls-secondary-hovered',
'focus-visible:bg-controls-secondary-hovered',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
tertiary: [
'text-elements-on-tertiary',
'border',
'border-border-interactive/10',
'bg-transparent',
'hover:bg-controls-tertiary-hovered',
'hover:border-border-interactive-hovered',
'hover:border-border-interactive-hovered/[0.14]',
'focus-visible:bg-controls-tertiary-hovered',
'focus-visible:border-border-interactive-hovered',
'focus-visible:border-border-interactive-hovered/[0.14]',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
ghost: [
'text-elements-on-tertiary',
'border',
'border-transparent',
'bg-transparent',
'hover:bg-controls-tertiary-hovered',
'hover:border-border-interactive-hovered',
'hover:border-border-interactive-hovered/[0.14]',
'focus-visible:bg-controls-tertiary-hovered',
'focus-visible:border-border-interactive-hovered',
'focus-visible:border-border-interactive-hovered/[0.14]',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
danger: [
'text-elements-on-danger',
'border',
'border-transparent',
'bg-border-danger',
'hover:bg-controls-danger-hovered',
'focus-visible:bg-controls-danger-hovered',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
'danger-ghost': [
'text-elements-danger',
'border',
'border-transparent',
'bg-transparent',
'hover:bg-controls-tertiary-hovered',
'hover:border-border-interactive-hovered',
'hover:border-border-interactive-hovered/[0.14]',
'focus-visible:bg-controls-tertiary-hovered',
'focus-visible:border-border-interactive-hovered',
'focus-visible:border-border-interactive-hovered/[0.14]',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
unstyled: [],
},
},
compoundVariants: [
{
size: 'lg',
iconOnly: true,
class: ['py-3.5', 'px-3.5'],
},
{
size: 'md',
iconOnly: true,
class: ['py-3.25', 'px-3.25'],
},
{
size: 'sm',
iconOnly: true,
class: ['py-2', 'px-2'],
},
{
size: 'xs',
iconOnly: true,
class: ['py-1', 'px-1'],
},
],
defaultVariants: {
size: 'md',
variant: 'primary',
fullWidth: false,
iconOnly: false,
shape: 'rounded',
},
},
{
responsiveVariants: true,
},
);
/**
* Represents the type of a button theme, which is derived from the `buttonTheme` variant props.
*/
export type ButtonTheme = VariantProps<typeof buttonTheme>;

View File

@ -0,0 +1,178 @@
import React, { forwardRef, useCallback } from 'react';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { buttonTheme } from './Button.theme';
import type { ButtonTheme } from './Button.theme';
import { Link } from 'react-router-dom';
import { cloneIcon } from 'utils/cloneIcon';
/**
* Represents the properties of a base button component.
*/
interface ButtonBaseProps {
/**
* The optional left icon element for a component.
* @type {ReactNode}
*/
leftIcon?: ReactNode;
/**
* The optional right icon element to display.
* @type {ReactNode}
*/
rightIcon?: ReactNode;
}
/**
* Interface for the props of a button link component.
*/
export interface ButtonLinkProps
extends Omit<ComponentPropsWithoutRef<'a'>, 'color'> {
/**
* Specifies the optional property `as` with a value of `'a'`.
* @type {'a'}
*/
as?: 'a';
/**
* Indicates whether the item is external or not.
* @type {boolean}
*/
external?: boolean;
/**
* The URL of a web page or resource.
* @type {string}
*/
href: string;
}
export interface ButtonProps
extends Omit<ComponentPropsWithoutRef<'button'>, 'color'> {
/**
* Specifies the optional property `as` with a value of `'button'`.
* @type {'button'}
*/
as?: 'button';
}
/**
* Interface representing the props for a button component.
* Extends the ComponentPropsWithoutRef<'button'> and ButtonTheme interfaces.
*/
export type ButtonOrLinkProps = (ButtonLinkProps | ButtonProps) &
ButtonBaseProps &
ButtonTheme;
/**
* A custom button component that can be used in React applications.
*/
const Button = forwardRef<
HTMLButtonElement | HTMLAnchorElement,
ButtonOrLinkProps
>(
(
{
children,
className,
leftIcon,
rightIcon,
fullWidth,
iconOnly,
shape,
variant,
...props
},
ref,
) => {
// Conditionally render between <NextLink>, <a> or <button> depending on props
// useCallback to prevent unnecessary re-rendering
const Component = useCallback(
({ children: _children, ..._props }: ButtonOrLinkProps) => {
if (_props.as === 'a') {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { external, href, as, ...baseLinkProps } = _props;
// External link
if (external) {
const externalLinkProps = {
target: '_blank',
rel: 'noopener',
href,
...baseLinkProps,
};
return <a {...externalLinkProps}>{_children}</a>;
}
// Internal link
return (
<Link {...baseLinkProps} to={href}>
{_children}
</Link>
);
} else {
const { ...buttonProps } = _props;
// @ts-expect-error - as prop is not a valid prop for button elements
return <button {...buttonProps}>{_children}</button>;
}
},
[],
);
/**
* Extracts specific style properties from the given props object and returns them as a new object.
*/
const styleProps = (({
variant = 'primary',
size = 'md',
fullWidth = false,
iconOnly = false,
shape = 'rounded',
as,
}) => ({
variant,
size,
fullWidth,
iconOnly,
shape,
as,
}))({ ...props, fullWidth, iconOnly, shape, variant });
/**
* Validates that a button component has either children or an aria-label prop.
*/
if (typeof children === 'undefined' && !props['aria-label']) {
throw new Error(
'Button components must have either children or an aria-label prop',
);
}
const iconSize = useCallback(() => {
switch (styleProps.size) {
case 'lg':
return { width: 20, height: 20 };
case 'sm':
case 'xs':
return { width: 16, height: 16 };
case 'md':
default: {
return { width: 18, height: 18 };
}
}
}, [styleProps.size])();
return (
<Component
{...props}
// @ts-expect-error - ref is not a valid prop for button elements
ref={ref}
className={buttonTheme({ ...styleProps, class: className })}
>
{cloneIcon(leftIcon, { ...iconSize })}
{children}
{cloneIcon(rightIcon, { ...iconSize })}
</Component>
);
},
);
Button.displayName = 'Button';
export { Button };

View File

@ -0,0 +1,2 @@
export * from './Button';
export * from './Button.theme';

View File

@ -0,0 +1,30 @@
import React, { ComponentPropsWithoutRef } from 'react';
export interface CustomIconProps extends ComponentPropsWithoutRef<'svg'> {
size?: number | string; // width and height will both be set as the same value
name?: string;
}
export const CustomIcon = ({
children,
width = 24,
height = 24,
size,
viewBox = '0 0 24 24',
name,
...rest
}: CustomIconProps) => {
return (
<svg
aria-labelledby={name}
height={size || height}
role="presentation"
viewBox={viewBox}
width={size || width}
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
{children}
</svg>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const PlusIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.66666 9.99999C1.66666 5.39762 5.39762 1.66666 9.99999 1.66666C14.6024 1.66666 18.3333 5.39762 18.3333 9.99999C18.3333 14.6024 14.6024 18.3333 9.99999 18.3333C5.39762 18.3333 1.66666 14.6024 1.66666 9.99999ZM10.625 6.46483C10.625 6.11966 10.3452 5.83983 9.99999 5.83983C9.65481 5.83983 9.37499 6.11966 9.37499 6.46483V9.37537H6.46446C6.11928 9.37537 5.83946 9.65519 5.83946 10.0004C5.83946 10.3455 6.11928 10.6254 6.46446 10.6254H9.37499V13.5359C9.37499 13.8811 9.65481 14.1609 9.99999 14.1609C10.3452 14.1609 10.625 13.8811 10.625 13.5359V10.6254H13.5355C13.8807 10.6254 14.1605 10.3455 14.1605 10.0004C14.1605 9.65519 13.8807 9.37537 13.5355 9.37537H10.625V6.46483Z"
fill="currentColor"
/>
</CustomIcon>
);
};

View File

@ -0,0 +1,24 @@
# 1. What icons are compatible with this component?
- Viewbox "0 0 24 24": From where you're exporting from, please make sure the icon is using viewBox="0 0 24 24" before downloading/exporting. Not doing so will result in incorrect icon scaling
# 2. How to add a new icon?
**2.1 Sanitising the icon**
1. Duplicate a current icon e.g. CrossIcon and rename it accordingly.
2. Rename the function inside the new file you duplicated too
3. Replace the markup with your SVG markup (make sure it complies with the above section's rule)
4. Depending on the svg you pasted...
A. If the `<svg>` has only 1 child, remove the `<svg>` parent entirely so you only have the path left
B. If your component has more than 1 paths, rename `<svg>` tag with the `<g>` tag. Then, remove all attributes of this `<g>` tag so that it's just `<g>`
5. Usually, icons are single colored. If that's the case, replace all fill/stroke color with `currentColor`. E.g. <path d="..." fill="currentColor">. Leave the other attributes without removing them.
6. If your icon has more than one colour, then it's up to you to decide whether we want to use tailwind to help set the fill and stroke colors
7. Lastly, export your icon in `index.ts` by following what was done for CrossIcon
8. Make sure to provide a name to the `<CustomIcon>` component for accessibility sake
9. Done!
**2.3 Use your newly imported icon**
1. You can change simply use `<BellIcon size="32" />` to quickly change both width and height with the same value (square). For custom viewBox, width and height, simply provide all three props.
2. Coloring the icon: Simply add a className with text color. E.g. `<BellIcon className="text-gray-500" />`

View File

@ -0,0 +1,2 @@
export * from './PlusIcon';
export * from './CustomIcon';

View File

@ -7,6 +7,7 @@ import { GQLClient } from 'gql-client';
import { ThemeProvider } from '@material-tailwind/react';
import './index.css';
import '@fontsource/inter';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { GQLClientProvider } from './context/GQLClientContext';

View File

@ -1,3 +1,5 @@
import { Button, ButtonOrLinkProps } from 'components/shared/Button';
import { PlusIcon } from 'components/shared/CustomIcon';
import React from 'react';
const Page = () => {
@ -15,32 +17,56 @@ const Page = () => {
{/* Insert Components here */}
<div className="flex flex-col gap-10 items-center justify-between">
<h1 className="text-2xl font-bold">Component A</h1>
<div className="flex flex-row gap-10 items-center justify-center">
<div
tabIndex={0}
className="h-20 w-40 bg-red-400 rounded-md focus-ring"
/>
<div
tabIndex={0}
className="h-20 w-40 bg-red-400 rounded-md focus-ring"
/>
<div
tabIndex={0}
className="h-20 w-40 bg-red-400 rounded-md focus-ring"
/>
<div
tabIndex={0}
className="h-20 w-40 bg-red-400 rounded-md focus-ring"
/>
</div>
<div className="flex flex-row gap-10 items-center justify-center">
<div className="h-20 w-40 bg-red-400 rounded-md" />
<div className="h-20 w-40 bg-red-400 rounded-md" />
<div className="h-20 w-40 bg-red-400 rounded-md" />
<div className="h-20 w-40 bg-red-400 rounded-md" />
<h1 className="text-2xl font-bold">Button</h1>
<div className="flex flex-col gap-10">
{['primary', 'secondary', 'tertiary', 'danger'].map(
(variant, index) => (
<div className="flex gap-5 flex-wrap" key={index}>
{['lg', 'md', 'sm', 'xs', 'disabled'].map((size) => (
<Button
leftIcon={<PlusIcon />}
rightIcon={<PlusIcon />}
variant={variant as ButtonOrLinkProps['variant']}
size={
size !== 'disabled'
? (size as ButtonOrLinkProps['size'])
: 'md'
}
key={`${variant}-${size}`}
disabled={size === 'disabled'}
>
Button
</Button>
))}
</div>
),
)}
{[
'primary',
'secondary',
'tertiary',
'ghost',
'danger',
'danger-ghost',
].map((variant, index) => (
<div className="flex gap-5 flex-wrap" key={index}>
{['lg', 'md', 'sm', 'xs', 'disabled'].map((size) => (
<Button
iconOnly
variant={variant as ButtonOrLinkProps['variant']}
size={
size !== 'disabled'
? (size as ButtonOrLinkProps['size'])
: 'md'
}
key={`${variant}-${size}`}
disabled={size === 'disabled'}
>
<PlusIcon />
</Button>
))}
</div>
))}
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { Children, cloneElement, isValidElement } from 'react';
import type { Attributes, ReactElement, ReactNode } from 'react';
/**
* Clones an icon element with optional additional props.
* @param {ReactNode} icon - The icon element to clone.
* @param {Attributes & P} [props] - Additional props to apply to the cloned icon.
* @returns {ReactNode} - The cloned icon element with the applied props.
*/
export function cloneIcon<P extends object>(
icon: ReactNode,
props?: Attributes & P,
): ReactNode {
return Children.map(icon, (child) =>
isValidElement(child) ? cloneElement(child as ReactElement, props) : child,
);
}

View File

@ -8,6 +8,9 @@ export default withMT({
'../../node_modules/@material-tailwind/react/theme/components/**/*.{js,ts,jsx,tsx}',
],
theme: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
extend: {
colors: {
emerald: {
@ -138,6 +141,15 @@ export default withMT({
'high-contrast': '#0b1d2e',
},
},
boxShadow: {
button:
'inset 0px 0px 4px rgba(255, 255, 255, 0.25), inset 0px -2px 0px rgba(0, 0, 0, 0.04)',
},
spacing: {
2.5: '0.625rem',
3.25: '0.8125rem',
3.5: '0.875rem',
},
},
},
plugins: [],

View File

@ -14,7 +14,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"baseUrl": "src"
},
"include": ["src"]
}

View File

@ -1280,7 +1280,7 @@
resolved "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz"
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
"@babel/runtime@^7.10.4", "@babel/runtime@^7.3.1":
"@babel/runtime@^7.10.4", "@babel/runtime@^7.23.7", "@babel/runtime@^7.3.1":
version "7.23.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
@ -2068,6 +2068,11 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9"
integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==
"@fontsource/inter@^5.0.16":
version "5.0.16"
resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.0.16.tgz#b858508cdb56dcbbf3166903122851e2fbd16b50"
integrity sha512-qF0aH5UiZvCmneX5orJbVRoc2VTyLTV3X/7laMp03Qt28L+B9tFlZODOGUL64wDWc69YVdi1LeJB0cIgd51lvw==
"@gar/promisify@^1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
@ -15026,6 +15031,20 @@ tailwind-merge@1.8.1:
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-1.8.1.tgz#0e56c8afbab2491f72e06381043ffec8b720ba04"
integrity sha512-+fflfPxvHFr81hTJpQ3MIwtqgvefHZFUHFiIHpVIRXvG/nX9+gu2P7JNlFu2bfDMJ+uHhi/pUgzaYacMoXv+Ww==
tailwind-merge@^2.2.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.2.1.tgz#3f10f296a2dba1d88769de8244fafd95c3324aeb"
integrity sha512-o+2GTLkthfa5YUt4JxPfzMIpQzZ3adD1vLVkvKE1Twl9UAhGsEbIZhHHZVRttyW177S8PDJI3bTQNaebyofK3Q==
dependencies:
"@babel/runtime" "^7.23.7"
tailwind-variants@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/tailwind-variants/-/tailwind-variants-0.2.0.tgz#5c3a24afb6e8bb23f33aa3aa3550add8848de8a3"
integrity sha512-EuW5Sic7c0tzp+p5rJwAgb7398Jb0hi4zkyCstOoZPW0DWwr+EWkNtnZYEo5CjgE1tazHUzyt4oIhss64UXRVA==
dependencies:
tailwind-merge "^2.2.0"
tailwindcss@^3.0.2, tailwindcss@^3.3.6:
version "3.3.6"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.6.tgz"