Skip to content

Commit

Permalink
feat(atomic): add button function for lit components (#4857)
Browse files Browse the repository at this point in the history
Add Lit equivalent to `<Button>` functional element.

Also replaced `button.tsx` to `stencil-button.tsx` which is responsible
for all the file changes

## Usage 
### With Stencil
```tsx
return (
  <Button
    style="primary"
    text={i18n.t('load-more-results')}
    part="load-more-results-button"
    class="my-2 p-3 font-bold"
    onClick={() => onClick()}
  ></Button>
);
```

### With Lit
```ts
return button({
  props: {
    style: 'primary',
    text: i18n.t('load-more-results'),
    part: 'load-more-results-button',
    class: 'my-2 p-3 font-bold',
    onClick: () => onClick(),
  },
});
```

## Question
Lit does not natively support spreading attributes as we do in .tsx
files, which can lead to code repetition. To address this, we could use
[lit-helpers](https://open-wc.org/docs/development/lit-helpers/#spread-directives)
to avoid duplicating attributes, properties, and events while
maintaining consistency in our patterns.

WDYT
  • Loading branch information
y-lakhdar authored Feb 6, 2025
1 parent c353e4d commit b64e226
Show file tree
Hide file tree
Showing 64 changed files with 373 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import {
InitializeBindings,
} from '../../../utils/initialization-utils';
import {createAppLoadedListener} from '../../common/interface/store';
import {LoadMoreButton} from '../../common/load-more/button';
import {LoadMoreContainer} from '../../common/load-more/container';
import {LoadMoreGuard} from '../../common/load-more/guard';
import {LoadMoreProgressBar} from '../../common/load-more/progress-bar';
import {LoadMoreButton} from '../../common/load-more/stencil-button';
import {LoadMoreSummary} from '../../common/load-more/summary';
import {CommerceBindings} from '../atomic-commerce-interface/atomic-commerce-interface';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {isUndefined} from '@coveo/bueno';
import {NumericFacet} from '@coveo/headless/commerce';
import {Component, h, Prop, Event, EventEmitter, State} from '@stencil/core';
import {Button} from '../../../common/button';
import {Button} from '../../../common/stencil-button';
import {CommerceBindings as Bindings} from '../../atomic-commerce-interface/atomic-commerce-interface';

export type Range = {start: number; end: number};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
InitializeBindings,
} from '../../../../utils/initialization-utils';
import {filterProtocol} from '../../../../utils/xss-utils';
import {Button} from '../../../common/button';
import {Button} from '../../../common/stencil-button';
import {CommerceBindings} from '../../atomic-commerce-interface/atomic-commerce-interface';
import {ProductContext} from '../product-template-decorators';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {FunctionalComponent, h} from '@stencil/core';
import {i18n} from 'i18next';
import {Button} from '../button';
import {Button} from '../stencil-button';
import {Breadcrumb} from './breadcrumb-types';
import {
joinBreadcrumbValues,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {FunctionalComponent, h} from '@stencil/core';
import {i18n} from 'i18next';
import {Button} from '../button';
import {Button} from '../stencil-button';

export interface BreadcrumbClearAllProps {
setRef: (el: HTMLButtonElement) => void;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {h, FunctionalComponent} from '@stencil/core';
import {i18n} from 'i18next';
import {Button} from '../button';
import {Button} from '../stencil-button';

export interface BreadcrumbShowLessProps {
onShowLess: () => void;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {h, FunctionalComponent} from '@stencil/core';
import {i18n} from 'i18next';
import {Button} from '../button';
import {Button} from '../stencil-button';

export interface BreadcrumbShowMoreProps {
setRef: (el: HTMLButtonElement) => void;
Expand Down
185 changes: 185 additions & 0 deletions packages/atomic/src/components/common/button.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import {createRipple} from '@/src/utils/ripple';
import {fireEvent, within} from '@storybook/test';
import {html, render} from 'lit';
import {vi} from 'vitest';
import {button, ButtonProps} from './button';

vi.mock('@/src/utils/ripple', () => ({
createRipple: vi.fn(),
}));

describe('button', () => {
let container: HTMLElement;

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(() => {
document.body.removeChild(container);
});

const renderButton = (props: Partial<ButtonProps>): HTMLButtonElement => {
render(
html`${button({
props: {
...props,
style: props.style ?? 'primary',
},
children: html``,
})}`,
container
);
return within(container).getByRole('button') as HTMLButtonElement;
};

it('should render a button in the document', () => {
const props = {};
const button = renderButton(props);
expect(button).toBeInTheDocument();
});

it('should render a button with the correct style', () => {
const props: Partial<ButtonProps> = {
style: 'outline-error',
};

const button = renderButton(props);

expect(button).toHaveClass('btn-outline-error');
});

it('should render a button with the correct text', () => {
const props = {
text: 'Click me',
};

const button = renderButton(props);

expect(button.querySelector('span')?.textContent).toBe('Click me');
});

it('should wrap the button text with a truncate class', () => {
const props = {
text: 'Click me',
};

const button = renderButton(props);

expect(button.querySelector('span')).toHaveClass('truncate');
});

it('should handle click event', async () => {
const handleClick = vi.fn();
const props = {
onClick: handleClick,
};

const button = renderButton(props);

await fireEvent.click(button);

expect(handleClick).toHaveBeenCalled();
});

it('should apply disabled attribute', () => {
const props = {
disabled: true,
};

const button = renderButton(props);

expect(button.hasAttribute('disabled')).toBe(true);
});

it('should apply aria attributes', () => {
const props: Partial<ButtonProps> = {
ariaLabel: 'button',
ariaPressed: 'true',
};

const button = renderButton(props);

expect(button.getAttribute('aria-label')).toBe('button');
expect(button.getAttribute('aria-pressed')).toBe('true');
});

it('should apply custom class', () => {
const props = {
class: 'custom-class',
};

const button = renderButton(props);

expect(button).toHaveClass('custom-class');
expect(button).toHaveClass('btn-primary');
});

it('should apply part attribute', () => {
const props = {
part: 'button-part',
};

const button = renderButton(props);

expect(button.getAttribute('part')).toBe('button-part');
});

it('should apply title attribute', () => {
const props = {
title: 'Button Title',
};

const button = renderButton(props);

expect(button.getAttribute('title')).toBe('Button Title');
});

it('should apply tabindex attribute', () => {
const props = {
tabIndex: 1,
};

const button = renderButton(props);

expect(button.getAttribute('tabindex')).toBe('1');
});

it('should apply role attribute', () => {
const props: Partial<ButtonProps> = {
role: 'button',
};

const button = renderButton(props);

expect(button.getAttribute('role')).toBe('button');
});

it('should call onMouseDown when the mousedown event is fired on the button', async () => {
const props: Partial<ButtonProps> = {};
const button = renderButton(props);
await fireEvent.mouseDown(button);
expect(createRipple).toHaveBeenCalled();
});

it('should apply form attribute', () => {
const props = {
form: 'form-id',
};

const button = renderButton(props);

expect(button.getAttribute('form')).toBe('form-id');
});

it('should apply type attribute', () => {
const props: Partial<ButtonProps> = {
type: 'submit',
};

const button = renderButton(props);

expect(button.getAttribute('type')).toBe('submit');
});
});
74 changes: 74 additions & 0 deletions packages/atomic/src/components/common/button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {html} from 'lit-html';
import {ifDefined} from 'lit-html/directives/if-defined.js';
import {createRipple} from '../../utils/ripple';
import {
getRippleColorForButtonStyle,
getClassNameForButtonStyle,
ButtonStyle,
} from './button-style';

export interface ButtonProps {
style: ButtonStyle;
onClick?(event?: MouseEvent): void;
class?: string;
text?: string;
part?: string;
type?: 'button' | 'submit' | 'reset' | 'menu';
form?: string;
role?:
| 'status'
| 'application'
| 'checkbox'
| 'button'
| 'dialog'
| 'img'
| 'radiogroup'
| 'toolbar'
| 'listitem'
| 'list'
| 'separator';
disabled?: boolean;
ariaLabel?: string;
ariaExpanded?: 'true' | 'false';
ariaPressed?: 'true' | 'false' | 'mixed';
ariaChecked?: 'true' | 'false' | 'mixed';
ariaCurrent?: 'page' | 'false';
ariaControls?: string;
ariaHidden?: 'true' | 'false';
tabIndex?: number;
title?: string;
}

export const button = <T>({
props,
children,
}: {
props: ButtonProps;
children: T;
}) => {
const rippleColor = getRippleColorForButtonStyle(props.style);
const className = getClassNameForButtonStyle(props.style);

return html` <button
type=${ifDefined(props.type)}
title=${ifDefined(props.title)}
tabindex=${ifDefined(props.tabIndex)}
role=${ifDefined(props.role)}
part=${ifDefined(props.part)}
form=${ifDefined(props.form)}
class=${props.class ? `${className} ${props.class}` : className}
aria-pressed=${ifDefined(props.ariaPressed)}
aria-label=${ifDefined(props.ariaLabel)}
aria-hidden=${ifDefined(props.ariaHidden)}
aria-expanded=${ifDefined(props.ariaExpanded)}
aria-current=${ifDefined(props.ariaCurrent)}
aria-controls=${ifDefined(props.ariaControls)}
aria-checked=${ifDefined(props.ariaChecked)}
@mousedown=${(e: MouseEvent) => createRipple(e, {color: rippleColor})}
@click=${props.onClick}
?disabled=${props.disabled}
>
{props.text ? <span class="truncate">${props.text}</span> : null}
${children}
</button>`;
};
2 changes: 1 addition & 1 deletion packages/atomic/src/components/common/carousel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {h, FunctionalComponent, Fragment} from '@stencil/core';
import {JSXBase} from '@stencil/core/internal';
import ArrowRight from '../../images/arrow-right.svg';
import {Button} from './button';
import {AnyBindings} from './interface/bindings';
import {Button} from './stencil-button';

export interface CarouselProps {
bindings: AnyBindings;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {FunctionalComponent, h} from '@stencil/core';
import MinusIcon from '../../../images/minus.svg';
import PlusIcon from '../../../images/plus.svg';
import {Button} from '../button';
import {Button} from '../stencil-button';

export type TruncateAfter = 'none' | '1' | '2' | '3' | '4';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {FunctionalComponent, h} from '@stencil/core';
import {i18n} from 'i18next';
import LeftArrow from '../../../../images/arrow-left-rounded.svg';
import {Button} from '../../button';
import {Button} from '../../stencil-button';

interface CategoryFacetAllCategoryButtonProps {
i18n: i18n;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {FunctionalComponent, h} from '@stencil/core';
import {i18n} from 'i18next';
import LeftArrow from '../../../../images/arrow-left-rounded.svg';
import {getFieldValueCaption} from '../../../../utils/field-utils';
import {Button} from '../../button';
import {Button} from '../../stencil-button';

interface CategoryFacetParentButtonProps {
i18n: i18n;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {FunctionalComponent, h} from '@stencil/core';
import {i18n} from 'i18next';
import {getFieldValueCaption} from '../../../../utils/field-utils';
import {Button} from '../../button';
import {Button} from '../../stencil-button';
import {FacetValueLabelHighlight} from '../facet-value-label-highlight/facet-value-label-highlight';

interface CategoryFacetSearchValueProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from '@coveo/headless';
import {Component, h, State, Prop, Event, EventEmitter} from '@stencil/core';
import {parseDate} from '../../../../utils/date-utils';
import {Button} from '../../../common/button';
import {Button} from '../../../common/stencil-button';
import {AnyBindings} from '../../interface/bindings';

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {i18n} from 'i18next';
import ArrowBottomIcon from '../../../../images/arrow-bottom-rounded.svg';
import ArrowTopIcon from '../../../../images/arrow-top-rounded.svg';
import CloseIcon from '../../../../images/close.svg';
import {Button} from '../../button';
import {Heading} from '../../heading';
import {Button} from '../../stencil-button';

export interface FacetHeaderProps {
i18n: i18n;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Component, h, State, Prop, Event, EventEmitter} from '@stencil/core';
import {Button} from '../../button';
import {AnyBindings} from '../../interface/bindings';
import {Button} from '../../stencil-button';
import {NumericFilter, NumericFilterState} from '../../types';
import {NumberInputType} from './number-input-type';

Expand Down
Loading

0 comments on commit b64e226

Please sign in to comment.