From 13aafda02e6c3dbce471293b79ff33dc49a1b6e2 Mon Sep 17 00:00:00 2001 From: Lizzy Zhang <77908367+ezhangy@users.noreply.github.com> Date: Wed, 14 Aug 2024 15:23:38 -0400 Subject: [PATCH] Implement basic njwds-button component (#4) * wip: add button tests for variant prop * refactor: rename boolean button props to look more like HTML attributes * refactor: button component to only have variant prop * tests: add button tests to check as-child attribute * chore: delete unnecessary buttonAttributes component * chore: remove buttonAttributes from Njwdsbutton interface * refactor: button component uses NJWDS naming for variant property * delete autogen docs * add components to www output * delete autogen readmes * add button doc page * fix: inverse needs outline class to work * rename variants to secondary-dark and link-dark * refactor variant prop to type * wip: button design review * wip: update button HTML docs * add mode prop --- packages/stencil-library/src/components.d.ts | 6 + .../src/components/banner/readme.md | 10 -- .../src/components/button/button.e2e.ts | 105 ++++++++++++++++++ .../src/components/button/button.spec.ts | 54 ++++++++- .../src/components/button/button.tsx | 78 +++++++++++-- .../src/components/button/index.html | 58 ++++++++++ .../src/components/button/readme.md | 17 --- .../src/components/my-component/readme.md | 19 ---- packages/stencil-library/src/index.html | 4 +- packages/stencil-library/src/interface.d.ts | 1 + 10 files changed, 293 insertions(+), 59 deletions(-) delete mode 100644 packages/stencil-library/src/components/banner/readme.md create mode 100644 packages/stencil-library/src/components/button/button.e2e.ts create mode 100644 packages/stencil-library/src/components/button/index.html delete mode 100644 packages/stencil-library/src/components/button/readme.md delete mode 100644 packages/stencil-library/src/components/my-component/readme.md create mode 100644 packages/stencil-library/src/interface.d.ts diff --git a/packages/stencil-library/src/components.d.ts b/packages/stencil-library/src/components.d.ts index 0540ae4..2f223fd 100644 --- a/packages/stencil-library/src/components.d.ts +++ b/packages/stencil-library/src/components.d.ts @@ -6,7 +6,9 @@ */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { ButtonVariant } from "./components/button/button"; +import { Mode } from "./interface"; export { ButtonVariant } from "./components/button/button"; +export { Mode } from "./interface"; export namespace Components { interface MyComponent { /** @@ -30,6 +32,8 @@ export namespace Components { interface NjwdsBanner { } interface NjwdsButton { + "asChild": boolean; + "mode": Mode; "variant": ButtonVariant; } } @@ -88,6 +92,8 @@ declare namespace LocalJSX { interface NjwdsBanner { } interface NjwdsButton { + "asChild"?: boolean; + "mode"?: Mode; "variant"?: ButtonVariant; } interface IntrinsicElements { diff --git a/packages/stencil-library/src/components/banner/readme.md b/packages/stencil-library/src/components/banner/readme.md deleted file mode 100644 index 33a1c29..0000000 --- a/packages/stencil-library/src/components/banner/readme.md +++ /dev/null @@ -1,10 +0,0 @@ -# njwds-banner - - - -<!-- Auto Generated Below --> - - ----------------------------------------------- - -*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/stencil-library/src/components/button/button.e2e.ts b/packages/stencil-library/src/components/button/button.e2e.ts new file mode 100644 index 0000000..45cfbbf --- /dev/null +++ b/packages/stencil-library/src/components/button/button.e2e.ts @@ -0,0 +1,105 @@ +import { E2EElement, newE2EPage } from '@stencil/core/testing'; + +const renderAndGetButtonElement = async (content: string): Promise<E2EElement> => { + const page = await newE2EPage(); + await page.setContent(content); + const button = await page.find('njwds-button > button'); + return button; +}; + +describe('<njwds-button>', () => { + describe('variant', () => { + it('renders with only the "usa-button" class by default', async () => { + const button = await renderAndGetButtonElement('<njwds-button></njwds-button>'); + expect(button.className).toBe('usa-button'); + }); + + it('renders the primary variant with only the "usa-button" class', async () => { + const button = await renderAndGetButtonElement(` + <njwds-button variant="primary"></njwds-button> + `); + const buttonClasses = button.className.split(' ').sort(); + expect(buttonClasses).toEqual(['usa-button']); + }); + + it('renders the secondary variant with "usa-button--outline" class', async () => { + const button = await renderAndGetButtonElement(` + <njwds-button variant="secondary"></njwds-button> + `); + const buttonClasses = button.className.split(' ').sort(); + expect(buttonClasses).toEqual(['usa-button', 'usa-button--outline'].sort()); + }); + + it('renders the link variant with USWDS unstyled styling', async () => { + const button = await renderAndGetButtonElement(` + <njwds-button variant="link"></njwds-button> + `); + const buttonClasses = button.className.split(' ').sort(); + expect(buttonClasses).toEqual(['usa-button', 'usa-button--unstyled'].sort()); + }); + + it('renders danger variant with USWDS secondary styling', async () => { + const button = await renderAndGetButtonElement(` + <njwds-button variant="danger"></njwds-button> + `); + const buttonClasses = button.className.split(' ').sort(); + expect(buttonClasses).toEqual(['usa-button', 'usa-button--secondary'].sort()); + }); + }); + + describe('mode', () => { + // TODO: How to write a test checking if mode="light" by default + + it.each(['primary', 'danger'])("mode doesn't affect %s variant", async variant => { + const lightButtonClasses = ( + await renderAndGetButtonElement(` + <njwds-button variant="${variant}" mode="light"></njwds-button> + `) + ).className.split(' '); + const darkButtonClasses = ( + await renderAndGetButtonElement(` + <njwds-button variant="${variant}" mode="dark"></njwds-button> + `) + ).className.split(' '); + expect(lightButtonClasses.sort()).toEqual(darkButtonClasses.sort()); + }); + + it('renders dark mode link variant as USWDS outline inverse, unstyled variant', async () => { + const button = await renderAndGetButtonElement(` + <njwds-button variant="link" mode="dark"></njwds-button> + `); + const buttonClasses = button.className.split(' ').sort(); + expect(buttonClasses).toEqual(['usa-button', 'usa-button--unstyled', 'usa-button--outline', 'usa-button--inverse'].sort()); + }); + + it('renders dark mode link variant as USWDS outline inverse, unstyled variant', async () => { + const button = await renderAndGetButtonElement(` + <njwds-button variant="link" mode="dark"></njwds-button> + `); + const buttonClasses = button.className.split(' ').sort(); + expect(buttonClasses).toEqual(['usa-button', 'usa-button--unstyled', 'usa-button--outline', 'usa-button--inverse'].sort()); + }); + }); + + describe('asChild', () => { + it('renders a button element when asChild is false (default)', async () => { + const button = await renderAndGetButtonElement('<njwds-button></njwds-button>'); + expect(button.tagName).toBe('BUTTON'); + }); + + it('renders with only the "usa-button" class by default', async () => { + const button = await renderAndGetButtonElement('<njwds-button as-child><button></button></njwds-button>'); + expect(button.className.trim()).toBe('usa-button'); + }); + + it('renders the button slot element with custom attributes when asChild is true', async () => { + const button = await renderAndGetButtonElement(` + <njwds-button as-child> + <button data-test-1="1" data-test-2="2"></button> + </njwds-button> + `); + expect(button).toEqualAttribute('data-test-1', '1'); + expect(button).toEqualAttribute('data-test-2', '2'); + }); + }); +}); diff --git a/packages/stencil-library/src/components/button/button.spec.ts b/packages/stencil-library/src/components/button/button.spec.ts index 322d05f..5af3a21 100644 --- a/packages/stencil-library/src/components/button/button.spec.ts +++ b/packages/stencil-library/src/components/button/button.spec.ts @@ -1,6 +1,54 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { Button } from './button'; + describe('<njwds-button>', () => { - describe('variant', () => { - it('by default, renders without special styling', () => {}); - it.each(['secondary', 'accent-cool', 'accent-warm', 'base', 'outline']); + describe('asChild', () => { + it('throws an error when asChild is true there are no slots', async () => { + const renderButtonWithNoSlots = () => + newSpecPage({ + components: [Button], + html: `<njwds-button as-child></njwds-button>`, + }); + await expect(renderButtonWithNoSlots).rejects.toThrow(); + }); + + it('throws an error when asChild is true and there is more than one slot', async () => { + const renderButtonWithTwoSlots = () => + newSpecPage({ + components: [Button], + html: ` + <njwds-button as-child> + <button>slot 1</button> + <button>slot 2</button> + </njwds-button>`, + }); + await expect(renderButtonWithTwoSlots).rejects.toThrow(); + }); + + it('throws an error when asChild is true and the slot element is not a button', async () => { + const renderButtonWithParagraphSlot = async () => + await newSpecPage({ + components: [Button], + html: ` + <njwds-button as-child> + <p>slot</p> + </njwds-button>`, + }); + await expect(renderButtonWithParagraphSlot).rejects.toThrow(); + }); + + it('does not throw error when asChild is true and there is a single slot element that is a <button>', async () => { + const page = await newSpecPage({ + components: [Button], + html: ` + <njwds-button as-child="true"> + <button>slot</button> + </njwds-button>`, + }); + const slotElements = page.root.children; + expect(slotElements.length).toBe(1); + const slotElement = slotElements[0]; + expect(slotElement.tagName).toBe('BUTTON'); + }); }); }); diff --git a/packages/stencil-library/src/components/button/button.tsx b/packages/stencil-library/src/components/button/button.tsx index b3fe328..19d7eea 100644 --- a/packages/stencil-library/src/components/button/button.tsx +++ b/packages/stencil-library/src/components/button/button.tsx @@ -1,19 +1,81 @@ import { Component, Prop, h } from "@stencil/core"; +import { Host, HTMLStencilElement } from "@stencil/core/internal"; +import { Element } from '@stencil/core'; +import { Mode } from "../../interface"; -export type ButtonVariant = "default" | "secondary" | "accent-cool" | "accent-warm" | "base" | "outline" + +export type ButtonVariant = "primary" | "secondary" | "link" | "danger" @Component({ tag: "njwds-button", }) export class Button { - @Prop() variant: ButtonVariant = "default"; + @Prop() variant: ButtonVariant = "primary"; + @Prop() mode: Mode = "light" + + @Prop() asChild: boolean = false + @Element() private hostElement: HTMLStencilElement; + + private getButtonClassName(): string { + const getVariantClassName = (variant: ButtonVariant): string => { + switch (variant) { + case 'primary': + return "" + case "secondary": + return "usa-button--outline" + case 'link': + return "usa-button--unstyled" + case 'danger': + return "usa-button--secondary" + } + } + + const getDarkModeVariantClassName = (variant: ButtonVariant) => { + switch (variant) { + case "secondary": + return "usa-button--outline usa-button--inverse" + case 'link': + return "usa-button--unstyled usa-button--outline usa-button--inverse" + default: + return getVariantClassName(variant) + } + } + + return this.mode === "light" + ? `usa-button ${getVariantClassName(this.variant)}` + : `usa-button ${getDarkModeVariantClassName(this.variant)}` + } + + componentWillLoad() { + if (this.asChild) { + const slotElements = this.hostElement.children + if (slotElements.length !== 1) { + throw new Error(`If the asChild property is set to true on the njwds-button component, the component must have exactly one slot element. Instead got ${slotElements.length} elements.`) + } + if (slotElements[0].tagName !== "BUTTON") { + throw new Error(`If the asChild property is set to true on the njwds-button component, the slot element must be a <button>. Instead got ${slotElements[0].outerHTML}`) + } + const buttonElement = slotElements[0] as HTMLButtonElement + const buttonClassName = this.getButtonClassName() + buttonElement.className = `${buttonElement.className} ${buttonClassName}` + } + + } + render() { - const variantClass = `usa-button--${this.variant}` - return ( - <button class={`usa-button ${variantClass}`}> - <slot /> - </button> - ) + const buttonClassName = this.getButtonClassName() + + return this.asChild + ? ( + <Host> + <slot /> + </Host> + ) + : ( + <button class={buttonClassName}> + <slot /> + </button> + ) } } \ No newline at end of file diff --git a/packages/stencil-library/src/components/button/index.html b/packages/stencil-library/src/components/button/index.html new file mode 100644 index 0000000..b960c92 --- /dev/null +++ b/packages/stencil-library/src/components/button/index.html @@ -0,0 +1,58 @@ +<!DOCTYPE html> +<html dir="ltr" lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0" /> + <title>Button Component</title> + <script type="module" src="/build/stencil-library.esm.js"></script> + <script nomodule src="/build/stencil-library.js"></script> + <link rel="stylesheet" href="/build/css/styles.css" /> + </head> + <body> + <h1>Button</h1> + <h2>Primary</h2> + <njwds-button variant="primary"> + Button + </njwds-button> + <h2>Secondary</h2> + <njwds-button variant="secondary"> + Button + </njwds-button> + <h2>Secondary Dark</h2> + <njwds-button variant="secondary" mode="dark" style="background-color: black; padding: 2rem;"> + Button + </njwds-button> + <h2>Link</h2> + <njwds-button variant="link" > + Button + </njwds-button> + <h2>Link Dark</h2> + <njwds-button variant="link" mode="dark" style="background-color: black;"> + Button + </njwds-button> + <h2>Danger</h2> + <njwds-button variant="danger"> + Button + </njwds-button> + + <h2>As Child</h2> + <njwds-button as-child> + <button "data-test"="test" > + Button + </button> + </njwds-button> + + <h2>Planned Styling Changes</h2> + <h3>Danger Button Active State</h3> + <njwds-button type="danger"> + Button + </njwds-button> + <h3>Focus Ring</h3> + <njwds-button style="background-color: black; padding: 2rem;"> + Button + </njwds-button> + + <h2>Questions</h2> + <p>Should we have a single "type" property with "secondary-dark" and "link-dark"? Or a "mode" property that can be "light"/"dark", and only change the styling for the "secondary" and "link" button types?</p> + </body> +</html> diff --git a/packages/stencil-library/src/components/button/readme.md b/packages/stencil-library/src/components/button/readme.md deleted file mode 100644 index db30254..0000000 --- a/packages/stencil-library/src/components/button/readme.md +++ /dev/null @@ -1,17 +0,0 @@ -# njwds-button - - - -<!-- Auto Generated Below --> - - -## Properties - -| Property | Attribute | Description | Type | Default | -| --------- | --------- | ----------- | ----------------------------------------------------------------------------------- | ----------- | -| `variant` | `variant` | | `"accent-cool" \| "accent-warm" \| "base" \| "default" \| "outline" \| "secondary"` | `"default"` | - - ----------------------------------------------- - -*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/stencil-library/src/components/my-component/readme.md b/packages/stencil-library/src/components/my-component/readme.md deleted file mode 100644 index 06b5864..0000000 --- a/packages/stencil-library/src/components/my-component/readme.md +++ /dev/null @@ -1,19 +0,0 @@ -# my-component - - - -<!-- Auto Generated Below --> - - -## Properties - -| Property | Attribute | Description | Type | Default | -| -------- | --------- | --------------- | -------- | ----------- | -| `first` | `first` | The first name | `string` | `undefined` | -| `last` | `last` | The last name | `string` | `undefined` | -| `middle` | `middle` | The middle name | `string` | `undefined` | - - ----------------------------------------------- - -*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/stencil-library/src/index.html b/packages/stencil-library/src/index.html index c2b3413..51bfa77 100644 --- a/packages/stencil-library/src/index.html +++ b/packages/stencil-library/src/index.html @@ -21,8 +21,8 @@ <h2>Button</h2> <njwds-button> Default </njwds-button> - <njwds-button variant="secondary"> - Secondary + <njwds-button variant="secondary" as-child> + <button autofocus="true">test</button> </njwds-button> </body> </html> diff --git a/packages/stencil-library/src/interface.d.ts b/packages/stencil-library/src/interface.d.ts new file mode 100644 index 0000000..ad01be2 --- /dev/null +++ b/packages/stencil-library/src/interface.d.ts @@ -0,0 +1 @@ +export type Mode = 'light' | 'dark';