diff --git a/.changeset/curly-shrimps-smash.md b/.changeset/curly-shrimps-smash.md new file mode 100644 index 0000000000..79d6dea189 --- /dev/null +++ b/.changeset/curly-shrimps-smash.md @@ -0,0 +1,5 @@ +--- +"pie-docs": patch +--- + +[Added] - documentation for when buttons and links act like one another diff --git a/.changeset/fifty-panthers-bake.md b/.changeset/fifty-panthers-bake.md new file mode 100644 index 0000000000..3cd2b6f0ff --- /dev/null +++ b/.changeset/fifty-panthers-bake.md @@ -0,0 +1,6 @@ +--- +"@justeattakeaway/pie-button": minor +--- + +[Added] - tag prop which lets the button render as an anchor tag +[Added] - `href`, `rel` and `target` props which can be passed to the anchor element diff --git a/.changeset/little-ligers-sniff.md b/.changeset/little-ligers-sniff.md new file mode 100644 index 0000000000..c16b5f186c --- /dev/null +++ b/.changeset/little-ligers-sniff.md @@ -0,0 +1,7 @@ +--- +"pie-storybook": patch +--- + +[Added] - anchor story for pie-button +[Added] - new props to button stories +[Changed] - exclude some button props from stories where they aren't relevant diff --git a/.changeset/rich-gorillas-agree.md b/.changeset/rich-gorillas-agree.md new file mode 100644 index 0000000000..a9af7403ec --- /dev/null +++ b/.changeset/rich-gorillas-agree.md @@ -0,0 +1,5 @@ +--- +"@justeattakeaway/pie-webc-testing": patch +--- + +[Changed] - update PropObject type to adhere more closely to component interface diff --git a/apps/pie-docs/src/components/button/overview/overview.md b/apps/pie-docs/src/components/button/overview/overview.md index f410809771..fcba447f01 100644 --- a/apps/pie-docs/src/components/button/overview/overview.md +++ b/apps/pie-docs/src/components/button/overview/overview.md @@ -28,15 +28,15 @@ Buttons serve a wide range of purposes in user interfaces, such as submitting fo do: { type: usageTypes.text, items: [ - "Use Buttons when you need to direct the user to an action.", - "When pairing Buttons, use the same sized Buttons together." - + "Use buttons when you need to direct the user to an action.", + "When pairing buttons, use the same sized buttons together.", + "When multiple buttons are used within the same component or page, ensure that there is a clear hierarchy of actions." ] }, dont: { type: usageTypes.text, items: [ - "Do not use buttons as navigational elements. Instead, use links when the desired action is to take the user to a new page." + "Don't mix button sizes when buttons are used together in a pair." ] } } %} @@ -87,7 +87,7 @@ Secondary buttons serve as supplementary options for secondary, non-essential ac ### Outline -Outline buttons are designed to provide increased emphasis compared to ghost buttons, owing to their visible stroke. They can be utilized either as standalone buttons or in combination with a primary button. +Outline buttons are designed to provide increased emphasis compared to ghost buttons, owing to their visible stroke. They can be utilised either as standalone buttons or in combination with a primary button. {% contentPageImage { src:"../../../assets/img/components/button/variation-outline.svg", @@ -257,11 +257,23 @@ Button sizes can adapt to different screen widths, like wide and narrow views, b --- +## Behaviours + +### Buttons that act as links + +This is available when a button needs to be used as a navigational element to direct users to a new page or location. + +Use these with caution - dictation software users may not be able to properly identify these actions, since they are semantically links, even though they may look like buttons. + +--- + ## Content ### Labels -Button labels should clearly indicate the action of the Button and describe what will occur once the user clicks the Button. Use active verbs, such as Add or Delete. For sets of buttons, use specific labels, such as Save or Discard, instead of using OK and Cancel. This is particularly helpful when the user is confirming an action. Use sentence-style capitalisation (only the first world in a phrase and any proper nouns capitalised). +Button labels should clearly indicate the action of the Button and describe what will occur once the user clicks the Button. Use active verbs, such as Add or Delete. For sets of buttons, use specific labels, such as Save or Discard, instead of using OK and Cancel. This is particularly helpful when the user is confirming an action. + +Use sentence-style capitalisation (only the first world in a phrase and any proper nouns capitalised). --- diff --git a/apps/pie-docs/src/components/link/overview/overview.md b/apps/pie-docs/src/components/link/overview/overview.md index 4fb658f2c9..d1d10eb4ae 100644 --- a/apps/pie-docs/src/components/link/overview/overview.md +++ b/apps/pie-docs/src/components/link/overview/overview.md @@ -33,8 +33,7 @@ Links are often used to connect various pages, sections, or external resources, dont: { type: usageTypes.text, items: [ - "Don’t use standalone links as calls to action. Use buttons instead.", - "Don’t use standalone links for actions that will change elements in a screen. Use buttons instead." + "Don't use the reversed styling when surrounded by regular text, as it will get lost." ] } } %} @@ -201,11 +200,21 @@ You can use icons to reinforce the action that will take place when the user int --- +## Behaviours + +### Links that act as buttons + +This is available when a link needs to be used as a call to action that triggers an action for the users. + +Use these with caution - dictation software users may not be able to properly identify these actions, since they are semantically buttons, even though they may look like links. + +--- + ## Content - Be mindful of which words in a paragraph you use for your links. Make sure the words you convert into links are directly related to the content that the link will lead you to. -- Use sentence-style capitalization (only the first word in a phrase and any proper nouns capitalized). +- Use sentence-style capitalisation (only the first word in a phrase and any proper nouns capitalised). --- @@ -372,11 +381,6 @@ Here are some examples of links in right-to-left context: ## Resources -{% notification { - type: "warning", - message: "We’re currently working on updating our Link documentation, please see the resources below." -} %} - {% resourceTable { componentName: 'Link' } %} diff --git a/apps/pie-storybook/stories/pie-button.stories.ts b/apps/pie-storybook/stories/pie-button.stories.ts index ab4144c653..00556c5a54 100644 --- a/apps/pie-storybook/stories/pie-button.stories.ts +++ b/apps/pie-storybook/stories/pie-button.stories.ts @@ -4,13 +4,14 @@ import { ifDefined } from 'lit/directives/if-defined.js'; /* eslint-disable import/no-duplicates */ import '@justeattakeaway/pie-button'; import { - ButtonProps as ButtonPropsBase, iconPlacements, sizes, types, variants, responsiveSizes, defaultProps, + type ButtonProps as ButtonPropsBase, + defaultProps, iconPlacements, responsiveSizes, sizes, types, variants, } from '@justeattakeaway/pie-button'; /* eslint-enable import/no-duplicates */ import '@justeattakeaway/pie-icons-webc/dist/IconPlusCircle.js'; import { createStory, type TemplateFunction, sanitizeAndRenderHTML } from '../utilities'; -import { StoryMeta, SlottedComponentProps } from '../types'; +import { type StoryMeta, type SlottedComponentProps } from '../types'; type ButtonProps = SlottedComponentProps; type ButtonStoryMeta = StoryMeta; @@ -25,6 +26,15 @@ const buttonStoryMeta: ButtonStoryMeta = { title: 'Button', component: 'pie-button', argTypes: { + tag: { + description: 'Choose the HTML element that will be used to render the button.
For this story, the prop has the value of `button`. See the Anchor story to interact with the component when this prop has a value of `a`.', + control: { + disable: true, + }, + defaultValue: { + summary: 'button', + }, + }, size: { description: 'Set the size of the button.', control: 'select', @@ -34,7 +44,7 @@ const buttonStoryMeta: ButtonStoryMeta = { }, }, type: { - description: 'Set the type of the button.', + description: 'Set the type of the button.

