Skip to content

Commit d1e838f

Browse files
committed
♻️ feat: refactored "as element" to allow element attributes
1 parent a20db69 commit d1e838f

File tree

18 files changed

+298
-197
lines changed

18 files changed

+298
-197
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import '@testing-library/jest-dom';
2+
import { render, screen } from '@testing-library/react';
3+
import React from 'react';
4+
import { FormboxContainer } from 'lib';
5+
6+
describe('FormboxContainer', () => {
7+
it('renders correctly with default props', () => {
8+
render(
9+
<FormboxContainer type="input">
10+
<input type="text" />
11+
</FormboxContainer>,
12+
);
13+
expect(screen.getByRole('textbox')).toBeInTheDocument();
14+
});
15+
16+
it('renders a label when provided', () => {
17+
render(
18+
<FormboxContainer label="Test Label" type="input">
19+
<input type="text" />
20+
</FormboxContainer>,
21+
);
22+
expect(screen.getByText('Test Label')).toBeInTheDocument();
23+
});
24+
25+
it('supports different container element types', () => {
26+
render(
27+
<FormboxContainer<'div'> as="div" type="input">
28+
<input type="text" />
29+
</FormboxContainer>,
30+
);
31+
expect(screen.getByRole('textbox').closest('div')).toBeInTheDocument();
32+
});
33+
34+
it('handles custom widths correctly', () => {
35+
render(
36+
<FormboxContainer type="input" width={300}>
37+
<input type="text" />
38+
</FormboxContainer>,
39+
);
40+
const container = screen.getByRole('textbox').closest('label');
41+
expect(container).toHaveStyle({ width: '300px' });
42+
});
43+
44+
it('applies readOnly attributes correctly', () => {
45+
render(
46+
<FormboxContainer readOnly type="input">
47+
<input readOnly type="text" />
48+
</FormboxContainer>,
49+
);
50+
expect(screen.getByRole('textbox')).toHaveAttribute('readOnly');
51+
});
52+
53+
it('disables input when disabled prop is provided', () => {
54+
render(
55+
<FormboxContainer disabled type="input">
56+
<input disabled type="text" />
57+
</FormboxContainer>,
58+
);
59+
const input = screen.getByRole('textbox');
60+
expect(input).toBeDisabled();
61+
});
62+
});

