From 1a364fc95d056be6946540b90e940da366b0f3ba Mon Sep 17 00:00:00 2001 From: tulup-conner Date: Wed, 25 May 2022 23:12:45 -0700 Subject: [PATCH] feature(component): Rewrite `Avatar` to use themes, resolves #127 (#153) * feature(helper): Extract `DeepPartial` to helper `DeepPartial` is a type that is identical to the built-in [Partial](https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype), but also allows nested partials. For example, the following causes type errors: ```js let somePartial: Partial = { some: { deeplyNestedThing: { oh: no } } } ``` * feature(type): Add theme for `Avatar`s * feature(type): Create `CustomFlowbiteTheme` type This is a more natural API to read than `DeepPartial`. * feature(component): Rewrite `Avatar` to use themes, resolves #127 * test(component): Add theme unit test for `Avatar`s * feature(component): Prevent `className` on `Avatar`s Experimentally, destroy `className` if it is provided to `Avatar` to prevent the design from falling apart. * feature(helper): Add helper to exclude key from an object Additionally, provide a shortcut helper for the most common existing case, which is to remove `className` from a React component. * refactor(component): Add `excludeClassName` helper in `Avatar` --- src/docs/pages/ThemePage.tsx | 5 +- src/lib/components/Avatar/Avatar.spec.tsx | 36 ++++++- src/lib/components/Avatar/index.tsx | 99 ++++++++----------- .../components/Flowbite/FlowbiteTheme.d.ts | 38 +++++++ src/lib/components/Flowbite/index.tsx | 7 +- src/lib/helpers/deep-partial.d.ts | 5 + src/lib/helpers/exclude.spec.ts | 34 +++++++ src/lib/helpers/exclude.ts | 20 ++++ src/lib/theme/default.ts | 30 ++++++ 9 files changed, 202 insertions(+), 72 deletions(-) create mode 100644 src/lib/helpers/deep-partial.d.ts create mode 100644 src/lib/helpers/exclude.spec.ts create mode 100644 src/lib/helpers/exclude.ts diff --git a/src/docs/pages/ThemePage.tsx b/src/docs/pages/ThemePage.tsx index a324390bd..514f470eb 100644 --- a/src/docs/pages/ThemePage.tsx +++ b/src/docs/pages/ThemePage.tsx @@ -4,10 +4,11 @@ import { HiInformationCircle } from 'react-icons/hi'; import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; import { dracula } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { Alert, Card, DarkThemeToggle } from '../../lib'; -import { DeepPartial, Flowbite, FlowbiteTheme } from '../../lib/components'; +import { Flowbite } from '../../lib/components'; +import { CustomFlowbiteTheme } from '../../lib/components/Flowbite/FlowbiteTheme'; const ThemePage: FC = () => { - const theme: DeepPartial = { alert: { color: { primary: 'bg-primary' } } }; + const theme: CustomFlowbiteTheme = { alert: { color: { primary: 'bg-primary' } } }; return (
diff --git a/src/lib/components/Avatar/Avatar.spec.tsx b/src/lib/components/Avatar/Avatar.spec.tsx index 8ac1fd7e7..6a010db24 100644 --- a/src/lib/components/Avatar/Avatar.spec.tsx +++ b/src/lib/components/Avatar/Avatar.spec.tsx @@ -1,10 +1,38 @@ -import { cleanup, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { Avatar } from '.'; +import { Flowbite } from '../Flowbite'; +import { CustomFlowbiteTheme } from '../Flowbite/FlowbiteTheme'; import AvatarGroup from './AvatarGroup'; -describe('Avatar Component should be able to render a', () => { - afterEach(cleanup); +describe('Components / Avatar', () => { + describe('Theme', () => { + it("shouldn't be able to set className directly", () => { + const { getByTestId } = render(); + + expect(getByTestId('avatar-element')).not.toHaveClass('test testing'); + }); + + it('should be able to apply custom classes', () => { + const theme: CustomFlowbiteTheme = { + avatar: { + size: { + xxl: 'h-64 w-64', + }, + }, + }; + const { getByTestId } = render( + + + , + ); + expect(getByTestId('flowbite-avatar-img')).toHaveClass('h-64'); + expect(getByTestId('flowbite-avatar-img')).toHaveClass('w-64'); + }); + }); +}); + +describe('Avatar Component should be able to render a', () => { it('avatar', () => { const { getByTestId } = render(); expect(getByTestId('avatar-element')).toBeTruthy(); @@ -25,7 +53,7 @@ describe('Avatar Component should be able to render a', () => { it('bordered avatar', () => { const { getByTestId } = render(); expect(getByTestId('avatar-element').children[0].children[0].className).toContain( - 'ring-2 ring-gray-300 dark:ring-gray-500 p-1', + 'p-1 ring-2 ring-gray-300 dark:ring-gray-500', ); }); diff --git a/src/lib/components/Avatar/index.tsx b/src/lib/components/Avatar/index.tsx index a473da402..42781a1ab 100644 --- a/src/lib/components/Avatar/index.tsx +++ b/src/lib/components/Avatar/index.tsx @@ -1,74 +1,63 @@ import classNames from 'classnames'; -import { FC, PropsWithChildren } from 'react'; +import { ComponentProps, FC, PropsWithChildren } from 'react'; +import { excludeClassName } from '../../helpers/exclude'; +import { Sizes } from '../Flowbite/FlowbiteTheme'; +import { useTheme } from '../Flowbite/ThemeContext'; import AvatarGroup from './AvatarGroup'; import AvatarGroupCounter from './AvatarGroupCounter'; -export type AvatarProps = PropsWithChildren<{ +export interface AvatarProps extends PropsWithChildren> { alt?: string; - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; - rounded?: boolean; bordered?: boolean; img?: string; - status?: 'offline' | 'online' | 'away' | 'busy'; - statusPosition?: 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left'; + rounded?: boolean; + size?: keyof Sizes; stacked?: boolean; -}>; - -const sizeClasses: Record = { - xs: 'w-6 h-6', - sm: 'w-8 h-8', - md: 'w-10 h-10', - lg: 'w-20 h-20', - xl: 'w-36 h-36', -}; - -const statusClasses: Record = { - offline: 'bg-gray-400', - online: 'bg-green-400', - away: 'bg-yellow-400', - busy: 'bg-red-400', -}; - -const statusPositionClasses: Record = { - 'top-right': '-top-1 -right-1', - 'top-left': '-top-1 -left-1', - 'bottom-left': '-bottom-1 -left-1', - 'bottom-right': '-bottom-1 -right-1', -}; + status?: 'away' | 'busy' | 'offline' | 'online'; + statusPosition?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'; +} const AvatarComponent: FC = ({ alt = '', - img, - status, + bordered = false, children, - statusPosition = 'top-left', - size = 'md', + img, rounded = false, - bordered = false, + size = 'md', stacked = false, + status, + statusPosition = 'top-left', + ...props }) => { + const theirProps = excludeClassName(props); + const theme = useTheme().theme.avatar; + return ( -
+
{img ? ( {alt} ) : (
= ({ viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" > - +
)} {status && ( - + )}
{children &&
{children}
} diff --git a/src/lib/components/Flowbite/FlowbiteTheme.d.ts b/src/lib/components/Flowbite/FlowbiteTheme.d.ts index 00e836438..f01fdbb52 100644 --- a/src/lib/components/Flowbite/FlowbiteTheme.d.ts +++ b/src/lib/components/Flowbite/FlowbiteTheme.d.ts @@ -1,3 +1,5 @@ +export type CustomFlowbiteTheme = DeepPartial; + export interface FlowbiteTheme { accordion: { base: string; @@ -22,6 +24,30 @@ export interface FlowbiteTheme { icon: string; rounded: string; }; + avatar: { + base: string; + bordered: string; + img: { + disabled: string; + enabled: string; + }; + rounded: string; + size: Sizes; + stacked: string; + status: { + away: string; + base: string; + busy: string; + offline: string; + online: string; + }; + statusPosition: { + 'bottom-left': string; + 'bottom-right': string; + 'top-left': string; + 'top-right': string; + }; + }; } export type Colors = FlowbiteColors & { @@ -35,3 +61,15 @@ export interface FlowbiteColors { success: string; warning: string; } + +export type Sizes = FlowbiteSizes & { + [key in string]: string; +}; + +export interface FlowbiteSizes { + xs: string; + sm: string; + md: string; + lg: string; + xl: string; +} diff --git a/src/lib/components/Flowbite/index.tsx b/src/lib/components/Flowbite/index.tsx index 48b6056d5..7055e8599 100644 --- a/src/lib/components/Flowbite/index.tsx +++ b/src/lib/components/Flowbite/index.tsx @@ -4,6 +4,7 @@ import { mergeDeep } from '../../helpers/mergeDeep'; import defaultTheme from '../../theme/default'; import windowExists from '../../helpers/window-exists'; import { FlowbiteTheme } from './FlowbiteTheme'; +import { DeepPartial } from '../../helpers/deep-partial'; export interface ThemeProps { dark?: boolean; @@ -11,12 +12,6 @@ export interface ThemeProps { usePreferences?: boolean; } -export type DeepPartial = T extends object - ? { - [P in keyof T]?: DeepPartial; - } - : T; - interface FlowbiteProps extends HTMLAttributes { children: React.ReactNode; theme?: ThemeProps; diff --git a/src/lib/helpers/deep-partial.d.ts b/src/lib/helpers/deep-partial.d.ts new file mode 100644 index 000000000..6ac64c68d --- /dev/null +++ b/src/lib/helpers/deep-partial.d.ts @@ -0,0 +1,5 @@ +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; diff --git a/src/lib/helpers/exclude.spec.ts b/src/lib/helpers/exclude.spec.ts new file mode 100644 index 000000000..808c36e29 --- /dev/null +++ b/src/lib/helpers/exclude.spec.ts @@ -0,0 +1,34 @@ +import exclude from './exclude'; + +describe('Helpers / Exclude (delete key from object)', () => { + describe('Given object that contains targeted key', () => { + it('should return input object without that key', () => { + const input = { + a: 1, + b: 2, + c: 3, + }; + const output = exclude({ key: 'a', source: input }); + + expect(output).toEqual({ + b: 2, + c: 3, + }); + }); + }); + + describe('Given object that does not contain target key', () => { + it('should return input object', () => { + const input = { + b: 2, + c: 3, + }; + const output = exclude({ key: 'a', source: input }); + + expect(output).toEqual({ + b: 2, + c: 3, + }); + }); + }); +}); diff --git a/src/lib/helpers/exclude.ts b/src/lib/helpers/exclude.ts new file mode 100644 index 000000000..e07e244ac --- /dev/null +++ b/src/lib/helpers/exclude.ts @@ -0,0 +1,20 @@ +import { PropsWithChildren } from 'react'; + +export interface ExcludeProps { + key: string; + source: Record; +} + +export const excludeClassName = (props: PropsWithChildren): object => { + return exclude({ + key: 'className', + source: props, + }); +}; + +const exclude = ({ key, source }: ExcludeProps): object => { + delete source[key]; + return source; +}; + +export default exclude; diff --git a/src/lib/theme/default.ts b/src/lib/theme/default.ts index 0b7e8c782..c925620f7 100644 --- a/src/lib/theme/default.ts +++ b/src/lib/theme/default.ts @@ -37,4 +37,34 @@ export default { icon: 'mr-3 inline h-5 w-5 flex-shrink-0', rounded: 'rounded-lg', }, + avatar: { + base: 'flex items-center space-x-4', + bordered: 'p-1 ring-2 ring-gray-300 dark:ring-gray-500', + img: { + disabled: 'rounded relative overflow-hidden bg-gray-100 dark:bg-gray-600', + enabled: 'rounded', + }, + rounded: '!rounded-full', + size: { + xs: 'w-6 h-6', + sm: 'w-8 h-8', + md: 'w-10 h-10', + lg: 'w-20 h-20', + xl: 'w-36 h-36', + }, + stacked: 'ring-2 ring-gray-300 dark:ring-gray-500', + status: { + away: 'bg-yellow-400', + base: 'absolute h-3.5 w-3.5 rounded-full border-2 border-white dark:border-gray-800', + busy: 'bg-red-400', + offline: 'bg-gray-400', + online: 'bg-green-400', + }, + statusPosition: { + 'bottom-left': '-bottom-1 -left-1', + 'bottom-right': '-bottom-1 -right-1', + 'top-left': '-top-1 -left-1', + 'top-right': '-top-1 -right-1', + }, + }, };