Set this to `submit` to reveal more controls relating to form submission.', control: 'select', options: types, defaultValue: { @@ -76,7 +86,7 @@ const buttonStoryMeta: ButtonStoryMeta = { }, }, isResponsive: { - description: 'If `true`, uses the next larger size on wide viewports', + description: 'If `true`, uses the next larger size on wide viewports.

Set this to `true` to show the `responsiveSize` control.', control: 'boolean', defaultValue: { summary: defaultProps.isResponsive, @@ -154,6 +164,18 @@ const buttonStoryMeta: ButtonStoryMeta = { }, if: { arg: 'isResponsive', eq: true }, }, + href: { + description: 'Set the href attribute for the underlying anchor tag.', + control: 'text', + }, + target: { + description: 'Set the target attribute for the underlying anchor tag.', + control: 'text', + }, + rel: { + description: 'Set the rel attribute for the underlying anchor tag', + control: 'text', + }, }, args: defaultArgs, parameters: { @@ -186,26 +208,43 @@ const Template: TemplateFunction = ({ responsiveSize, }) => html` ${iconPlacement ? html`` : nothing} ${sanitizeAndRenderHTML(slot)} `; +const AnchorTemplate: TemplateFunction = (props: ButtonProps) => html` + + ${props.iconPlacement ? html`` : nothing} + ${sanitizeAndRenderHTML(props.slot)} + `; + const FormTemplate: TemplateFunction = (props: ButtonProps) => html`

Fake form

