@evo-web/react 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @evo-web/react
2
2
 
3
+ ## 0.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#525](https://github.com/eBay/evo-web/pull/525) [`4b00ffc`](https://github.com/eBay/evo-web/commit/4b00ffcaf64291002926bb0f43526234f6315396) Thanks [@HenriqueLimas](https://github.com/HenriqueLimas)! - feat(evo-react): add EvoButton component
8
+
3
9
  ## Unreleased
4
10
 
5
11
  Initial package setup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evo-web/react",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Collection of core eBay components; considered to be the building blocks for all composite structures, pages & apps.",
5
5
  "keywords": [
6
6
  "React",
@@ -11,13 +11,11 @@
11
11
  ],
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "https://github.com/eBay/evo-web",
14
+ "url": "git+https://github.com/eBay/evo-web.git",
15
15
  "directory": "packages/evo-react"
16
16
  },
17
17
  "license": "MIT",
18
- "sideEffects": [
19
- "./**/*.css"
20
- ],
18
+ "sideEffects": false,
21
19
  "type": "module",
22
20
  "exports": {
23
21
  "./package.json": "./package.json",
@@ -34,37 +32,33 @@
34
32
  "lint": "eslint . && prettier . --check --log-level=warn",
35
33
  "release": "npm run build",
36
34
  "start": "storybook dev -p 9001",
37
- "test": "vitest --browser.headless --passWithNoTests",
35
+ "test": "vitest run --browser.headless --passWithNoTests",
38
36
  "type:check": "tsc --noEmit",
39
37
  "update-icons": "exit 0",
40
38
  "version": "npm run update-icons && git add -A src"
41
39
  },
42
40
  "dependencies": {
43
- "@floating-ui/react": "^0.27.16",
41
+ "@floating-ui/react": "^0.27.17",
44
42
  "classnames": "^2.5.1",
45
- "makeup-active-descendant": "^0.7.10",
46
- "makeup-expander": "^0.11.9",
43
+ "makeup-active-descendant": "^0.7.11",
44
+ "makeup-expander": "^0.11.10",
47
45
  "makeup-floating-label": "^0.4.9",
48
- "makeup-focusables": "^0.4.5",
49
- "makeup-keyboard-trap": "^0.5.7",
50
- "makeup-prevent-scroll-keys": "^0.3.4",
51
- "makeup-roving-tabindex": "^0.7.7",
52
- "makeup-screenreader-trap": "^0.5.6",
53
- "makeup-typeahead": "^0.3.3",
46
+ "makeup-focusables": "^0.4.6",
47
+ "makeup-keyboard-trap": "^0.5.8",
48
+ "makeup-prevent-scroll-keys": "^0.3.5",
49
+ "makeup-roving-tabindex": "^0.7.8",
50
+ "makeup-screenreader-trap": "^0.5.7",
51
+ "makeup-typeahead": "^0.3.5",
54
52
  "react-remove-scroll": "^2.7.2"
55
53
  },
56
54
  "devDependencies": {
57
- "@vitejs/plugin-react": "^5.1.2",
55
+ "@vitejs/plugin-react": "^5.1.3",
58
56
  "@vitest/browser-playwright": "^4.0.18",
59
- "vitest-browser-react": "^2.0.4"
57
+ "vitest-browser-react": "^2.0.5"
60
58
  },
61
59
  "peerDependencies": {
62
60
  "@ebay/skin": "^19",
63
61
  "react": "^19",
64
62
  "react-dom": "^19"
65
- },
66
- "publishConfig": {
67
- "access": "public",
68
- "registry": "https://registry.npmjs.org"
69
63
  }
70
64
  }