__tests__/components/Link/LinkContainer.test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe('<LinkContainer />', () => {
1717

1818
it('should render as a paragraph', () => {
1919
const { container, getByText } = render(
20-
<LinkContainer as="p">
20+
<LinkContainer<'p'> as="p">
2121
<a href="#linkcontainer">Click me!</a>
2222
</LinkContainer>,
2323
);

src/components/Avatar/Avatar.tsx

+39-26
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,38 @@ export const avatarTranslations: AvatarTranslations = {
1313
label: 'Avatar',
1414
};
1515

16-
export type LocalAvatarProps = ThemeProps & {
17-
actionable?: boolean;
18-
className?: string;
19-
color?: 'neutral' | 'primary';
20-
imgSrc?: string;
21-
initials?: string;
22-
size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | '2xlarge' | '3xlarge' | '4xlarge' | '5xlarge' | 'custom';
23-
style?: React.CSSProperties;
24-
translations?: Partial<AvatarTranslations>;
25-
};
16+
export type AvatarElementType = Extract<keyof HTMLElementTagNameMap, 'a' | 'button' | 'div' | 'figure'>;
17+
18+
export type LocalAvatarProps<T extends AvatarElementType = 'div'> = ThemeProps &
19+
React.HTMLAttributes<HTMLElementTagNameMap[T]> & {
20+
actionable?: boolean;
21+
className?: string;
22+
color?: 'neutral' | 'primary';
23+
imgSrc?: string;
24+
initials?: string;
25+
size?:
26+
| 'xsmall'
27+
| 'small'
28+
| 'medium'
29+
| 'large'
30+
| 'xlarge'
31+
| '2xlarge'
32+
| '3xlarge'
33+
| '4xlarge'
34+
| '5xlarge'
35+
| 'custom';
36+
style?: React.CSSProperties;
37+
translations?: Partial<AvatarTranslations>;
38+
};
2639

27-
export type AvatarProps<T extends React.ElementType = 'div'> = AsReactType<T> & MergeElementProps<T, LocalAvatarProps>;
40+
export type AvatarProps<T extends AvatarElementType = 'div'> = AsReactType<T> & MergeElementProps<T, LocalAvatarProps>;
2841

2942
/**
3043
* An avatar represents a user as a picture or initials.
3144
* It can be rendered as a standard read-only div, an
3245
* actionable button, or a custom element.
3346
*/
34-
export function Avatar<T extends React.ElementType = 'div'>({
47+
export function Avatar<T extends AvatarElementType = 'div'>({
3548
actionable = false,
3649
as,
3750
className,
@@ -45,35 +58,35 @@ export function Avatar<T extends React.ElementType = 'div'>({
4558
translations: customTranslations,
4659
unthemed = false,
4760
...props
48-
}: AvatarProps<T>): React.ReactElement<AvatarProps<T>, T> {
61+
}: AvatarProps<T>): JSX.Element {
4962
const themeId = useThemeId(initThemeId);
5063
const color = contrast ? 'contrast' : initColor;
51-
const Element = as || (actionable ? 'button' : 'div');
64+
const element = as || (actionable ? 'button' : 'div');
5265
const translations = useTranslations<AvatarTranslations>({
5366
customTranslations,
5467
fallbackTranslations: avatarTranslations,
5568
});
5669
const { label } = translations;
5770

58-
return (
59-
<Element
60-
aria-label={`${label}: ${initials}`}
61-
className={cx(
71+
return React.createElement(
72+
element,
73+
{
74+
'aria-label': `${label}: ${initials}`,
75+
className: cx(
6276
styles.avatar,
6377
color && styles[`avatar--${color}`],
6478
themeId && !unthemed && styles[`avatar--${themeId}`],
6579
imgSrc && styles['avatar--image'],
6680
size && styles[`avatar--${size}`],
6781
actionable && styles['avatar--actionable'],
6882
className,
69-
)}
70-
role="img"
71-
style={{ backgroundImage: imgSrc && `url(${imgSrc})`, ...style }}
72-
tabIndex={actionable ? 0 : undefined}
73-
{...props}
74-
>
75-
<div className={styles.avatar__content}>{initials}</div>
76-
</Element>
83+
),
84+
role: 'img',
85+
style: { backgroundImage: imgSrc && `url(${imgSrc})`, ...style },
86+
tabIndex: actionable ? 0 : undefined,
87+
...props,
88+
},
89+
<div className={styles.avatar__content}>{initials}</div>,
7790
);
7891
}
7992

src/components/Avatar/ColoredAvatar.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import React from 'react';
33
import { Theme } from '../../types';
44
import { themes, ThemeColors, ThemeColorIds } from '../../config';
55
import { withTheme } from '../../context/Theme';
6-
import { Avatar, AvatarProps } from './Avatar';
6+
import { Avatar, AvatarElementType, AvatarProps } from './Avatar';
77

8-
export type ColoredAvatarProps<T extends React.ElementType = 'div'> = Omit<
8+
export type ColoredAvatarProps<T extends AvatarElementType = 'div'> = Omit<
99
AvatarProps<T>,
1010
'color' | 'contrast' | 'themeId'
1111
> & {
@@ -21,7 +21,7 @@ const StyledAvatar = styled(Avatar, {
2121
--avatar-text-color: ${props => themes[props.themeId][`color-${props.colorId}-contrast` as keyof ThemeColors]};
2222
`;
2323

24-
const BaseColoredAvatar = withTheme<ColoredAvatarProps>(StyledAvatar) as <T extends React.ElementType = 'div'>(
24+
const BaseColoredAvatar = withTheme<ColoredAvatarProps>(StyledAvatar) as <T extends AvatarElementType = 'div'>(
2525
p: Omit<ColoredAvatarProps<T>, 'themeId'> & { themeId?: Theme },
2626
) => React.ReactElement<T>;
2727

src/components/Avatar/StyledAvatar.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import styled from '@emotion/styled';
2-
import { Avatar, AvatarProps } from './Avatar';
2+
import { Avatar, AvatarElementType, AvatarProps } from './Avatar';
33

4-
export type StyledAvatarProps<T extends React.ElementType = 'div'> = Omit<
4+
export type StyledAvatarProps<T extends AvatarElementType = 'div'> = Omit<
55
AvatarProps<T>,
66
'color' | 'contrast' | 'themeId'
77
> & {
@@ -19,6 +19,6 @@ export const StyledAvatar = styled(Avatar, {
1919
})<StyledAvatarProps>`
2020
${({ backgroundColor }) => backgroundColor && `--avatar-background-color: ${backgroundColor};`}
2121
${({ textColor }) => textColor && `--avatar-text-color: ${textColor};`}
22-
` as <T extends React.ElementType = 'div'>(p: StyledAvatarProps<T>) => React.ReactElement<T>;
22+
` as <T extends AvatarElementType = 'div'>(p: StyledAvatarProps<T>) => React.ReactElement<T>;
2323

2424
(StyledAvatar as React.NamedExoticComponent).displayName = 'StyledAvatar';

src/components/Button/Button.tsx

+34-33
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,42 @@ import { useThemeId } from '../../context/Theme';
55
import styles from './styles/Button.module.css';
66
import { ButtonAlignment, ButtonWeight, ButtonShape, ButtonSize, ButtonColor, ButtonElementType } from './types';
77

8-
export type LocalButtonProps = ThemeProps & {
9-
/** Manually apply the active styles; this does not affect :active */
10-
active?: boolean;
11-
align?: ButtonAlignment;
12-
children: React.ReactNode;
13-
className?: string;
14-
color?: ButtonColor;
15-
disabled?: boolean;
16-
/** Manually apply the focus styles; this does not affect :focus */
17-
focused?: boolean;
18-
fullWidth?: boolean;
19-
/** Manually apply the hover styles; this does not affect :hover */
20-
hovered?: boolean;
21-
href?: string;
22-
/** An imitation button looks like a button but doesn't have any functionality */
23-
imitation?: boolean;
24-
loader?: React.ReactElement;
25-
loading?: boolean;
26-
/** Remove the minimum height styles */
27-
noHeight?: boolean;
28-
noPadding?: boolean;
29-
onClick?: (event: React.MouseEvent | React.KeyboardEvent | React.TouchEvent) => void;
30-
/** If the onKeyDown isn't specified then the onClick event is used */
31-
onKeyDown?: (event: React.KeyboardEvent) => void;
32-
shape?: ButtonShape;
33-
size?: ButtonSize;
34-
style?: React.CSSProperties;
35-
type?: 'button' | 'submit';
36-
unstyled?: boolean;
37-
unthemed?: boolean;
38-
weight?: ButtonWeight;
39-
};
8+
export type LocalButtonProps<T extends ButtonElementType = 'button'> = ThemeProps &
9+
React.HTMLAttributes<HTMLElementTagNameMap[T]> & {
10+
/** Manually apply the active styles; this does not affect :active */
11+
active?: boolean;
12+
align?: ButtonAlignment;
13+
children: React.ReactNode;
14+
className?: string;
15+
color?: ButtonColor;
16+
disabled?: boolean;
17+
/** Manually apply the focus styles; this does not affect :focus */
18+
focused?: boolean;
19+
fullWidth?: boolean;
20+
/** Manually apply the hover styles; this does not affect :hover */
21+
hovered?: boolean;
22+
href?: string;
23+
/** An imitation button looks like a button but doesn't have any functionality */
24+
imitation?: boolean;
25+
loader?: React.ReactElement;
26+
loading?: boolean;
27+
/** Remove the minimum height styles */
28+
noHeight?: boolean;
29+
noPadding?: boolean;
30+
onClick?: (event: React.MouseEvent | React.KeyboardEvent | React.TouchEvent) => void;
31+
/** If the onKeyDown isn't specified then the onClick event is used */
32+
onKeyDown?: (event: React.KeyboardEvent) => void;
33+
shape?: ButtonShape;
34+
size?: ButtonSize;
35+
style?: React.CSSProperties;
36+
type?: 'button' | 'submit';
37+
unstyled?: boolean;
38+
unthemed?: boolean;
39+
weight?: ButtonWeight;
40+
};
4041

4142
export type ButtonProps<T extends ButtonElementType = 'button'> = AsReactType<T> &
42-
MergeElementPropsWithoutRef<T, LocalButtonProps>;
43+
MergeElementPropsWithoutRef<T, LocalButtonProps<T>>;
4344

4445
export function ButtonBase<T extends ButtonElementType = 'button'>(
4546
{

src/components/Button/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ export type ButtonWeight = 'solid' | 'shaded' | 'outlined' | 'ghost' | 'inline';
55
export type ButtonShape = 'pill' | 'brick';
66
export type ButtonSize = 'small' | 'medium' | 'large' | 'relative';
77
export type ButtonColor = SemanticColor | 'neutralAndPrimary' | 'black' | 'white';
8-
export type ButtonElementType = Extract<keyof JSX.IntrinsicElements, 'button' | 'a' | 'div' | 'span'>;
8+
export type ButtonElementType = Extract<keyof HTMLElementTagNameMap, 'button' | 'a' | 'div' | 'span'>;

src/components/Form/Fieldset/Fieldset.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ export function FieldsetBase(
2828
return (
2929
<fieldset className={cx(styles.fieldset, className)} ref={forwardedRef} {...props}>
3030
{legend && (
31-
<Label as="legend" className={styles.fieldset__legend} contrast={contrast} strength="legend" themeId={themeId}>
31+
<Label<'legend'>
32+
as="legend"
33+
className={styles.fieldset__legend}
34+
contrast={contrast}
35+
strength="legend"
36+
themeId={themeId}
37+
>
3238
{legend}
3339
</Label>
3440
)}

src/components/Form/Formbox/FormboxContainer.tsx

+33-32
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,41 @@ import styles from './styles/Formbox.module.css';
66
import sizeStyles from './styles/FormboxSizes.module.css';
77
import { FormboxContainerElementType, FormboxInputElementType, FormboxSize, FormboxVariant } from './types';
88

9-
export type LocalFormboxContainerProps = ThemeProps & {
10-
/** Whether the formbox input was auto-filled (see useAutoFilled hook) */
11-
autoFilled?: boolean;
12-
children: NonNullable<React.ReactNode>;
13-
centered?: boolean;
14-
className?: string;
15-
disabled?: boolean;
16-
/** This should be true if there's no input value, read only value, or placeholder */
17-
empty?: boolean;
18-
/** Manually apply the focus styles; this does not affect focus */
19-
focused?: boolean;
20-
hasIconAfter?: boolean;
21-
hasIconBefore?: boolean;
22-
id?: string;
23-
/** The width of the input container */
24-
inputWidth?: string | number;
25-
label?: React.ReactNode | string;
26-
/** If the formbox container is read only then there are no hover events */
27-
readOnly?: boolean;
28-
size?: FormboxSize;
29-
style?: React.CSSProperties;
30-
/** A transitional input show the label as a placeholder which moves out of the way on focus or if there's a value */
31-
transitional?: boolean;
32-
/** Transparent inputs can be used inside styled containers (eg. a contained dropdown) */
33-
transparent?: boolean;
34-
type: FormboxInputElementType;
35-
validity?: StateColor;
36-
variant?: FormboxVariant;
37-
/** The width of the entire component */
38-
width?: string | number;
39-
};
9+
export type LocalFormboxContainerProps<T extends FormboxContainerElementType> = ThemeProps &
10+
React.HTMLAttributes<HTMLElementTagNameMap[T]> & {
11+
/** Whether the formbox input was auto-filled (see useAutoFilled hook) */
12+
autoFilled?: boolean;
13+
children: NonNullable<React.ReactNode>;
14+
centered?: boolean;
15+
className?: string;
16+
disabled?: boolean;
17+
/** This should be true if there's no input value, read only value, or placeholder */
18+
empty?: boolean;
19+
/** Manually apply the focus styles; this does not affect focus */
20+
focused?: boolean;
21+
hasIconAfter?: boolean;
22+
hasIconBefore?: boolean;
23+
id?: string;
24+
/** The width of the input container */
25+
inputWidth?: string | number;
26+
label?: React.ReactNode | string;
27+
/** If the formbox container is read only then there are no hover events */
28+
readOnly?: boolean;
29+
size?: FormboxSize;
30+
style?: React.CSSProperties;
31+
/** A transitional input show the label as a placeholder which moves out of the way on focus or if there's a value */
32+
transitional?: boolean;
33+
/** Transparent inputs can be used inside styled containers (eg. a contained dropdown) */
34+
transparent?: boolean;
35+
type: FormboxInputElementType;
36+
validity?: StateColor;
37+
variant?: FormboxVariant;
38+
/** The width of the entire component */
39+
width?: string | number;
40+
};
4041

4142
export type FormboxContainerProps<T extends FormboxContainerElementType> = AsReactType<T> &
42-
MergeElementProps<T, LocalFormboxContainerProps>;
43+
MergeElementProps<T, LocalFormboxContainerProps<T>>;
4344

4445
/**
4546
* This applies all the container and label styles for

src/components/Form/Formbox/types.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export type FormboxContainerElementType = Extract<keyof JSX.IntrinsicElements, 'label' | 'div'>;
1+
export type FormboxContainerElementType = Extract<keyof HTMLElementTagNameMap, 'label' | 'div'>;
22
export type FormboxIconPosition = 'before' | 'after';
3-
export type FormboxInputElementType = Extract<keyof JSX.IntrinsicElements, 'input' | 'select' | 'textarea'>;
3+
export type FormboxInputElementType = Extract<keyof HTMLElementTagNameMap, 'input' | 'select' | 'textarea'>;
44
export type FormboxValue = string | number;
55
export type FormboxVariant = 'underline' | 'filled' | 'outline' | 'pill' | 'minimal';
66

0 commit comments

Comments
 (0)