@@ -302,18 +341,67 @@ const createButtonStory = createStory(Template, defaultArgs); const createButtonStoryWithForm = createStory(FormTemplate, defaultArgs); -export const Primary = createButtonStory(); -export const Secondary = createButtonStory({ variant: 'secondary' }); -export const Outline = createButtonStory({ variant: 'outline' }, { bgColor: 'background-subtle' }); -export const Ghost = createButtonStory({ variant: 'ghost' }, { bgColor: 'background-subtle' }); -export const Destructive = createButtonStory({ variant: 'destructive' }); -export const DestructiveGhost = createButtonStory({ variant: 'destructive-ghost' }, { bgColor: 'background-subtle' }); -export const Inverse = createButtonStory({ variant: 'inverse' }, { bgColor: 'dark (container-dark)' }); -export const GhostInverse = createButtonStory({ variant: 'ghost-inverse' }, { bgColor: 'dark (container-dark)' }); -export const OutlineInverse = createButtonStory({ variant: 'outline-inverse' }, { bgColor: 'dark (container-dark)' }); -// For this story we simply want to test form integration with a reset and submit button. Therefore we are restricting what controls are shown. +const anchorOnlyProps : Array = ['href', 'target', 'rel']; + +export const Primary = createButtonStory({}, { + controls: { exclude: ['variant', ...anchorOnlyProps] }, +}); + +export const Secondary = createButtonStory({ variant: 'secondary' }, { + controls: { exclude: ['variant', ...anchorOnlyProps] }, +}); + +export const Outline = createButtonStory({ variant: 'outline' }, { + bgColor: 'background-subtle', + controls: { exclude: ['variant', ...anchorOnlyProps] }, +}); + +export const Ghost = createButtonStory({ variant: 'ghost' }, { + bgColor: 'background-subtle', + controls: { exclude: ['variant', ...anchorOnlyProps] }, +}); + +export const Destructive = createButtonStory({ variant: 'destructive' }, { + controls: { exclude: ['variant', ...anchorOnlyProps] }, +}); + +export const DestructiveGhost = createButtonStory({ variant: 'destructive-ghost' }, { + bgColor: 'background-subtle', + controls: { exclude: ['variant', ...anchorOnlyProps] }, +}); + +export const Inverse = createButtonStory({ variant: 'inverse' }, { + bgColor: 'dark (container-dark)', + controls: { exclude: ['variant', ...anchorOnlyProps] }, +}); + +export const GhostInverse = createButtonStory({ variant: 'ghost-inverse' }, { + bgColor: 'dark (container-dark)', + controls: { exclude: ['variant', ...anchorOnlyProps] }, +}); + +export const OutlineInverse = createButtonStory({ variant: 'outline-inverse' }, { + bgColor: 'dark (container-dark)', + controls: { exclude: ['variant', ...anchorOnlyProps] }, +}); + +export const Anchor = createStory(AnchorTemplate, defaultArgs)({ + href: '/?path=/story/button--anchor', +}, { + argTypes: { + tag: { + description: 'Choose the HTML element that will be used to render the button.
For this story, the prop has the value of `a`. See the other stories to interact with the component when this prop has a value of `button`.', + }, + }, + controls: { + // Hide button-only controls + exclude: ['type', 'disabled', 'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget', 'isLoading', 'name', 'value'], + }, +}); + export const FormIntegration = createButtonStoryWithForm({ type: 'submit' }, { controls: { - exclude: ['type', 'slot', 'variant', 'isFullWidth', 'iconPlacement'], + // For this story we simply want to test form integration with a reset and submit button. Therefore we are restricting what controls are shown. + exclude: ['type', 'slot', 'variant', 'isFullWidth', 'iconPlacement', ...anchorOnlyProps], }, }); diff --git a/packages/components/pie-button/src/button.scss b/packages/components/pie-button/src/button.scss index ed949dd725..7acd5bf464 100644 --- a/packages/components/pie-button/src/button.scss +++ b/packages/components/pie-button/src/button.scss @@ -72,7 +72,7 @@ } position: relative; - display: flex; + display: inline-flex; gap: var(--dt-spacing-b); align-items: center; justify-content: center; @@ -89,6 +89,7 @@ line-height: var(--btn-line-height); cursor: pointer; user-select: none; + text-decoration: none; // used to specify whether the button should be full width or not inline-size: var(--btn-inline-size); diff --git a/packages/components/pie-button/src/defs.ts b/packages/components/pie-button/src/defs.ts index 8bd31ae702..1adcae9780 100644 --- a/packages/components/pie-button/src/defs.ts +++ b/packages/components/pie-button/src/defs.ts @@ -1,5 +1,6 @@ import { type ComponentDefaultProps } from '@justeattakeaway/pie-webc-core'; +export const tags = ['button', 'a'] as const; export const sizes = ['xsmall', 'small-productive', 'small-expressive', 'medium', 'large'] as const; export const responsiveSizes = ['productive', 'expressive'] as const; export const types = ['submit', 'button', 'reset'] as const; @@ -16,30 +17,41 @@ export const formMethodTypes = ['post', 'get', 'dialog'] as const; export const formTargetTypes = ['_self', '_blank', '_parent', '_top'] as const; export interface ButtonProps { + /** + * Which HTML element to use when rendering the button. + */ + tag?: typeof tags[number]; + /** * What size the button should be. */ size?: typeof sizes[number]; + /** * What type attribute should be applied to the button. For example submit, button. */ type?: typeof types[number]; + /** * What style variant the button should be such as primary, outline or ghost. */ variant?: Variant; + /** * The placement of the icon slot, if provided, such as leading or trailing */ iconPlacement?: typeof iconPlacements[number]; + /** * When true, the button element is disabled. */ disabled?: boolean; + /** * When true, the button element will occupy the full width of its container. */ isFullWidth?: boolean; + /** * When true, displays a loading indicator inside the button. */ @@ -99,11 +111,30 @@ export interface ButtonProps { * What size should be attributed to the button when isResponsive is true and the screen is wide. */ responsiveSize?: typeof responsiveSizes[number]; + + /** + * If the button is rendered as an anchor element, this attribute will be applied to the `href` attribute on the anchor. + * [MDN reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href) + */ + href?: string; + + /** + * If the button is rendered as an anchor element, this attribute will be applied to the `rel` attribute on the anchor. + * [MDN reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel) + */ + rel?: string; + + /** + * If the button is rendered as an anchor element, this attribute will be applied to the `target` attribute on the anchor. + * [MDN reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target) + */ + target?: string; } -export type DefaultProps = ComponentDefaultProps; +export type DefaultProps = ComponentDefaultProps; export const defaultProps: DefaultProps = { + tag: 'button', size: 'medium', type: 'submit', variant: 'primary', diff --git a/packages/components/pie-button/src/index.ts b/packages/components/pie-button/src/index.ts index fa7079dbf0..9db2589e3e 100644 --- a/packages/components/pie-button/src/index.ts +++ b/packages/components/pie-button/src/index.ts @@ -1,15 +1,20 @@ import { - LitElement, html, unsafeCSS, nothing, PropertyValues, TemplateResult, + LitElement, html, unsafeCSS, nothing, type PropertyValues, type TemplateResult, } from 'lit'; -import { classMap } from 'lit/directives/class-map.js'; +import { classMap, type ClassInfo } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { property } from 'lit/decorators.js'; +import 'element-internals-polyfill'; + import { validPropertyValues, defineCustomElement, FormControlMixin } from '@justeattakeaway/pie-webc-core'; + +import '@justeattakeaway/pie-spinner'; +import { type SpinnerProps } from '@justeattakeaway/pie-spinner'; + import { - ButtonProps, sizes, types, variants, iconPlacements, defaultProps, + type ButtonProps, defaultProps, iconPlacements, sizes, tags, types, variants, } from './defs'; import styles from './button.scss?inline'; -import 'element-internals-polyfill'; -import '@justeattakeaway/pie-spinner'; // Valid values available to consumers export * from './defs'; @@ -39,8 +44,6 @@ export class PieButton extends FormControlMixin(LitElement) implements ButtonPro } updated (changedProperties: PropertyValues): void { - super.updated(changedProperties); - if (changedProperties.has('type')) { // If the new type is "submit", add the keydown event listener if (this.type === 'submit') { @@ -51,21 +54,25 @@ export class PieButton extends FormControlMixin(LitElement) implements ButtonPro } } - @property() + @property({ type: String }) + @validPropertyValues(componentSelector, tags, defaultProps.tag) + public tag = defaultProps.tag; + + @property({ type: String }) @validPropertyValues(componentSelector, sizes, defaultProps.size) - public size: ButtonProps['size'] = defaultProps.size; + public size = defaultProps.size; - @property() + @property({ type: String }) @validPropertyValues(componentSelector, types, defaultProps.type) - public type: ButtonProps['type'] = defaultProps.type; + public type = defaultProps.type; - @property() + @property({ type: String }) @validPropertyValues(componentSelector, variants, defaultProps.variant) - public variant: ButtonProps['variant'] = defaultProps.variant; + public variant = defaultProps.variant; @property({ type: String }) @validPropertyValues(componentSelector, iconPlacements, defaultProps.iconPlacement) - public iconPlacement: ButtonProps['iconPlacement'] = defaultProps.iconPlacement; + public iconPlacement = defaultProps.iconPlacement; @property({ type: Boolean }) public disabled = defaultProps.disabled; @@ -80,28 +87,37 @@ export class PieButton extends FormControlMixin(LitElement) implements ButtonPro public isResponsive = defaultProps.isResponsive; @property({ type: String }) - public name?: string; + public name: ButtonProps['name']; @property({ type: String }) - public value?: string; + public value: ButtonProps['value']; - @property() + @property({ type: String }) public formaction: ButtonProps['formaction']; - @property() + @property({ type: String }) public formenctype: ButtonProps['formenctype']; - @property() + @property({ type: String }) public formmethod: ButtonProps['formmethod']; @property({ type: Boolean }) public formnovalidate: ButtonProps['formnovalidate']; - @property() + @property({ type: String }) public formtarget: ButtonProps['formtarget']; @property({ type: String }) - public responsiveSize?: ButtonProps['responsiveSize']; + public responsiveSize: ButtonProps['responsiveSize']; + + @property({ type: String }) + public href: ButtonProps['href']; + + @property({ type: String }) + public rel: ButtonProps['rel']; + + @property({ type: String }) + public target: ButtonProps['target']; /** * This method creates an invisible button of the same type as pie-button. It is then clicked, and immediately removed from the DOM. @@ -157,17 +173,17 @@ export class PieButton extends FormControlMixin(LitElement) implements ButtonPro } private _handleClick () { - if (!this.isLoading && this.form) { - if (this.type === 'submit') { - // only submit the form if either formnovalidate is set, or the form passes validation checks (triggers native form validation) - if (this.formnovalidate || this.form.reportValidity()) { - this._simulateNativeButtonClick('submit'); - } - } + if (!this.form) return; + if (this.isLoading) return; + if (this.tag !== 'button') return; - if (this.type === 'reset') { - this._simulateNativeButtonClick('reset'); + if (this.type === 'submit') { + // only submit the form if either formnovalidate is set, or the form passes validation checks (triggers native form validation) + if (this.formnovalidate || this.form.reportValidity()) { + this._simulateNativeButtonClick('submit'); } + } else if (this.type === 'reset') { + this._simulateNativeButtonClick('reset'); } } @@ -197,8 +213,9 @@ export class PieButton extends FormControlMixin(LitElement) implements ButtonPro */ private renderSpinner (): TemplateResult { const { size, variant, disabled } = this; - const spinnerSize = size && size.includes('small') ? 'small' : 'medium'; // includes("small") matches for any small size value and xsmall - let spinnerVariant; + + const spinnerSize: SpinnerProps['size'] = size && size.includes('small') ? 'small' : 'medium'; // includes("small") matches for any small size value and xsmall + let spinnerVariant: SpinnerProps['variant']; if (disabled) { spinnerVariant = variant === 'ghost-inverse' ? 'inverse' : 'secondary'; } else { @@ -207,23 +224,60 @@ export class PieButton extends FormControlMixin(LitElement) implements ButtonPro } return html` - - `; + + `; + } + + renderAnchor (classes: ClassInfo) { + const { + href, iconPlacement, rel, target, + } = this; + + return html` + + ${iconPlacement === 'leading' ? html`` : nothing} + + ${iconPlacement === 'trailing' ? html`` : nothing} + `; + } + + renderButton (classes: ClassInfo) { + const { + disabled, iconPlacement, isLoading, type, + } = this; + + const buttonClasses = { + ...classes, + 'is-loading': isLoading, + }; + + return html` + `; } render () { const { - type, - disabled, isFullWidth, - variant, - size, - isLoading, isResponsive, - iconPlacement, responsiveSize, + size, + tag, + variant, } = this; const classes = { @@ -233,20 +287,13 @@ export class PieButton extends FormControlMixin(LitElement) implements ButtonPro [`o-btn--${responsiveSize}`]: Boolean(isResponsive && responsiveSize), [`o-btn--${variant}`]: true, [`o-btn--${size}`]: true, - 'is-loading': isLoading, }; - return html` - `; + if (tag === 'a') { + return this.renderAnchor(classes); + } + + return this.renderButton(classes); } focus () { diff --git a/packages/components/pie-button/test/accessibility/pie-button.spec.ts b/packages/components/pie-button/test/accessibility/pie-button.spec.ts index 08c2387318..78f3ceb17f 100644 --- a/packages/components/pie-button/test/accessibility/pie-button.spec.ts +++ b/packages/components/pie-button/test/accessibility/pie-button.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@justeattakeaway/pie-webc-testing/src/playwright/webc-fixtures.ts'; import { getAllPropCombinations, splitCombinationsByPropertyValue } from '@justeattakeaway/pie-webc-testing/src/helpers/get-all-prop-combos.ts'; -import { PropObject, WebComponentPropValues } from '@justeattakeaway/pie-webc-testing/src/helpers/defs.ts'; +import { type PropObject, type WebComponentPropValues } from '@justeattakeaway/pie-webc-testing/src/helpers/defs.ts'; import { PieButton } from '../../src/index.ts'; import { sizes, variants } from '../../src/defs.ts'; diff --git a/packages/components/pie-button/test/component/pie-button.spec.ts b/packages/components/pie-button/test/component/pie-button.spec.ts index 3339c39729..c845789874 100644 --- a/packages/components/pie-button/test/component/pie-button.spec.ts +++ b/packages/components/pie-button/test/component/pie-button.spec.ts @@ -1,18 +1,13 @@ import { getShadowElementStylePropValues } from '@justeattakeaway/pie-webc-testing/src/helpers/get-shadow-element-style-prop-values.ts'; import { test, expect } from '@sand4rt/experimental-ct-web'; -import { PieButton, ButtonProps } from '../../src/index.ts'; - -const props: Partial = { - size: 'large', - variant: 'primary', -}; +import { PieButton, type ButtonProps } from '../../src/index.ts'; type SizeResponsiveSize = { sizeName: ButtonProps['size']; responsiveSize: string; }; -const sizes:Array = [ +const sizes: Array = [ { sizeName: 'xsmall', responsiveSize: '--btn-height--small' }, { sizeName: 'small-expressive', responsiveSize: '--btn-height--medium' }, { sizeName: 'small-productive', responsiveSize: '--btn-height--medium' }, @@ -26,7 +21,6 @@ test('should correctly work with native click events', async ({ mount }) => { const component = await mount( PieButton, { - props, slots: { default: 'Click me!', }, @@ -552,7 +546,6 @@ test.describe('props', () => { const component = await mount( PieButton, { - props, slots: { default: 'Click me!', }, @@ -565,8 +558,7 @@ test.describe('props', () => { }); test.describe('when set to true', () => { test('the button should have the attribute', async ({ mount }) => { - const testProps:Partial = { - ...props, + const props: ButtonProps = { size: 'xsmall', isResponsive: true, }; @@ -574,7 +566,7 @@ test.describe('props', () => { const component = await mount( PieButton, { - props: testProps, + props, slots: { default: 'Click me!', }, @@ -587,14 +579,13 @@ test.describe('props', () => { sizes.forEach(({ sizeName, responsiveSize }) => { test(`a "${sizeName}" size button height should be equivalent to "${responsiveSize}"`, async ({ mount }) => { - const testProps: Partial = { - ...props, + const props: ButtonProps = { size: sizeName, isResponsive: true, }; const component = await mount(PieButton, { - props: testProps, + props, slots: { default: 'Click me!', }, @@ -614,7 +605,6 @@ test.describe('props', () => { const component = await mount( PieButton, { - props, slots: { default: 'Click me!', }, @@ -629,8 +619,7 @@ test.describe('props', () => { test.describe('when "isResponsive" is true', () => { test.describe('when "responsiveSize" is "expressive"', () => { test('the button should have the expected attribute', async ({ mount }) => { - const testProps:Partial = { - ...props, + const props: ButtonProps = { size: 'xsmall', isResponsive: true, responsiveSize: 'expressive', @@ -639,7 +628,7 @@ test.describe('props', () => { const component = await mount( PieButton, { - props: testProps, + props, slots: { default: 'Click me!', }, @@ -654,8 +643,7 @@ test.describe('props', () => { test.describe('when "responsiveSize" is "productive"', () => { test('the button should have the expected attribute', async ({ mount }) => { - const testProps:Partial = { - ...props, + const props: ButtonProps = { size: 'xsmall', isResponsive: true, responsiveSize: 'productive', @@ -664,7 +652,7 @@ test.describe('props', () => { const component = await mount( PieButton, { - props: testProps, + props, slots: { default: 'Click me!', }, @@ -676,4 +664,111 @@ test.describe('props', () => { }); }); }); + + test.describe('tag', () => { + test.describe('when set to "button"', () => { + test('should render a button element', async ({ mount }) => { + // Arrange + const props: ButtonProps = { + tag: 'button', + }; + + // Act + const component = await mount(PieButton, { + props, + slots: { + default: 'Click me!', + }, + }); + + const button = component.locator('button'); + + // Assert + expect(button).toBeVisible(); + }); + + test('should not render anchor-specific attributes', async ({ mount }) => { + // Arrange + const props: ButtonProps = { + tag: 'button', + // Anchor-specific props + href: '/test', + rel: 'noopener noreferrer', + target: '_blank', + }; + + // Act + const component = await mount(PieButton, { + props, + slots: { + default: 'Click me!', + }, + }); + + const button = component.locator('button'); + + const href = await button.getAttribute('href'); + const rel = await button.getAttribute('rel'); + const target = await button.getAttribute('target'); + + // Assert + expect.soft(rel).toBeNull(); + expect.soft(target).toBeNull(); + expect(href).toBeNull(); + }); + }); + + test.describe('when set to "a"', () => { + test('should render an anchor element', async ({ mount }) => { + // Arrange + const props: ButtonProps = { + tag: 'a', + }; + + // Act + const component = await mount(PieButton, { + props, + slots: { + default: 'Click me!', + }, + }); + + const anchor = component.locator('a'); + + // Assert + expect(anchor).toBeVisible(); + }); + + test('should not render button-specific attributes', async ({ mount }) => { + // Arrange + const props: ButtonProps = { + tag: 'a', + // Button-specific props + disabled: true, + isLoading: true, + type: 'submit', + }; + + // Act + const component = await mount(PieButton, { + props, + slots: { + default: 'Click me!', + }, + }); + + const anchor = component.locator('a'); + + // Assert + const disabled = await anchor.getAttribute('disabled'); + const type = await anchor.getAttribute('type'); + const spinner = component.locator('pie-spinner'); + + expect.soft(anchor).not.toHaveClass(/is-loading/); + expect.soft(disabled).toBeNull(); + expect.soft(type).toBeNull(); + expect(spinner).not.toBeVisible(); + }); + }); + }); }); diff --git a/packages/components/pie-button/test/visual/pie-button-anchor.spec.ts b/packages/components/pie-button/test/visual/pie-button-anchor.spec.ts new file mode 100644 index 0000000000..a18832a64a --- /dev/null +++ b/packages/components/pie-button/test/visual/pie-button-anchor.spec.ts @@ -0,0 +1,48 @@ +import { test } from '@sand4rt/experimental-ct-web'; +import percySnapshot from '@percy/playwright'; + +import { type PropObject, type WebComponentPropValues } from '@justeattakeaway/pie-webc-testing/src/helpers/defs.ts'; +import { getAllPropCombinations } from '@justeattakeaway/pie-webc-testing/src/helpers/get-all-prop-combos.ts'; +import { createTestWebComponent } from '@justeattakeaway/pie-webc-testing/src/helpers/rendering.ts'; +import { WebComponentTestWrapper } from '@justeattakeaway/pie-webc-testing/src/helpers/components/web-component-test-wrapper/WebComponentTestWrapper.ts'; +import { percyWidths } from '@justeattakeaway/pie-webc-testing/src/percy/breakpoints.ts'; + +import { PieButton } from '../../src/index.ts'; +import { type ButtonProps, sizes, variants } from '../../src/defs.ts'; + +const props: PropObject = { + variant: variants, + size: sizes, + tag: 'a', +}; + +// Renders a HTML string with the given prop values +const renderTestPieButton = (propVals: WebComponentPropValues) => `Hello world`; + +const componentPropsMatrix = getAllPropCombinations(props); + +test.beforeEach(async ({ mount }, testInfo) => { + testInfo.setTimeout(testInfo.timeout + 40000); + const component = await mount(PieButton); + await component.unmount(); +}); + +test('should render all size and variant variations for anchor tag', async ({ page, mount }) => { + for (const combo of componentPropsMatrix) { + const { renderedString, propValues } = createTestWebComponent(combo, renderTestPieButton); + const propKeyValues = `tag: ${propValues.tag}, size: ${propValues.size}, variant: ${propValues.variant}`; + const darkMode = ['inverse', 'ghost-inverse', 'outline-inverse'].includes(propValues.variant); + + await mount( + WebComponentTestWrapper, + { + props: { propKeyValues, darkMode }, + slots: { + component: renderedString.trim(), + }, + }, + ); + } + + await percySnapshot(page, 'PIE Button Anchor - sizes/variants', percyWidths); +}); diff --git a/packages/components/pie-button/test/visual/pie-button-size.spec.ts b/packages/components/pie-button/test/visual/pie-button-size.spec.ts index 5e72307ba3..5f97dfb25a 100644 --- a/packages/components/pie-button/test/visual/pie-button-size.spec.ts +++ b/packages/components/pie-button/test/visual/pie-button-size.spec.ts @@ -1,7 +1,7 @@ import { test } from '@sand4rt/experimental-ct-web'; import percySnapshot from '@percy/playwright'; -import type { - PropObject, WebComponentPropValues, WebComponentTestInput, +import { + type PropObject, type WebComponentPropValues, } from '@justeattakeaway/pie-webc-testing/src/helpers/defs.ts'; import { getAllPropCombinations, @@ -14,9 +14,9 @@ import { } from '@justeattakeaway/pie-webc-testing/src/helpers/components/web-component-test-wrapper/WebComponentTestWrapper.ts'; import { percyWidths } from '@justeattakeaway/pie-webc-testing/src/percy/breakpoints.ts'; import { PieButton } from '../../src/index.ts'; -import { sizes } from '../../src/defs.ts'; +import { type ButtonProps, sizes } from '../../src/defs.ts'; -const props: PropObject = { +const props: PropObject = { variant: ['primary'], size: sizes, isResponsive: [true, false], @@ -26,7 +26,7 @@ const props: PropObject = { // Renders a HTML string with the given prop values const renderTestPieButton = (propVals: WebComponentPropValues) => `Hello world`; -const componentPropsMatrix : WebComponentPropValues[] = getAllPropCombinations(props); +const componentPropsMatrix = getAllPropCombinations(props); test.beforeEach(async ({ mount }, testInfo) => { testInfo.setTimeout(testInfo.timeout + 40000); @@ -36,7 +36,7 @@ test.beforeEach(async ({ mount }, testInfo) => { test('should render all size variations', async ({ page, mount }) => { for (const combo of componentPropsMatrix) { - const testComponent: WebComponentTestInput = createTestWebComponent(combo, renderTestPieButton); + const testComponent = createTestWebComponent(combo, renderTestPieButton); const propKeyValues = `size: ${testComponent.propValues.size}, isResponsive: ${testComponent.propValues.isResponsive}, responsiveSize: ${testComponent.propValues.responsiveSize}`; await mount( diff --git a/packages/components/pie-divider/test/visual/pie-divider.spec.ts b/packages/components/pie-divider/test/visual/pie-divider.spec.ts index 5468ef435c..3316e51a9b 100644 --- a/packages/components/pie-divider/test/visual/pie-divider.spec.ts +++ b/packages/components/pie-divider/test/visual/pie-divider.spec.ts @@ -14,10 +14,10 @@ import { WebComponentTestWrapper, } from '@justeattakeaway/pie-webc-testing/src/helpers/components/web-component-test-wrapper/WebComponentTestWrapper.ts'; import { percyWidths } from '@justeattakeaway/pie-webc-testing/src/percy/breakpoints.ts'; -import { variants, orientations } from '../../src/defs.ts'; +import { variants, orientations, type DividerProps } from '../../src/defs.ts'; import { PieDivider } from '../../src/index.ts'; -const props: PropObject = { +const props: PropObject = { variant: variants, orientation: orientations, }; diff --git a/packages/components/pie-webc-testing/src/helpers/defs.ts b/packages/components/pie-webc-testing/src/helpers/defs.ts index 3f5b118ac6..1d619b0db5 100644 --- a/packages/components/pie-webc-testing/src/helpers/defs.ts +++ b/packages/components/pie-webc-testing/src/helpers/defs.ts @@ -1,6 +1,21 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -export type PropObject = { - [key: string]: any; + +/** + * This test helper type contains the same keys as the component prop interface. + * The values can be either a single value or an array of values. + * This is useful for testing components that have multiple props with the same type. + * @example + * interface TestComponentProps { + * foo: string; + * bar: number; + * } + * const testComponentProps: PropObject = { + * foo: 'foo', + * bar: [1, 2, 3], + * }; + */ +export type PropObject = { + [K in keyof T]: T[K] | Readonly; }; export type WebComponentPropValues = { diff --git a/yarn.lock b/yarn.lock index 4097f11011..a69a4c26e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5547,7 +5547,7 @@ __metadata: languageName: unknown linkType: soft -"@justeattakeaway/pie-cookie-banner@0.25.1, @justeattakeaway/pie-cookie-banner@workspace:packages/components/pie-cookie-banner": +"@justeattakeaway/pie-cookie-banner@0.26.0, @justeattakeaway/pie-cookie-banner@workspace:packages/components/pie-cookie-banner": version: 0.0.0-use.local resolution: "@justeattakeaway/pie-cookie-banner@workspace:packages/components/pie-cookie-banner" dependencies: @@ -5869,7 +5869,7 @@ __metadata: languageName: unknown linkType: soft -"@justeattakeaway/pie-webc@0.5.25, @justeattakeaway/pie-webc@workspace:packages/components/pie-webc": +"@justeattakeaway/pie-webc@0.5.26, @justeattakeaway/pie-webc@workspace:packages/components/pie-webc": version: 0.0.0-use.local resolution: "@justeattakeaway/pie-webc@workspace:packages/components/pie-webc" dependencies: @@ -5880,7 +5880,7 @@ __metadata: "@justeattakeaway/pie-checkbox-group": 0.6.2 "@justeattakeaway/pie-chip": 0.8.0 "@justeattakeaway/pie-components-config": 0.18.0 - "@justeattakeaway/pie-cookie-banner": 0.25.1 + "@justeattakeaway/pie-cookie-banner": 0.26.0 "@justeattakeaway/pie-divider": 0.13.9 "@justeattakeaway/pie-form-label": 0.14.1 "@justeattakeaway/pie-icon-button": 0.28.10 @@ -26182,7 +26182,7 @@ __metadata: "@justeattakeaway/pie-checkbox": 0.12.2 "@justeattakeaway/pie-checkbox-group": 0.6.2 "@justeattakeaway/pie-chip": 0.8.0 - "@justeattakeaway/pie-cookie-banner": 0.25.1 + "@justeattakeaway/pie-cookie-banner": 0.26.0 "@justeattakeaway/pie-css": 0.12.1 "@justeattakeaway/pie-divider": 0.13.9 "@justeattakeaway/pie-form-label": 0.14.1 @@ -34128,7 +34128,7 @@ __metadata: "@angular/platform-browser-dynamic": 15.2.0 "@angular/router": 15.2.0 "@justeattakeaway/pie-css": 0.12.1 - "@justeattakeaway/pie-webc": 0.5.25 + "@justeattakeaway/pie-webc": 0.5.26 rxjs: 7.8.0 tslib: 2.3.0 typescript: 4.9.4 @@ -34145,7 +34145,7 @@ __metadata: "@babel/preset-env": 7.24.5 "@babel/preset-react": 7.24.1 "@justeattakeaway/pie-css": 0.12.1 - "@justeattakeaway/pie-webc": 0.5.25 + "@justeattakeaway/pie-webc": 0.5.26 "@lit/react": 1.0.2 babel-loader: 8 eslint: 8.37.0 @@ -34162,7 +34162,7 @@ __metadata: resolution: "wc-next13@workspace:apps/examples/wc-next13" dependencies: "@justeattakeaway/pie-css": 0.12.1 - "@justeattakeaway/pie-webc": 0.5.25 + "@justeattakeaway/pie-webc": 0.5.26 "@lit-labs/nextjs": 0.2.0 "@lit/react": 1.0.5 "@types/react": 18.3.3 @@ -34185,7 +34185,7 @@ __metadata: dependencies: "@babel/preset-env": 7.24.5 "@justeattakeaway/pie-css": 0.12.1 - "@justeattakeaway/pie-webc": 0.5.25 + "@justeattakeaway/pie-webc": 0.5.26 babel-loader: 8 core-js: 3.30.0 nuxt: 2.17.0 @@ -34200,7 +34200,7 @@ __metadata: resolution: "wc-nuxt3@workspace:apps/examples/wc-nuxt3" dependencies: "@justeattakeaway/pie-css": 0.12.1 - "@justeattakeaway/pie-webc": 0.5.25 + "@justeattakeaway/pie-webc": 0.5.26 "@types/node": 18 nuxt: 3.4.3 nuxt-ssr-lit: 1.6.5 @@ -34212,7 +34212,7 @@ __metadata: resolution: "wc-react17@workspace:apps/examples/wc-react17" dependencies: "@justeattakeaway/pie-css": 0.12.1 - "@justeattakeaway/pie-webc": 0.5.25 + "@justeattakeaway/pie-webc": 0.5.26 "@lit/react": 1.0.5 "@types/react": ^17.0.2 "@types/react-dom": ^17.0.2 @@ -34232,7 +34232,7 @@ __metadata: resolution: "wc-react18@workspace:apps/examples/wc-react18" dependencies: "@justeattakeaway/pie-css": 0.12.1 - "@justeattakeaway/pie-webc": 0.5.25 + "@justeattakeaway/pie-webc": 0.5.26 "@lit/react": 1.0.5 "@types/react": 18.3.3 "@types/react-dom": 18.3.0 @@ -34254,7 +34254,7 @@ __metadata: "@justeat/pie-design-tokens": 6.3.1 "@justeattakeaway/pie-css": 0.12.1 "@justeattakeaway/pie-icons-webc": 0.25.0 - "@justeattakeaway/pie-webc": 0.5.25 + "@justeattakeaway/pie-webc": 0.5.26 vite: 4.5.3 languageName: unknown linkType: soft @@ -34264,7 +34264,7 @@ __metadata: resolution: "wc-vue3@workspace:apps/examples/wc-vue3" dependencies: "@justeattakeaway/pie-css": 0.12.1 - "@justeattakeaway/pie-webc": 0.5.25 + "@justeattakeaway/pie-webc": 0.5.26 "@types/node": 18.15.11 "@vitejs/plugin-vue": 4.0.0 "@vue/tsconfig": 0.1.3