@@ -0,0 +1,21 @@
1
+ import type { ComponentProps } from "react";
2
+
3
+ type ButtonType = "cta" | "fake" | "expand" | "default";
4
+ type Props = ComponentProps<"span"> & {
5
+ type?: ButtonType;
6
+ };
7
+
8
+ const classPrefixes: { [key in ButtonType]: string } = {
9
+ cta: "cta-",
10
+ fake: "fake-",
11
+ expand: "expand-",
12
+ default: "",
13
+ };
14
+
15
+ export function EvoButtonCell({ type = "default", children, ...rest }: Props) {
16
+ return (
17
+ <span className={`${classPrefixes[type]}btn__cell`} {...rest}>
18
+ {children}
19
+ </span>
20
+ );
21
+ }
@@ -0,0 +1,113 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { EvoButton } from "./button";
3
+ import { EvoButtonCell } from "./button-cell";
4
+
5
+ const meta: Meta<typeof EvoButton> = {
6
+ title: "Components/EvoButton",
7
+ component: EvoButton,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component: `
13
+ A flexible button component that can render as either a \`<button>\` or \`<a>\` element based on the \`href\` prop.
14
+
15
+ ## Usage
16
+
17
+ \`\`\`tsx
18
+ import { EvoButton } from "@evo-web/react";
19
+ \`\`\`
20
+ `,
21
+ },
22
+ },
23
+ },
24
+ argTypes: {
25
+ priority: {
26
+ control: "select",
27
+ options: ["primary", "secondary", "tertiary", "none"],
28
+ description: "Button priority level",
29
+ },
30
+ variant: {
31
+ control: "select",
32
+ options: ["standard", "destructive", "form"],
33
+ description: "Button variant style",
34
+ },
35
+ size: {
36
+ control: "select",
37
+ options: ["small", "large"],
38
+ description: "Button size",
39
+ },
40
+ bodyState: {
41
+ control: "select",
42
+ options: ["loading", "expand", "reset", "none"],
43
+ description: "Button body state",
44
+ },
45
+ split: {
46
+ control: "select",
47
+ options: ["start", "end"],
48
+ description: "Split button position",
49
+ },
50
+ fluid: {
51
+ control: "boolean",
52
+ description: "Full width button",
53
+ },
54
+ disabled: {
55
+ control: "boolean",
56
+ description: "Disabled state",
57
+ },
58
+ partiallyDisabled: {
59
+ control: "boolean",
60
+ description: "Partially disabled (aria-disabled)",
61
+ },
62
+ transparent: {
63
+ control: "boolean",
64
+ description: "Transparent background",
65
+ },
66
+ borderless: {
67
+ control: "boolean",
68
+ description: "No border",
69
+ },
70
+ fixedHeight: {
71
+ control: "boolean",
72
+ description: "Fixed height",
73
+ },
74
+ truncate: {
75
+ control: "boolean",
76
+ description: "Truncate text with ellipsis",
77
+ },
78
+ href: {
79
+ control: "text",
80
+ description: "Link URL (renders as anchor)",
81
+ },
82
+ children: {
83
+ control: "text",
84
+ description: "Button text content",
85
+ },
86
+ },
87
+ args: {
88
+ priority: "primary",
89
+ variant: "standard",
90
+ children: "Button",
91
+ },
92
+ };
93
+
94
+ export default meta;
95
+
96
+ type Story = StoryObj<typeof EvoButton>;
97
+
98
+ export const Default: Story = {
99
+ args: {
100
+ children: "Button",
101
+ },
102
+ };
103
+
104
+ export const WithButtonCell: Story = {
105
+ render: (args) => (
106
+ <EvoButton {...args}>
107
+ <EvoButtonCell style={{ justifyContent: "space-between" }}>
108
+ <span>Select</span>
109
+ <span>Any</span>
110
+ </EvoButtonCell>
111
+ </EvoButton>
112
+ ),
113
+ };
@@ -0,0 +1,138 @@
1
+ import type { KeyboardEvent } from "react";
2
+ import React from "react";
3
+ import classNames from "classnames";
4
+ import type {
5
+ AnchorButtonProps,
6
+ NativeButtonProps,
7
+ Priority,
8
+ Size,
9
+ Split,
10
+ } from "./types";
11
+ import "@ebay/skin/button";
12
+
13
+ export function EvoButton(props: AnchorButtonProps): React.JSX.Element;
14
+ export function EvoButton(props: NativeButtonProps): React.JSX.Element;
15
+ export function EvoButton(
16
+ props: AnchorButtonProps | NativeButtonProps,
17
+ ): React.JSX.Element {
18
+ const {
19
+ priority = "secondary",
20
+ variant = "standard",
21
+ size,
22
+ bodyState,
23
+ split,
24
+ transparent = false,
25
+ fluid = false,
26
+ disabled,
27
+ partiallyDisabled,
28
+ children,
29
+ onKeyDown,
30
+ onEscape,
31
+ truncate = false,
32
+ href,
33
+ className: extraClasses,
34
+ borderless,
35
+ fixedHeight,
36
+ ...rest
37
+ } = props;
38
+ const classPrefix = href ? "fake-btn" : "btn";
39
+ const priorityStyles: { [key in Priority]: string } = {
40
+ primary: `${classPrefix}--primary`,
41
+ secondary: `${classPrefix}--secondary`,
42
+ tertiary: `${classPrefix}--tertiary`,
43
+ none: "",
44
+ };
45
+ const sizeStyles: { [key in Size]: string } = {
46
+ large: `${classPrefix}--large`,
47
+ small: `${classPrefix}--small`,
48
+ };
49
+ const splitStyles: { [key in Split]: string } = {
50
+ start: `${classPrefix}--split-start`,
51
+ end: `${classPrefix}--split-end`,
52
+ };
53
+ const isDestructive = variant === "destructive";
54
+ const isForm = variant === "form";
55
+ const className = classNames(
56
+ classPrefix,
57
+ extraClasses,
58
+ priorityStyles[isForm || borderless ? "none" : priority],
59
+ size && sizeStyles[size],
60
+ split && splitStyles[split],
61
+ isDestructive && `${classPrefix}--destructive`,
62
+ isForm && `${classPrefix}--form`,
63
+ transparent && `${classPrefix}--transparent`,
64
+ fluid && `${classPrefix}--fluid`,
65
+ truncate && `${classPrefix}--truncated`,
66
+ borderless && `${classPrefix}--borderless`,
67
+ fixedHeight &&
68
+ (size && sizeStyles[size]
69
+ ? `${sizeStyles[size]}-fixed-height`
70
+ : `${classPrefix}--fixed-height`),
71
+ );
72
+
73
+ const bodyContent = (() => {
74
+ switch (bodyState) {
75
+ case "loading":
76
+ return (
77
+ <span className="btn__cell">
78
+ {/* TODO: Replace with <EvoProgressSpinner /> when available */}
79
+ <span>Loading...</span>
80
+ </span>
81
+ );
82
+ case "expand":
83
+ return (
84
+ <span className="btn__cell">
85
+ <span className="btn__text">{children}</span>
86
+ {/* TODO: Replace with <EvoIconChevronDown16 /> when available */}
87
+ <span>▼</span>
88
+ </span>
89
+ );
90
+ default:
91
+ return children;
92
+ }
93
+ })();
94
+
95
+ const ariaLive = bodyState === "loading" ? "polite" : undefined;
96
+
97
+ const keyDownHandler = (
98
+ event: KeyboardEvent<HTMLButtonElement | HTMLAnchorElement>,
99
+ ) => {
100
+ onKeyDown?.(
101
+ event as KeyboardEvent<HTMLButtonElement> &
102
+ KeyboardEvent<HTMLAnchorElement>,
103
+ );
104
+ if (event.key === "Escape" && !disabled && onEscape) {
105
+ onEscape(
106
+ event as KeyboardEvent<HTMLButtonElement> &
107
+ KeyboardEvent<HTMLAnchorElement>,
108
+ );
109
+ }
110
+ };
111
+
112
+ if (href) {
113
+ return (
114
+ <a
115
+ {...(rest as React.ComponentProps<"a">)}
116
+ className={className}
117
+ href={disabled ? undefined : href}
118
+ onKeyDown={keyDownHandler}
119
+ aria-live={ariaLive}
120
+ >
121
+ {bodyContent}
122
+ </a>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <button
128
+ {...(rest as React.ComponentProps<"button">)}
129
+ disabled={disabled}
130
+ aria-disabled={partiallyDisabled ? "true" : undefined}
131
+ aria-live={ariaLive}
132
+ className={className}
133
+ onKeyDown={keyDownHandler}
134
+ >
135
+ {bodyContent}
136
+ </button>
137
+ );
138
+ }
@@ -0,0 +1,10 @@
1
+ export { EvoButton } from "./button";
2
+ export { EvoButtonCell } from "./button-cell";
3
+ export type {
4
+ EvoButtonProps,
5
+ Size,
6
+ Priority,
7
+ Variant,
8
+ BodyState,
9
+ Split,
10
+ } from "./types";
@@ -0,0 +1,83 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`EvoButton SSR > should render button with bodyState=expand 1`] = `"<button class="btn btn--secondary"><span class="btn__cell"><span class="btn__text">Button</span><span>▼</span></span></button>"`;
4
+
5
+ exports[`EvoButton SSR > should render button with bodyState=loading 1`] = `"<button aria-live="polite" class="btn btn--secondary"><span class="btn__cell"><span>Loading...</span></span></button>"`;
6
+
7
+ exports[`EvoButton SSR > should render button with borderless=false 1`] = `"<button class="btn btn--secondary">Button</button>"`;
8
+
9
+ exports[`EvoButton SSR > should render button with borderless=true 1`] = `"<button class="btn btn--borderless">Button</button>"`;
10
+
11
+ exports[`EvoButton SSR > should render button with disabled=false 1`] = `"<button class="btn btn--secondary">Button</button>"`;
12
+
13
+ exports[`EvoButton SSR > should render button with disabled=true 1`] = `"<button disabled="" class="btn btn--secondary">Button</button>"`;
14
+
15
+ exports[`EvoButton SSR > should render button with fixedHeight=false 1`] = `"<button class="btn btn--secondary">Button</button>"`;
16
+
17
+ exports[`EvoButton SSR > should render button with fixedHeight=true 1`] = `"<button class="btn btn--secondary btn--fixed-height">Button</button>"`;
18
+
19
+ exports[`EvoButton SSR > should render button with fluid=false 1`] = `"<button class="btn btn--secondary">Button</button>"`;
20
+
21
+ exports[`EvoButton SSR > should render button with fluid=true 1`] = `"<button class="btn btn--secondary btn--fluid">Button</button>"`;
22
+
23
+ exports[`EvoButton SSR > should render button with partiallyDisabled=false 1`] = `"<button class="btn btn--secondary">Button</button>"`;
24
+
25
+ exports[`EvoButton SSR > should render button with partiallyDisabled=true 1`] = `"<button aria-disabled="true" class="btn btn--secondary">Button</button>"`;
26
+
27
+ exports[`EvoButton SSR > should render button with priority=none 1`] = `"<button class="btn">Button</button>"`;
28
+
29
+ exports[`EvoButton SSR > should render button with priority=primary 1`] = `"<button class="btn btn--primary">Button</button>"`;
30
+
31
+ exports[`EvoButton SSR > should render button with priority=secondary 1`] = `"<button class="btn btn--secondary">Button</button>"`;
32
+
33
+ exports[`EvoButton SSR > should render button with priority=tertiary 1`] = `"<button class="btn btn--tertiary">Button</button>"`;
34
+
35
+ exports[`EvoButton SSR > should render button with size=large 1`] = `"<button class="btn btn--secondary btn--large">Button</button>"`;
36
+
37
+ exports[`EvoButton SSR > should render button with size=small 1`] = `"<button class="btn btn--secondary btn--small">Button</button>"`;
38
+
39
+ exports[`EvoButton SSR > should render button with split=end 1`] = `"<button class="btn btn--primary btn--split-end">Button</button>"`;
40
+
41
+ exports[`EvoButton SSR > should render button with split=start 1`] = `"<button class="btn btn--primary btn--split-start">Button</button>"`;
42
+
43
+ exports[`EvoButton SSR > should render button with transparent=false 1`] = `"<button class="btn btn--secondary">Button</button>"`;
44
+
45
+ exports[`EvoButton SSR > should render button with transparent=true 1`] = `"<button class="btn btn--secondary btn--transparent">Button</button>"`;
46
+
47
+ exports[`EvoButton SSR > should render button with truncate=false 1`] = `"<button class="btn btn--secondary">Button</button>"`;
48
+
49
+ exports[`EvoButton SSR > should render button with truncate=true 1`] = `"<button class="btn btn--secondary btn--truncated">Button</button>"`;
50
+
51
+ exports[`EvoButton SSR > should render button with variant=destructive 1`] = `"<button class="btn btn--secondary btn--destructive">Button</button>"`;
52
+
53
+ exports[`EvoButton SSR > should render button with variant=form 1`] = `"<button class="btn btn--form">Button</button>"`;
54
+
55
+ exports[`EvoButton SSR > should render button with variant=standard 1`] = `"<button class="btn btn--secondary">Button</button>"`;
56
+
57
+ exports[`EvoButton SSR > should render combined: primary large fluid 1`] = `"<button class="btn btn--primary btn--large btn--fluid">Combined</button>"`;
58
+
59
+ exports[`EvoButton SSR > should render defaults 1`] = `"<button class="btn btn--secondary">Default Button</button>"`;
60
+
61
+ exports[`EvoButton SSR > should render destructive primary large 1`] = `"<button class="btn btn--primary btn--large btn--destructive">Delete</button>"`;
62
+
63
+ exports[`EvoButton SSR > should render disabled link without href 1`] = `"<a class="fake-btn fake-btn--secondary">Disabled Link</a>"`;
64
+
65
+ exports[`EvoButton SSR > should render fake version (anchor) 1`] = `"<a aria-label="fake button" class="fake-btn fake-btn--primary fake-btn--large" href="https://ebay.com">Link Button</a>"`;
66
+
67
+ exports[`EvoButton SSR > should render form variant with expand 1`] = `"<button class="btn btn--form"><span class="btn__cell"><span class="btn__text">Form Expand</span><span>▼</span></span></button>"`;
68
+
69
+ exports[`EvoButton SSR > should render large fixed-height button 1`] = `"<button class="btn btn--secondary btn--large btn--large-fixed-height">Large Fixed Height</button>"`;
70
+
71
+ exports[`EvoButton SSR > should render large truncated button 1`] = `"<button class="btn btn--secondary btn--large btn--truncated">Large Truncated</button>"`;
72
+
73
+ exports[`EvoButton SSR > should render with ButtonCell 1`] = `"<button class="btn btn--secondary"><span class="btn__cell" style="justify-content:space-between"><span>Left</span><span>Right</span></span></button>"`;
74
+
75
+ exports[`EvoButton SSR > should render with aria-label 1`] = `"<button aria-label="Submit form" class="btn btn--secondary">Submit</button>"`;
76
+
77
+ exports[`EvoButton SSR > should render with custom className 1`] = `"<button class="btn custom-class btn--secondary">Button</button>"`;
78
+
79
+ exports[`EvoButton SSR > should render with data attributes 1`] = `"<button data-testid="button" class="btn btn--secondary">Button</button>"`;
80
+
81
+ exports[`EvoButton SSR > should render with id override 1`] = `"<button id="test" class="btn btn--secondary">Button</button>"`;
82
+
83
+ exports[`EvoButton SSR > should render with type override 1`] = `"<button type="submit" class="btn btn--secondary">Submit</button>"`;
@@ -0,0 +1,298 @@
1
+ import React from "react";
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
3
+ import { render } from "vitest-browser-react";
4
+ import { userEvent } from "vitest/browser";
5
+ import { EvoButton } from "../button";
6
+ import { EvoButtonCell } from "../button-cell";
7
+
8
+ describe("evo-button", () => {
9
+ let user: ReturnType<typeof userEvent.setup>;
10
+
11
+ beforeEach(() => {
12
+ user = userEvent.setup();
13
+ });
14
+
15
+ afterEach(() => {
16
+ user.cleanup();
17
+ });
18
+
19
+ describe("given button is enabled", () => {
20
+ it("emits click event when clicked", async () => {
21
+ const onClick = vi.fn();
22
+ const screen = await render(
23
+ <EvoButton onClick={onClick}>Click Me</EvoButton>,
24
+ );
25
+
26
+ await user.click(screen.getByRole("button"));
27
+
28
+ expect(onClick).toHaveBeenCalledTimes(1);
29
+ });
30
+
31
+ it("emits escape event when escape key is pressed", async () => {
32
+ const onEscape = vi.fn();
33
+ const screen = await render(
34
+ <EvoButton onEscape={onEscape}>Button</EvoButton>,
35
+ );
36
+
37
+ const button = screen.getByRole("button");
38
+ await user.click(button);
39
+ await user.keyboard("{Escape}");
40
+
41
+ expect(onEscape).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it("emits both onKeyDown and onEscape on escape key", async () => {
45
+ const onKeyDown = vi.fn();
46
+ const onEscape = vi.fn();
47
+ const screen = await render(
48
+ <EvoButton onKeyDown={onKeyDown} onEscape={onEscape}>
49
+ Button
50
+ </EvoButton>,
51
+ );
52
+
53
+ const button = screen.getByRole("button");
54
+ await user.click(button);
55
+ await user.keyboard("{Escape}");
56
+
57
+ expect(onKeyDown).toHaveBeenCalledTimes(1);
58
+ expect(onEscape).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it("emits focus event when focused", async () => {
62
+ const onFocus = vi.fn();
63
+ const screen = await render(
64
+ <EvoButton onFocus={onFocus}>Button</EvoButton>,
65
+ );
66
+
67
+ await user.click(screen.getByRole("button"));
68
+
69
+ expect(onFocus).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it("emits blur event when blurred", async () => {
73
+ const onBlur = vi.fn();
74
+ const screen = await render(
75
+ <EvoButton onBlur={onBlur}>Button</EvoButton>,
76
+ );
77
+
78
+ const button = screen.getByRole("button");
79
+ await user.click(button);
80
+ await user.tab();
81
+
82
+ expect(onBlur).toHaveBeenCalledTimes(1);
83
+ });
84
+ });
85
+
86
+ describe("given button is disabled", () => {
87
+ it("does not emit click event when clicked", async () => {
88
+ const onClick = vi.fn();
89
+ const screen = await render(
90
+ <EvoButton onClick={onClick} disabled>
91
+ Button
92
+ </EvoButton>,
93
+ );
94
+
95
+ const button = screen.getByRole("button");
96
+ await expect.element(button).toBeDisabled();
97
+ expect(onClick).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("does not emit escape event when escape key is pressed", async () => {
101
+ const onEscape = vi.fn();
102
+ const screen = await render(
103
+ <EvoButton onEscape={onEscape} disabled>
104
+ Button
105
+ </EvoButton>,
106
+ );
107
+
108
+ const button = screen.getByRole("button");
109
+ await expect.element(button).toBeDisabled();
110
+ expect(onEscape).not.toHaveBeenCalled();
111
+ });
112
+ });
113
+
114
+ describe("ref forwarding", () => {
115
+ it("forwards ref to button element", async () => {
116
+ const ref = React.createRef<HTMLButtonElement>();
117
+ await render(<EvoButton ref={ref}>Button</EvoButton>);
118
+
119
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
120
+ expect(ref.current?.tagName).toBe("BUTTON");
121
+ });
122
+
123
+ it("forwards ref to anchor element when href is provided", async () => {
124
+ const ref = React.createRef<HTMLAnchorElement>();
125
+ await render(
126
+ <EvoButton href="https://ebay.com" ref={ref}>
127
+ Link
128
+ </EvoButton>,
129
+ );
130
+
131
+ expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
132
+ expect(ref.current?.tagName).toBe("A");
133
+ });
134
+ });
135
+
136
+ describe("anchor element behavior", () => {
137
+ it("renders as link when href is provided", async () => {
138
+ const screen = await render(
139
+ <EvoButton
140
+ href="https://ebay.com"
141
+ priority="primary"
142
+ onClick={(e) => e.preventDefault()}
143
+ >
144
+ Link Button
145
+ </EvoButton>,
146
+ );
147
+
148
+ const link = screen.getByRole("link");
149
+ await expect.element(link).toHaveAttribute("href", "https://ebay.com");
150
+ });
151
+
152
+ it("does not render href when disabled", async () => {
153
+ const screen = await render(
154
+ <EvoButton href="https://ebay.com" disabled>
155
+ Disabled Link
156
+ </EvoButton>,
157
+ );
158
+
159
+ const link = screen.getByText("Disabled Link");
160
+ await expect.element(link).not.toHaveAttribute("href");
161
+ });
162
+
163
+ it("emits escape event on anchor element", async () => {
164
+ const onEscape = vi.fn();
165
+ const screen = await render(
166
+ <EvoButton
167
+ href="https://ebay.com"
168
+ onEscape={onEscape}
169
+ onClick={(e) => e.preventDefault()}
170
+ >
171
+ Link
172
+ </EvoButton>,
173
+ );
174
+
175
+ const link = screen.getByRole("link");
176
+ await user.click(link);
177
+ await user.keyboard("{Escape}");
178
+
179
+ expect(onEscape).toHaveBeenCalledTimes(1);
180
+ });
181
+ });
182
+
183
+ describe("body states", () => {
184
+ it("renders loading state with aria-live", async () => {
185
+ const screen = await render(
186
+ <EvoButton bodyState="loading">Submit</EvoButton>,
187
+ );
188
+
189
+ const button = screen.getByRole("button");
190
+ expect(button).toHaveAttribute("aria-live", "polite");
191
+ });
192
+
193
+ it("renders expand state with correct structure", async () => {
194
+ const screen = await render(
195
+ <EvoButton bodyState="expand">Options</EvoButton>,
196
+ );
197
+
198
+ const text = screen.getByText("Options");
199
+ await expect.element(text).toBeInTheDocument();
200
+ });
201
+
202
+ it("maintains interactivity in loading state", async () => {
203
+ const onClick = vi.fn();
204
+ const screen = await render(
205
+ <EvoButton bodyState="loading" onClick={onClick}>
206
+ Submit
207
+ </EvoButton>,
208
+ );
209
+
210
+ await user.click(screen.getByRole("button"));
211
+
212
+ expect(onClick).toHaveBeenCalledTimes(1);
213
+ });
214
+ });
215
+
216
+ describe("partially disabled state", () => {
217
+ it("renders with aria-disabled but remains clickable", async () => {
218
+ const onClick = vi.fn();
219
+ const screen = await render(
220
+ <EvoButton partiallyDisabled onClick={onClick}>
221
+ Partially Disabled
222
+ </EvoButton>,
223
+ );
224
+
225
+ const button = screen.getByRole("button");
226
+ await expect.element(button).toHaveAttribute("aria-disabled", "true");
227
+
228
+ await button.click({ force: true });
229
+ expect(onClick).toHaveBeenCalledTimes(1);
230
+ });
231
+ });
232
+
233
+ describe("keyboard navigation", () => {
234
+ it("can be focused via keyboard", async () => {
235
+ const onFocus = vi.fn();
236
+ await render(<EvoButton onFocus={onFocus}>Button</EvoButton>);
237
+
238
+ await user.tab();
239
+
240
+ expect(onFocus).toHaveBeenCalledTimes(1);
241
+ });
242
+
243
+ it("emits onKeyDown for all keys", async () => {
244
+ const onKeyDown = vi.fn();
245
+ const screen = await render(
246
+ <EvoButton onKeyDown={onKeyDown}>Button</EvoButton>,
247
+ );
248
+
249
+ const button = screen.getByRole("button");
250
+ await user.click(button);
251
+ await user.keyboard("{Enter}");
252
+
253
+ expect(onKeyDown).toHaveBeenCalledTimes(1);
254
+ });
255
+ });
256
+
257
+ describe("button cell", () => {
258
+ it("renders custom layout with ButtonCell", async () => {
259
+ const onClick = vi.fn();
260
+ const screen = await render(
261
+ <EvoButton onClick={onClick}>
262
+ <EvoButtonCell style={{ justifyContent: "space-between" }}>
263
+ <span>Left</span>
264
+ <span>Right</span>
265
+ </EvoButtonCell>
266
+ </EvoButton>,
267
+ );
268
+
269
+ await expect.element(screen.getByText("Left")).toBeInTheDocument();
270
+ await expect.element(screen.getByText("Right")).toBeInTheDocument();
271
+
272
+ await user.click(screen.getByRole("button"));
273
+ expect(onClick).toHaveBeenCalledTimes(1);
274
+ });
275
+ });
276
+
277
+ describe("accessibility", () => {
278
+ it("has correct role for button", async () => {
279
+ const screen = await render(<EvoButton>Button</EvoButton>);
280
+ await expect.element(screen.getByRole("button")).toBeInTheDocument();
281
+ });
282
+
283
+ it("has correct role for link", async () => {
284
+ const screen = await render(
285
+ <EvoButton href="https://ebay.com">Link</EvoButton>,
286
+ );
287
+ await expect.element(screen.getByRole("link")).toBeInTheDocument();
288
+ });
289
+
290
+ it("supports aria-label", async () => {
291
+ const screen = await render(
292
+ <EvoButton aria-label="Submit form">Submit</EvoButton>,
293
+ );
294
+ const button = screen.getByRole("button", { name: "Submit form" });
295
+ await expect.element(button).toBeInTheDocument();
296
+ });
297
+ });
298
+ });
@@ -0,0 +1,245 @@
1
+ import { it, expect, describe } from "vitest";
2
+ import { renderToString } from "react-dom/server";
3
+ import { EvoButton } from "../button";
4
+ import { EvoButtonCell } from "../button-cell";
5
+ import type { Priority, Size, Variant, Split, BodyState } from "../types";
6
+
7
+ describe("EvoButton SSR", () => {
8
+ it.each<Priority>(["primary", "secondary", "tertiary", "none"])(
9
+ "should render button with priority=%s",
10
+ (priority) => {
11
+ expect(
12
+ renderToString(<EvoButton priority={priority}>Button</EvoButton>),
13
+ ).toMatchSnapshot();
14
+ },
15
+ );
16
+
17
+ it.each<Size>(["large", "small"])(
18
+ "should render button with size=%s",
19
+ (size) => {
20
+ expect(
21
+ renderToString(<EvoButton size={size}>Button</EvoButton>),
22
+ ).toMatchSnapshot();
23
+ },
24
+ );
25
+
26
+ it.each<Variant>(["standard", "destructive", "form"])(
27
+ "should render button with variant=%s",
28
+ (variant) => {
29
+ expect(
30
+ renderToString(<EvoButton variant={variant}>Button</EvoButton>),
31
+ ).toMatchSnapshot();
32
+ },
33
+ );
34
+
35
+ it.each<Split>(["start", "end"])(
36
+ "should render button with split=%s",
37
+ (split) => {
38
+ expect(
39
+ renderToString(
40
+ <EvoButton split={split} priority="primary">
41
+ Button
42
+ </EvoButton>,
43
+ ),
44
+ ).toMatchSnapshot();
45
+ },
46
+ );
47
+
48
+ it.each<BodyState>(["loading", "expand"])(
49
+ "should render button with bodyState=%s",
50
+ (bodyState) => {
51
+ expect(
52
+ renderToString(<EvoButton bodyState={bodyState}>Button</EvoButton>),
53
+ ).toMatchSnapshot();
54
+ },
55
+ );
56
+
57
+ it.each<boolean>([false, true])(
58
+ "should render button with fluid=%s",
59
+ (fluid) => {
60
+ expect(
61
+ renderToString(<EvoButton fluid={fluid}>Button</EvoButton>),
62
+ ).toMatchSnapshot();
63
+ },
64
+ );
65
+
66
+ it.each<boolean>([false, true])(
67
+ "should render button with disabled=%s",
68
+ (disabled) => {
69
+ expect(
70
+ renderToString(<EvoButton disabled={disabled}>Button</EvoButton>),
71
+ ).toMatchSnapshot();
72
+ },
73
+ );
74
+
75
+ it.each<boolean>([false, true])(
76
+ "should render button with partiallyDisabled=%s",
77
+ (partiallyDisabled) => {
78
+ expect(
79
+ renderToString(
80
+ <EvoButton partiallyDisabled={partiallyDisabled}>Button</EvoButton>,
81
+ ),
82
+ ).toMatchSnapshot();
83
+ },
84
+ );
85
+
86
+ it.each<boolean>([false, true])(
87
+ "should render button with transparent=%s",
88
+ (transparent) => {
89
+ expect(
90
+ renderToString(<EvoButton transparent={transparent}>Button</EvoButton>),
91
+ ).toMatchSnapshot();
92
+ },
93
+ );
94
+
95
+ it.each<boolean>([false, true])(
96
+ "should render button with borderless=%s",
97
+ (borderless) => {
98
+ expect(
99
+ renderToString(<EvoButton borderless={borderless}>Button</EvoButton>),
100
+ ).toMatchSnapshot();
101
+ },
102
+ );
103
+
104
+ it.each<boolean>([false, true])(
105
+ "should render button with truncate=%s",
106
+ (truncate) => {
107
+ expect(
108
+ renderToString(<EvoButton truncate={truncate}>Button</EvoButton>),
109
+ ).toMatchSnapshot();
110
+ },
111
+ );
112
+
113
+ it.each<boolean>([false, true])(
114
+ "should render button with fixedHeight=%s",
115
+ (fixedHeight) => {
116
+ expect(
117
+ renderToString(<EvoButton fixedHeight={fixedHeight}>Button</EvoButton>),
118
+ ).toMatchSnapshot();
119
+ },
120
+ );
121
+
122
+ it("should render defaults", () => {
123
+ expect(
124
+ renderToString(<EvoButton>Default Button</EvoButton>),
125
+ ).toMatchSnapshot();
126
+ });
127
+
128
+ it("should render with id override", () => {
129
+ expect(
130
+ renderToString(<EvoButton id="test">Button</EvoButton>),
131
+ ).toMatchSnapshot();
132
+ });
133
+
134
+ it("should render with type override", () => {
135
+ expect(
136
+ renderToString(<EvoButton type="submit">Submit</EvoButton>),
137
+ ).toMatchSnapshot();
138
+ });
139
+
140
+ it("should render fake version (anchor)", () => {
141
+ expect(
142
+ renderToString(
143
+ <EvoButton
144
+ href="https://ebay.com"
145
+ size="large"
146
+ priority="primary"
147
+ aria-label="fake button"
148
+ >
149
+ Link Button
150
+ </EvoButton>,
151
+ ),
152
+ ).toMatchSnapshot();
153
+ });
154
+
155
+ it("should render disabled link without href", () => {
156
+ expect(
157
+ renderToString(
158
+ <EvoButton href="https://ebay.com" disabled>
159
+ Disabled Link
160
+ </EvoButton>,
161
+ ),
162
+ ).toMatchSnapshot();
163
+ });
164
+
165
+ it("should render large truncated button", () => {
166
+ expect(
167
+ renderToString(
168
+ <EvoButton truncate size="large">
169
+ Large Truncated
170
+ </EvoButton>,
171
+ ),
172
+ ).toMatchSnapshot();
173
+ });
174
+
175
+ it("should render large fixed-height button", () => {
176
+ expect(
177
+ renderToString(
178
+ <EvoButton fixedHeight size="large">
179
+ Large Fixed Height
180
+ </EvoButton>,
181
+ ),
182
+ ).toMatchSnapshot();
183
+ });
184
+
185
+ it("should render form variant with expand", () => {
186
+ expect(
187
+ renderToString(
188
+ <EvoButton variant="form" bodyState="expand">
189
+ Form Expand
190
+ </EvoButton>,
191
+ ),
192
+ ).toMatchSnapshot();
193
+ });
194
+
195
+ it("should render destructive primary large", () => {
196
+ expect(
197
+ renderToString(
198
+ <EvoButton variant="destructive" priority="primary" size="large">
199
+ Delete
200
+ </EvoButton>,
201
+ ),
202
+ ).toMatchSnapshot();
203
+ });
204
+
205
+ it("should render with custom className", () => {
206
+ expect(
207
+ renderToString(<EvoButton className="custom-class">Button</EvoButton>),
208
+ ).toMatchSnapshot();
209
+ });
210
+
211
+ it("should render with aria-label", () => {
212
+ expect(
213
+ renderToString(<EvoButton aria-label="Submit form">Submit</EvoButton>),
214
+ ).toMatchSnapshot();
215
+ });
216
+
217
+ it("should render with data attributes", () => {
218
+ expect(
219
+ renderToString(<EvoButton data-testid="button">Button</EvoButton>),
220
+ ).toMatchSnapshot();
221
+ });
222
+
223
+ it("should render with ButtonCell", () => {
224
+ expect(
225
+ renderToString(
226
+ <EvoButton>
227
+ <EvoButtonCell style={{ justifyContent: "space-between" }}>
228
+ <span>Left</span>
229
+ <span>Right</span>
230
+ </EvoButtonCell>
231
+ </EvoButton>,
232
+ ),
233
+ ).toMatchSnapshot();
234
+ });
235
+
236
+ it("should render combined: primary large fluid", () => {
237
+ expect(
238
+ renderToString(
239
+ <EvoButton priority="primary" size="large" fluid>
240
+ Combined
241
+ </EvoButton>,
242
+ ),
243
+ ).toMatchSnapshot();
244
+ });
245
+ });
@@ -0,0 +1,36 @@
1
+ import type { ComponentProps, KeyboardEvent } from "react";
2
+
3
+ export type Priority = "primary" | "secondary" | "tertiary" | "none";
4
+ export type Variant = "standard" | "destructive" | "form";
5
+ export type Size = "small" | "large";
6
+ export type BodyState = "loading" | "expand" | "reset" | "none";
7
+ export type Split = "start" | "end";
8
+
9
+ type BaseButtonProps = {
10
+ fluid?: boolean;
11
+ partiallyDisabled?: boolean;
12
+ truncate?: boolean;
13
+ priority?: Priority;
14
+ variant?: Variant;
15
+ size?: Size;
16
+ bodyState?: BodyState;
17
+ split?: Split;
18
+ transparent?: boolean;
19
+ borderless?: boolean;
20
+ fixedHeight?: boolean;
21
+ };
22
+
23
+ export type AnchorButtonProps = ComponentProps<"a"> &
24
+ BaseButtonProps & {
25
+ href: string;
26
+ onEscape?: (e: KeyboardEvent<HTMLAnchorElement>) => void;
27
+ disabled?: boolean;
28
+ };
29
+
30
+ export type NativeButtonProps = ComponentProps<"button"> &
31
+ BaseButtonProps & {
32
+ href?: never;
33
+ onEscape?: (e: KeyboardEvent<HTMLButtonElement>) => void;
34
+ };
35
+
36
+ export type EvoButtonProps = AnchorButtonProps | NativeButtonProps;
package/src/index.ts CHANGED
@@ -1 +1,9 @@
1
- export const VERSION = "0.0.0";
1
+ export { EvoButton, EvoButtonCell } from "./evo-button";
2
+ export type {
3
+ EvoButtonProps,
4
+ Size,
5
+ Priority,
6
+ Variant,
7
+ BodyState,
8
+ Split,
9
+ } from "./evo-button";
package/test.setup.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import "vitest-browser-react";
1
2
  import "@ebay/skin/dist/tokens/evo-core.css";
2
3
  import "@ebay/skin/dist/tokens/evo-light.css";
3
4
  import "@ebay/skin/dist/tokens/evo-dark.css";
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "extends": "./tsconfig.json",
3
- "exclude": ["./**/__tests__/*", "./**/*.stories.tsx"],
3
+ "exclude": ["./**/*.stories.tsx", "./**/test/*"],
4
4
  "include": ["./src/**/*"]
5
5
  }
package/vite.config.js CHANGED
@@ -18,10 +18,12 @@ export default defineConfig({
18
18
  lib: {
19
19
  entry: "./src/index.ts",
20
20
  formats: ["es"],
21
- fileName: "index",
22
21
  },
23
22
  rollupOptions: {
24
23
  output: {
24
+ preserveModules: true,
25
+ preserveModulesRoot: "src",
26
+ entryFileNames: "[name].js",
25
27
  banner: `"use client";\n`,
26
28
  },
27
29
  plugins: [
@@ -59,6 +61,7 @@ export default defineConfig({
59
61
  extends: true,
60
62
  test: {
61
63
  name: "browser",
64
+ setupFiles: ["./test.setup.ts"],
62
65
  browser: {
63
66
  enabled: true,
64
67
  provider: playwright(),