Skip to content

Commit

Permalink
feature(component): Rewrite Avatar to use themes, resolves #127 (#153)
Browse files Browse the repository at this point in the history
* 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<ComplexObject> = {
  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<FlowbiteTheme>`.

* 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`
  • Loading branch information
tulup-conner authored May 26, 2022
1 parent 87df117 commit 1a364fc
Show file tree
Hide file tree
Showing 9 changed files with 202 additions and 72 deletions.
5 changes: 3 additions & 2 deletions src/docs/pages/ThemePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlowbiteTheme> = { alert: { color: { primary: 'bg-primary' } } };
const theme: CustomFlowbiteTheme = { alert: { color: { primary: 'bg-primary' } } };

return (
<div className="mx-auto flex max-w-4xl flex-col gap-8 dark:text-white">
Expand Down
36 changes: 32 additions & 4 deletions src/lib/components/Avatar/Avatar.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<Avatar className="test testing" />);

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(
<Flowbite theme={{ theme }}>
<Avatar size="xxl" />
</Flowbite>,
);

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(<Avatar />);
expect(getByTestId('avatar-element')).toBeTruthy();
Expand All @@ -25,7 +53,7 @@ describe('Avatar Component should be able to render a', () => {
it('bordered avatar', () => {
const { getByTestId } = render(<Avatar bordered={true} />);
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',
);
});

Expand Down
99 changes: 39 additions & 60 deletions src/lib/components/Avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,76 @@
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<ComponentProps<'div'>> {
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<AvatarProps['size'] & string, string> = {
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<AvatarProps['status'] & string, string> = {
offline: 'bg-gray-400',
online: 'bg-green-400',
away: 'bg-yellow-400',
busy: 'bg-red-400',
};

const statusPositionClasses: Record<AvatarProps['statusPosition'] & string, string> = {
'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<AvatarProps> = ({
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 (
<div data-testid="avatar-element" className="flex items-center space-x-4">
<div className={theme.base} data-testid="avatar-element" {...theirProps}>
<div className="relative">
{img ? (
<img
className={classNames(sizeClasses[size], {
rounded: !rounded,
'rounded-full': rounded,
'ring-2 ring-gray-300 dark:ring-gray-500': bordered || stacked,
'p-1': bordered,
})}
src={img}
alt={alt}
className={classNames(
bordered && theme.bordered,
rounded && theme.rounded,
stacked && theme.stacked,
theme.img.enabled,
theme.size[size],
)}
data-testid="flowbite-avatar-img"
src={img}
/>
) : (
<div
className={classNames(`relative overflow-hidden bg-gray-100 dark:bg-gray-600`, sizeClasses[size], {
rounded: !rounded,
'rounded-full': rounded,
'ring-2 ring-gray-300 dark:ring-gray-500': bordered || stacked,
'p-1': bordered,
})}
className={classNames(
bordered && theme.bordered,
rounded && theme.rounded,
stacked && theme.stacked,
theme.img.disabled,
theme.size[size],
)}
data-testid="flowbite-avatar-img"
>
<svg
className="absolute -bottom-1 h-auto w-auto text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
></path>
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg>
</div>
)}
{status && (
<span
className={classNames(
'absolute h-3.5 w-3.5 rounded-full border-2 border-white dark:border-gray-800',
statusClasses[status],
statusPositionClasses[statusPosition],
)}
></span>
<span className={classNames(theme.status.base, theme.status[status], theme.statusPosition[statusPosition])} />
)}
</div>
{children && <div>{children}</div>}
Expand Down
38 changes: 38 additions & 0 deletions src/lib/components/Flowbite/FlowbiteTheme.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type CustomFlowbiteTheme = DeepPartial<FlowbiteTheme>;

export interface FlowbiteTheme {
accordion: {
base: string;
Expand All @@ -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 & {
Expand All @@ -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;
}
7 changes: 1 addition & 6 deletions src/lib/components/Flowbite/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,14 @@ 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;
theme?: DeepPartial<FlowbiteTheme>;
usePreferences?: boolean;
}

export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;

interface FlowbiteProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
theme?: ThemeProps;
Expand Down
5 changes: 5 additions & 0 deletions src/lib/helpers/deep-partial.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
34 changes: 34 additions & 0 deletions src/lib/helpers/exclude.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
});
20 changes: 20 additions & 0 deletions src/lib/helpers/exclude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { PropsWithChildren } from 'react';

export interface ExcludeProps {
key: string;
source: Record<string, unknown>;
}

export const excludeClassName = (props: PropsWithChildren<object>): object => {
return exclude({
key: 'className',
source: props,
});
};

const exclude = ({ key, source }: ExcludeProps): object => {
delete source[key];
return source;
};

export default exclude;
30 changes: 30 additions & 0 deletions src/lib/theme/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
};

0 comments on commit 1a364fc

Please sign in to comment.