@handled-ai/design-system 0.9.28 → 0.11.0
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/dist/components/account-contacts-popover.d.ts +22 -0
- package/dist/components/account-contacts-popover.js +180 -0
- package/dist/components/account-contacts-popover.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/compliance-badge.d.ts +10 -0
- package/dist/components/compliance-badge.js +95 -0
- package/dist/components/compliance-badge.js.map +1 -0
- package/dist/components/contact-chip.d.ts +12 -0
- package/dist/components/contact-chip.js +98 -0
- package/dist/components/contact-chip.js.map +1 -0
- package/dist/components/draft-feedback-inline.d.ts +11 -0
- package/dist/components/draft-feedback-inline.js +153 -0
- package/dist/components/draft-feedback-inline.js.map +1 -0
- package/dist/components/empty-state.d.ts +11 -0
- package/dist/components/empty-state.js +46 -0
- package/dist/components/empty-state.js.map +1 -0
- package/dist/components/filter-chip.d.ts +9 -0
- package/dist/components/filter-chip.js +67 -0
- package/dist/components/filter-chip.js.map +1 -0
- package/dist/components/inline-banner.d.ts +10 -0
- package/dist/components/inline-banner.js +97 -0
- package/dist/components/inline-banner.js.map +1 -0
- package/dist/components/kbd-hint.d.ts +5 -0
- package/dist/components/kbd-hint.js +51 -0
- package/dist/components/kbd-hint.js.map +1 -0
- package/dist/components/rich-text-toolbar.d.ts +9 -0
- package/dist/components/rich-text-toolbar.js +103 -0
- package/dist/components/rich-text-toolbar.js.map +1 -0
- package/dist/components/step-timeline.d.ts +19 -0
- package/dist/components/step-timeline.js +134 -0
- package/dist/components/step-timeline.js.map +1 -0
- package/dist/components/sticky-action-bar.d.ts +10 -0
- package/dist/components/sticky-action-bar.js +56 -0
- package/dist/components/sticky-action-bar.js.map +1 -0
- package/dist/components/suggested-actions.js +2 -304
- package/dist/components/suggested-actions.js.map +1 -1
- package/dist/components/switch.d.ts +6 -0
- package/dist/components/switch.js +66 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/variable-autocomplete.d.ts +21 -0
- package/dist/components/variable-autocomplete.js +171 -0
- package/dist/components/variable-autocomplete.js.map +1 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/components/__tests__/compliance-badge.test.tsx +88 -0
- package/src/components/__tests__/contact-chip.test.tsx +88 -0
- package/src/components/__tests__/empty-state.test.tsx +76 -0
- package/src/components/__tests__/filter-chip.test.tsx +73 -0
- package/src/components/__tests__/inline-banner.test.tsx +110 -0
- package/src/components/__tests__/kbd-hint.test.tsx +29 -0
- package/src/components/__tests__/rich-text-toolbar.test.tsx +92 -0
- package/src/components/__tests__/step-timeline.test.tsx +174 -0
- package/src/components/__tests__/sticky-action-bar.test.tsx +52 -0
- package/src/components/__tests__/switch.test.tsx +39 -0
- package/src/components/__tests__/variable-autocomplete.test.tsx +155 -0
- package/src/components/account-contacts-popover.tsx +192 -0
- package/src/components/compliance-badge.tsx +68 -0
- package/src/components/contact-chip.tsx +68 -0
- package/src/components/draft-feedback-inline.tsx +193 -0
- package/src/components/empty-state.tsx +37 -0
- package/src/components/filter-chip.tsx +37 -0
- package/src/components/inline-banner.tsx +69 -0
- package/src/components/kbd-hint.tsx +21 -0
- package/src/components/rich-text-toolbar.tsx +90 -0
- package/src/components/step-timeline.tsx +149 -0
- package/src/components/sticky-action-bar.tsx +36 -0
- package/src/components/suggested-actions.tsx +2 -363
- package/src/components/switch.tsx +29 -0
- package/src/components/variable-autocomplete.tsx +178 -0
- package/src/index.ts +16 -1
- package/src/styles/globals.css +60 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
4
|
+
import { ContactChip } from "../contact-chip";
|
|
5
|
+
|
|
6
|
+
describe("ContactChip", () => {
|
|
7
|
+
it("renders with data-slot='contact-chip'", () => {
|
|
8
|
+
const { container } = render(<ContactChip name="Alice" />);
|
|
9
|
+
const el = container.querySelector('[data-slot="contact-chip"]');
|
|
10
|
+
expect(el).not.toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders name text", () => {
|
|
14
|
+
render(<ContactChip name="Alice" />);
|
|
15
|
+
expect(screen.getByText("Alice")).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders email when provided", () => {
|
|
19
|
+
render(<ContactChip name="Alice" email="alice@example.com" />);
|
|
20
|
+
expect(screen.getByText("alice@example.com")).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not render email when not provided", () => {
|
|
24
|
+
const { container } = render(<ContactChip name="Alice" />);
|
|
25
|
+
const spans = container.querySelectorAll("span.text-muted-foreground");
|
|
26
|
+
expect(spans.length).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("verified state (default) has data-verified='true'", () => {
|
|
30
|
+
const { container } = render(<ContactChip name="Alice" />);
|
|
31
|
+
const el = container.querySelector('[data-slot="contact-chip"]')!;
|
|
32
|
+
expect(el.getAttribute("data-verified")).toBe("true");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("unverified state has data-verified='false'", () => {
|
|
36
|
+
const { container } = render(<ContactChip name="Alice" verified={false} />);
|
|
37
|
+
const el = container.querySelector('[data-slot="contact-chip"]')!;
|
|
38
|
+
expect(el.getAttribute("data-verified")).toBe("false");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("shows Confirm button when verified=false and onConfirm provided", () => {
|
|
42
|
+
render(<ContactChip name="Alice" verified={false} onConfirm={() => {}} />);
|
|
43
|
+
expect(screen.getByText("Confirm")).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not show Confirm button when verified=true", () => {
|
|
47
|
+
const { container } = render(<ContactChip name="Alice" verified={true} onConfirm={() => {}} />);
|
|
48
|
+
const confirmBtn = container.querySelector('[data-slot="contact-chip-confirm"]');
|
|
49
|
+
expect(confirmBtn).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("Confirm button fires onConfirm on click", () => {
|
|
53
|
+
const handler = vi.fn();
|
|
54
|
+
render(<ContactChip name="Alice" verified={false} onConfirm={handler} />);
|
|
55
|
+
fireEvent.click(screen.getByText("Confirm"));
|
|
56
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("Confirm button has aria-label='Confirm Alice' and type='button'", () => {
|
|
60
|
+
const { container } = render(
|
|
61
|
+
<ContactChip name="Alice" verified={false} onConfirm={() => {}} />
|
|
62
|
+
);
|
|
63
|
+
const btn = container.querySelector('[data-slot="contact-chip-confirm"]') as HTMLButtonElement;
|
|
64
|
+
expect(btn.getAttribute("aria-label")).toBe("Confirm Alice");
|
|
65
|
+
expect(btn.type).toBe("button");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("shows remove button when onRemove provided", () => {
|
|
69
|
+
const { container } = render(<ContactChip name="Alice" onRemove={() => {}} />);
|
|
70
|
+
const removeBtn = container.querySelector('[data-slot="contact-chip-remove"]');
|
|
71
|
+
expect(removeBtn).not.toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("remove button fires onRemove on click", () => {
|
|
75
|
+
const handler = vi.fn();
|
|
76
|
+
const { container } = render(<ContactChip name="Alice" onRemove={handler} />);
|
|
77
|
+
const removeBtn = container.querySelector('[data-slot="contact-chip-remove"]')!;
|
|
78
|
+
fireEvent.click(removeBtn);
|
|
79
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("remove button has aria-label='Remove Alice' and type='button'", () => {
|
|
83
|
+
const { container } = render(<ContactChip name="Alice" onRemove={() => {}} />);
|
|
84
|
+
const btn = container.querySelector('[data-slot="contact-chip-remove"]') as HTMLButtonElement;
|
|
85
|
+
expect(btn.getAttribute("aria-label")).toBe("Remove Alice");
|
|
86
|
+
expect(btn.type).toBe("button");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import { EmptyState } from "../empty-state";
|
|
5
|
+
|
|
6
|
+
describe("EmptyState", () => {
|
|
7
|
+
it("renders with data-slot='empty-state'", () => {
|
|
8
|
+
const { container } = render(<EmptyState description="No items" />);
|
|
9
|
+
const el = container.querySelector('[data-slot="empty-state"]');
|
|
10
|
+
expect(el).not.toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders description text", () => {
|
|
14
|
+
render(<EmptyState description="Nothing to show here" />);
|
|
15
|
+
expect(screen.getByText("Nothing to show here")).not.toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders icon when provided", () => {
|
|
19
|
+
const { container } = render(
|
|
20
|
+
<EmptyState
|
|
21
|
+
description="No items"
|
|
22
|
+
icon={<span data-testid="test-icon">★</span>}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
const iconWrapper = container.querySelector('[data-slot="empty-state-icon"]');
|
|
26
|
+
expect(iconWrapper).not.toBeNull();
|
|
27
|
+
expect(screen.getByTestId("test-icon")).not.toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders title when provided", () => {
|
|
31
|
+
const { container } = render(
|
|
32
|
+
<EmptyState description="No items" title="Empty" />
|
|
33
|
+
);
|
|
34
|
+
const titleEl = container.querySelector('[data-slot="empty-state-title"]');
|
|
35
|
+
expect(titleEl).not.toBeNull();
|
|
36
|
+
expect(screen.getByText("Empty")).not.toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders action when provided", () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<EmptyState
|
|
42
|
+
description="No items"
|
|
43
|
+
action={<button type="button">Add item</button>}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
const actionWrapper = container.querySelector('[data-slot="empty-state-action"]');
|
|
47
|
+
expect(actionWrapper).not.toBeNull();
|
|
48
|
+
expect(screen.getByText("Add item")).not.toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("omits icon wrapper when icon not provided", () => {
|
|
52
|
+
const { container } = render(<EmptyState description="No items" />);
|
|
53
|
+
const iconWrapper = container.querySelector('[data-slot="empty-state-icon"]');
|
|
54
|
+
expect(iconWrapper).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("omits title when not provided", () => {
|
|
58
|
+
const { container } = render(<EmptyState description="No items" />);
|
|
59
|
+
const titleEl = container.querySelector('[data-slot="empty-state-title"]');
|
|
60
|
+
expect(titleEl).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("omits action wrapper when action not provided", () => {
|
|
64
|
+
const { container } = render(<EmptyState description="No items" />);
|
|
65
|
+
const actionWrapper = container.querySelector('[data-slot="empty-state-action"]');
|
|
66
|
+
expect(actionWrapper).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("merges custom className", () => {
|
|
70
|
+
const { container } = render(
|
|
71
|
+
<EmptyState description="No items" className="my-custom-class" />
|
|
72
|
+
);
|
|
73
|
+
const el = container.querySelector('[data-slot="empty-state"]')!;
|
|
74
|
+
expect(el.classList.contains("my-custom-class")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
4
|
+
import { FilterChip } from "../filter-chip";
|
|
5
|
+
|
|
6
|
+
describe("FilterChip", () => {
|
|
7
|
+
it("renders with data-slot='filter-chip'", () => {
|
|
8
|
+
const { container } = render(<FilterChip>Status</FilterChip>);
|
|
9
|
+
const el = container.querySelector('[data-slot="filter-chip"]');
|
|
10
|
+
expect(el).not.toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders children as label text", () => {
|
|
14
|
+
render(<FilterChip>Status</FilterChip>);
|
|
15
|
+
expect(screen.getByText("Status")).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders default Filter icon (svg present)", () => {
|
|
19
|
+
const { container } = render(<FilterChip>Status</FilterChip>);
|
|
20
|
+
const svgs = container.querySelectorAll("svg");
|
|
21
|
+
expect(svgs.length).toBeGreaterThanOrEqual(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders ChevronDown icon", () => {
|
|
25
|
+
const { container } = render(<FilterChip>Status</FilterChip>);
|
|
26
|
+
// Filter + ChevronDown = at least 2 svgs
|
|
27
|
+
const svgs = container.querySelectorAll("svg");
|
|
28
|
+
expect(svgs.length).toBe(2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("fires onClick when clicked", () => {
|
|
32
|
+
const handler = vi.fn();
|
|
33
|
+
render(<FilterChip onClick={handler}>Status</FilterChip>);
|
|
34
|
+
fireEvent.click(screen.getByText("Status"));
|
|
35
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("has type='button'", () => {
|
|
39
|
+
const { container } = render(<FilterChip>Status</FilterChip>);
|
|
40
|
+
const btn = container.querySelector('[data-slot="filter-chip"]') as HTMLButtonElement;
|
|
41
|
+
expect(btn.type).toBe("button");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("applies data-active attribute when active=true", () => {
|
|
45
|
+
const { container } = render(<FilterChip active>Status</FilterChip>);
|
|
46
|
+
const el = container.querySelector('[data-slot="filter-chip"]')!;
|
|
47
|
+
expect(el.getAttribute("data-active")).toBe("");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not have data-active when active is false/undefined", () => {
|
|
51
|
+
const { container } = render(<FilterChip>Status</FilterChip>);
|
|
52
|
+
const el = container.querySelector('[data-slot="filter-chip"]')!;
|
|
53
|
+
expect(el.hasAttribute("data-active")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("merges custom className", () => {
|
|
57
|
+
const { container } = render(<FilterChip className="my-custom">Status</FilterChip>);
|
|
58
|
+
const el = container.querySelector('[data-slot="filter-chip"]')!;
|
|
59
|
+
expect(el.classList.contains("my-custom")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("sets aria-pressed when active", () => {
|
|
63
|
+
const { container } = render(<FilterChip active>Status</FilterChip>);
|
|
64
|
+
const el = container.querySelector('[data-slot="filter-chip"]')!;
|
|
65
|
+
expect(el.getAttribute("aria-pressed")).toBe("true");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("sets aria-pressed=false when not active", () => {
|
|
69
|
+
const { container } = render(<FilterChip active={false}>Status</FilterChip>);
|
|
70
|
+
const el = container.querySelector('[data-slot="filter-chip"]')!;
|
|
71
|
+
expect(el.getAttribute("aria-pressed")).toBe("false");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
4
|
+
import { InlineBanner } from "../inline-banner";
|
|
5
|
+
|
|
6
|
+
describe("InlineBanner", () => {
|
|
7
|
+
it("renders with data-slot='inline-banner'", () => {
|
|
8
|
+
const { container } = render(<InlineBanner>Test</InlineBanner>);
|
|
9
|
+
const el = container.querySelector('[data-slot="inline-banner"]');
|
|
10
|
+
expect(el).not.toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("defaults to variant='warning'", () => {
|
|
14
|
+
const { container } = render(<InlineBanner>Test</InlineBanner>);
|
|
15
|
+
const el = container.querySelector('[data-slot="inline-banner"]')!;
|
|
16
|
+
expect(el.getAttribute("data-variant")).toBe("warning");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders children content", () => {
|
|
20
|
+
render(<InlineBanner>Important message</InlineBanner>);
|
|
21
|
+
expect(screen.getByText("Important message")).not.toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("shows dismiss button when onDismiss provided", () => {
|
|
25
|
+
const { container } = render(
|
|
26
|
+
<InlineBanner onDismiss={() => {}}>Test</InlineBanner>
|
|
27
|
+
);
|
|
28
|
+
const dismissBtn = container.querySelector('[data-slot="inline-banner-dismiss"]');
|
|
29
|
+
expect(dismissBtn).not.toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("dismiss button fires onDismiss on click", () => {
|
|
33
|
+
const onDismiss = vi.fn();
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<InlineBanner onDismiss={onDismiss}>Test</InlineBanner>
|
|
36
|
+
);
|
|
37
|
+
const dismissBtn = container.querySelector('[data-slot="inline-banner-dismiss"]')!;
|
|
38
|
+
fireEvent.click(dismissBtn);
|
|
39
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("dismiss button has aria-label='Dismiss'", () => {
|
|
43
|
+
const { container } = render(
|
|
44
|
+
<InlineBanner onDismiss={() => {}}>Test</InlineBanner>
|
|
45
|
+
);
|
|
46
|
+
const dismissBtn = container.querySelector('[data-slot="inline-banner-dismiss"]')!;
|
|
47
|
+
expect(dismissBtn.getAttribute("aria-label")).toBe("Dismiss");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("dismiss button has type='button'", () => {
|
|
51
|
+
const { container } = render(
|
|
52
|
+
<InlineBanner onDismiss={() => {}}>Test</InlineBanner>
|
|
53
|
+
);
|
|
54
|
+
const dismissBtn = container.querySelector('[data-slot="inline-banner-dismiss"]')!;
|
|
55
|
+
expect(dismissBtn.getAttribute("type")).toBe("button");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("hides dismiss button when onDismiss not provided", () => {
|
|
59
|
+
const { container } = render(<InlineBanner>Test</InlineBanner>);
|
|
60
|
+
const dismissBtn = container.querySelector('[data-slot="inline-banner-dismiss"]');
|
|
61
|
+
expect(dismissBtn).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("sets data-variant attribute correctly", () => {
|
|
65
|
+
const variants = ["info", "warning", "destructive"] as const;
|
|
66
|
+
for (const variant of variants) {
|
|
67
|
+
const { container, unmount } = render(
|
|
68
|
+
<InlineBanner variant={variant}>Test</InlineBanner>
|
|
69
|
+
);
|
|
70
|
+
const el = container.querySelector('[data-slot="inline-banner"]')!;
|
|
71
|
+
expect(el.getAttribute("data-variant")).toBe(variant);
|
|
72
|
+
unmount();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("merges custom className", () => {
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<InlineBanner className="my-custom-class">Test</InlineBanner>
|
|
79
|
+
);
|
|
80
|
+
const el = container.querySelector('[data-slot="inline-banner"]')!;
|
|
81
|
+
expect(el.classList.contains("my-custom-class")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("has role='alert' for warning variant", () => {
|
|
85
|
+
const { container } = render(<InlineBanner variant="warning">Test</InlineBanner>);
|
|
86
|
+
const el = container.querySelector('[data-slot="inline-banner"]')!;
|
|
87
|
+
expect(el.getAttribute("role")).toBe("alert");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("has role='alert' for destructive variant", () => {
|
|
91
|
+
const { container } = render(<InlineBanner variant="destructive">Test</InlineBanner>);
|
|
92
|
+
const el = container.querySelector('[data-slot="inline-banner"]')!;
|
|
93
|
+
expect(el.getAttribute("role")).toBe("alert");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("has role='status' for info variant", () => {
|
|
97
|
+
const { container } = render(<InlineBanner variant="info">Test</InlineBanner>);
|
|
98
|
+
const el = container.querySelector('[data-slot="inline-banner"]')!;
|
|
99
|
+
expect(el.getAttribute("role")).toBe("status");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders custom icon when provided", () => {
|
|
103
|
+
const { container } = render(
|
|
104
|
+
<InlineBanner icon={<span data-testid="custom-icon">!</span>}>Test</InlineBanner>
|
|
105
|
+
);
|
|
106
|
+
expect(screen.getByTestId("custom-icon")).not.toBeNull();
|
|
107
|
+
const iconSlot = container.querySelector('[data-slot="inline-banner-icon"]');
|
|
108
|
+
expect(iconSlot).not.toBeNull();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import { KbdHint } from "../kbd-hint";
|
|
5
|
+
|
|
6
|
+
describe("KbdHint", () => {
|
|
7
|
+
it("renders with data-slot='kbd-hint'", () => {
|
|
8
|
+
const { container } = render(<KbdHint>⌘K</KbdHint>);
|
|
9
|
+
const el = container.querySelector('[data-slot="kbd-hint"]');
|
|
10
|
+
expect(el).not.toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders children text", () => {
|
|
14
|
+
render(<KbdHint>⌘K</KbdHint>);
|
|
15
|
+
expect(screen.getByText("⌘K")).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("merges custom className", () => {
|
|
19
|
+
const { container } = render(<KbdHint className="my-custom-class">⌘K</KbdHint>);
|
|
20
|
+
const el = container.querySelector('[data-slot="kbd-hint"]')!;
|
|
21
|
+
expect(el.classList.contains("my-custom-class")).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders as <kbd> element", () => {
|
|
25
|
+
const { container } = render(<KbdHint>⌘K</KbdHint>);
|
|
26
|
+
const el = container.querySelector('[data-slot="kbd-hint"]')!;
|
|
27
|
+
expect(el.tagName).toBe("KBD");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
4
|
+
import { RichTextToolbar } from "../rich-text-toolbar";
|
|
5
|
+
|
|
6
|
+
describe("RichTextToolbar", () => {
|
|
7
|
+
it("renders with data-slot='rich-text-toolbar'", () => {
|
|
8
|
+
const { container } = render(<RichTextToolbar />);
|
|
9
|
+
const el = container.querySelector('[data-slot="rich-text-toolbar"]');
|
|
10
|
+
expect(el).not.toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("has role='toolbar' and aria-label", () => {
|
|
14
|
+
const { container } = render(<RichTextToolbar />);
|
|
15
|
+
const el = container.querySelector('[data-slot="rich-text-toolbar"]')!;
|
|
16
|
+
expect(el.getAttribute("role")).toBe("toolbar");
|
|
17
|
+
expect(el.getAttribute("aria-label")).toBe("Rich text formatting");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("font button has aria-haspopup", () => {
|
|
21
|
+
render(<RichTextToolbar />);
|
|
22
|
+
const fontBtn = screen.getByLabelText("Font family");
|
|
23
|
+
expect(fontBtn.getAttribute("aria-haspopup")).toBe("true");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders all toolbar buttons with data-slot='rich-text-toolbar-button'", () => {
|
|
27
|
+
const { container } = render(<RichTextToolbar />);
|
|
28
|
+
const buttons = container.querySelectorAll('[data-slot="rich-text-toolbar-button"]');
|
|
29
|
+
// undo, redo, font, bold, italic, underline, align, list, delete = 9
|
|
30
|
+
expect(buttons.length).toBe(9);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("fires onAction with 'bold' when Bold button clicked", () => {
|
|
34
|
+
const handler = vi.fn();
|
|
35
|
+
render(<RichTextToolbar onAction={handler} />);
|
|
36
|
+
fireEvent.click(screen.getByLabelText("Bold"));
|
|
37
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
38
|
+
expect(handler).toHaveBeenCalledWith("bold");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("fires onAction with 'undo' when Undo button clicked", () => {
|
|
42
|
+
const handler = vi.fn();
|
|
43
|
+
render(<RichTextToolbar onAction={handler} />);
|
|
44
|
+
fireEvent.click(screen.getByLabelText("Undo"));
|
|
45
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(handler).toHaveBeenCalledWith("undo");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("fires onAction with 'font' when font button clicked", () => {
|
|
50
|
+
const handler = vi.fn();
|
|
51
|
+
render(<RichTextToolbar onAction={handler} />);
|
|
52
|
+
fireEvent.click(screen.getByLabelText("Font family"));
|
|
53
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(handler).toHaveBeenCalledWith("font");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("fires onAction with 'delete' when Delete button clicked", () => {
|
|
58
|
+
const handler = vi.fn();
|
|
59
|
+
render(<RichTextToolbar onAction={handler} />);
|
|
60
|
+
fireEvent.click(screen.getByLabelText("Delete"));
|
|
61
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(handler).toHaveBeenCalledWith("delete");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("all buttons have type='button'", () => {
|
|
66
|
+
const { container } = render(<RichTextToolbar />);
|
|
67
|
+
const buttons = container.querySelectorAll('[data-slot="rich-text-toolbar-button"]');
|
|
68
|
+
buttons.forEach((btn) => {
|
|
69
|
+
expect((btn as HTMLButtonElement).type).toBe("button");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("all buttons have aria-label", () => {
|
|
74
|
+
const { container } = render(<RichTextToolbar />);
|
|
75
|
+
const buttons = container.querySelectorAll('[data-slot="rich-text-toolbar-button"]');
|
|
76
|
+
buttons.forEach((btn) => {
|
|
77
|
+
expect(btn.getAttribute("aria-label")).not.toBeNull();
|
|
78
|
+
expect(btn.getAttribute("aria-label")).not.toBe("");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renders 'Sans Serif' font picker text", () => {
|
|
83
|
+
render(<RichTextToolbar />);
|
|
84
|
+
expect(screen.getByText("Sans Serif")).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("delete button has hover:text-destructive class", () => {
|
|
88
|
+
render(<RichTextToolbar />);
|
|
89
|
+
const deleteBtn = screen.getByLabelText("Delete");
|
|
90
|
+
expect(deleteBtn.className).toContain("hover:text-destructive");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
4
|
+
import { StepTimeline, type TimelineStep } from "../step-timeline";
|
|
5
|
+
|
|
6
|
+
const sampleSteps: TimelineStep[] = [
|
|
7
|
+
{ id: "1", type: "email", label: "Send intro email" },
|
|
8
|
+
{ id: "2", type: "call", label: "Follow-up call" },
|
|
9
|
+
{ id: "3", type: "task", label: "Update CRM" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe("StepTimeline", () => {
|
|
13
|
+
it("renders with data-slot='step-timeline'", () => {
|
|
14
|
+
const { container } = render(<StepTimeline steps={sampleSteps} />);
|
|
15
|
+
const el = container.querySelector('[data-slot="step-timeline"]');
|
|
16
|
+
expect(el).not.toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders step cards with data-slot='step-timeline-step'", () => {
|
|
20
|
+
const { container } = render(<StepTimeline steps={sampleSteps} />);
|
|
21
|
+
const steps = container.querySelectorAll('[data-slot="step-timeline-step"]');
|
|
22
|
+
expect(steps.length).toBe(3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("renders step type label (uppercase type text)", () => {
|
|
26
|
+
render(<StepTimeline steps={sampleSteps} />);
|
|
27
|
+
expect(screen.getByText("email")).toBeDefined();
|
|
28
|
+
expect(screen.getByText("call")).toBeDefined();
|
|
29
|
+
expect(screen.getByText("task")).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders step label text", () => {
|
|
33
|
+
render(<StepTimeline steps={sampleSteps} />);
|
|
34
|
+
expect(screen.getByText("Send intro email")).toBeDefined();
|
|
35
|
+
expect(screen.getByText("Follow-up call")).toBeDefined();
|
|
36
|
+
expect(screen.getByText("Update CRM")).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders interactive header as button when onStepClick provided", () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<StepTimeline steps={[sampleSteps[0]]} onStepClick={() => {}} />
|
|
42
|
+
);
|
|
43
|
+
const card = container.querySelector('[data-slot="step-timeline-card"]');
|
|
44
|
+
const button = card!.querySelector("button");
|
|
45
|
+
expect(button).not.toBeNull();
|
|
46
|
+
expect(button!.type).toBe("button");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders non-interactive header as div when no onStepClick or renderStepBody", () => {
|
|
50
|
+
const { container } = render(<StepTimeline steps={[sampleSteps[0]]} />);
|
|
51
|
+
const card = container.querySelector('[data-slot="step-timeline-card"]');
|
|
52
|
+
const button = card!.querySelector("button");
|
|
53
|
+
expect(button).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("fires onStepClick with step id when header clicked", () => {
|
|
57
|
+
const handler = vi.fn();
|
|
58
|
+
render(<StepTimeline steps={sampleSteps} onStepClick={handler} />);
|
|
59
|
+
fireEvent.click(screen.getByText("Send intro email"));
|
|
60
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(handler).toHaveBeenCalledWith("1");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders expanded body via renderStepBody when expandedStepId matches", () => {
|
|
65
|
+
render(
|
|
66
|
+
<StepTimeline
|
|
67
|
+
steps={sampleSteps}
|
|
68
|
+
expandedStepId="1"
|
|
69
|
+
renderStepBody={(step) => <div>Body for {step.label}</div>}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
expect(screen.getByText("Body for Send intro email")).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not render body when step is not expanded", () => {
|
|
76
|
+
const { container } = render(
|
|
77
|
+
<StepTimeline
|
|
78
|
+
steps={sampleSteps}
|
|
79
|
+
expandedStepId="1"
|
|
80
|
+
renderStepBody={(step) => <div data-testid={`body-${step.id}`}>Body for {step.label}</div>}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
// Step 1 body should exist
|
|
84
|
+
const body1 = container.querySelector('[data-testid="body-1"]');
|
|
85
|
+
expect(body1).not.toBeNull();
|
|
86
|
+
// Step 2 body should NOT exist
|
|
87
|
+
const body2 = container.querySelector('[data-testid="body-2"]');
|
|
88
|
+
expect(body2).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders accessory via renderStepAccessory", () => {
|
|
92
|
+
render(
|
|
93
|
+
<StepTimeline
|
|
94
|
+
steps={[sampleSteps[0]]}
|
|
95
|
+
onStepClick={() => {}}
|
|
96
|
+
renderStepAccessory={(step) => <span>Accessory-{step.id}</span>}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
expect(screen.getByText("Accessory-1")).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("shows 'Add Step' insert buttons when onInsert provided", () => {
|
|
103
|
+
render(<StepTimeline steps={sampleSteps} onInsert={() => {}} />);
|
|
104
|
+
const insertButtons = screen.getAllByText("+ Add Step");
|
|
105
|
+
// Between steps: (3-1)=2 inter-step buttons + 1 trailing = 3 total
|
|
106
|
+
expect(insertButtons.length).toBe(3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("hides insert buttons when onInsert not provided", () => {
|
|
110
|
+
const { container } = render(<StepTimeline steps={sampleSteps} />);
|
|
111
|
+
const insertButtons = container.querySelectorAll('[data-slot="step-timeline-insert"]');
|
|
112
|
+
expect(insertButtons.length).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("fires onInsert with correct index when insert button clicked", () => {
|
|
116
|
+
const handler = vi.fn();
|
|
117
|
+
render(<StepTimeline steps={sampleSteps} onInsert={handler} />);
|
|
118
|
+
const insertButtons = screen.getAllByText("+ Add Step");
|
|
119
|
+
// First insert button is between step 0 and step 1, index=1
|
|
120
|
+
fireEvent.click(insertButtons[0]);
|
|
121
|
+
expect(handler).toHaveBeenCalledWith(1);
|
|
122
|
+
// Second insert button is between step 1 and step 2, index=2
|
|
123
|
+
fireEvent.click(insertButtons[1]);
|
|
124
|
+
expect(handler).toHaveBeenCalledWith(2);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("shows trailing insert button (after last step) when onInsert provided", () => {
|
|
128
|
+
const handler = vi.fn();
|
|
129
|
+
render(<StepTimeline steps={sampleSteps} onInsert={handler} />);
|
|
130
|
+
const insertButtons = screen.getAllByText("+ Add Step");
|
|
131
|
+
// Last insert button should fire with steps.length (3)
|
|
132
|
+
fireEvent.click(insertButtons[insertButtons.length - 1]);
|
|
133
|
+
expect(handler).toHaveBeenCalledWith(3);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("sets aria-expanded on the trigger button", () => {
|
|
137
|
+
const { container } = render(
|
|
138
|
+
<StepTimeline
|
|
139
|
+
steps={sampleSteps}
|
|
140
|
+
expandedStepId="1"
|
|
141
|
+
onStepClick={() => {}}
|
|
142
|
+
renderStepBody={(step) => <div>Body for {step.label}</div>}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
const cards = container.querySelectorAll('[data-slot="step-timeline-card"]');
|
|
146
|
+
// First step is expanded
|
|
147
|
+
const expandedBtn = cards[0].querySelector("button[aria-expanded]");
|
|
148
|
+
expect(expandedBtn).not.toBeNull();
|
|
149
|
+
expect(expandedBtn!.getAttribute("aria-expanded")).toBe("true");
|
|
150
|
+
// Second step is not expanded
|
|
151
|
+
const collapsedBtn = cards[1].querySelector("button[aria-expanded]");
|
|
152
|
+
expect(collapsedBtn).not.toBeNull();
|
|
153
|
+
expect(collapsedBtn!.getAttribute("aria-expanded")).toBe("false");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("sets aria-controls linking trigger to body", () => {
|
|
157
|
+
const { container } = render(
|
|
158
|
+
<StepTimeline
|
|
159
|
+
steps={sampleSteps}
|
|
160
|
+
expandedStepId="1"
|
|
161
|
+
onStepClick={() => {}}
|
|
162
|
+
renderStepBody={(step) => <div>Body for {step.label}</div>}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
const card = container.querySelectorAll('[data-slot="step-timeline-card"]')[0];
|
|
166
|
+
const btn = card.querySelector("button[aria-controls]");
|
|
167
|
+
expect(btn).not.toBeNull();
|
|
168
|
+
expect(btn!.getAttribute("aria-controls")).toBe("step-timeline-body-1");
|
|
169
|
+
// Body element should have matching id
|
|
170
|
+
const body = card.querySelector('[data-slot="step-timeline-body"]');
|
|
171
|
+
expect(body).not.toBeNull();
|
|
172
|
+
expect(body!.getAttribute("id")).toBe("step-timeline-body-1");
|
|
173
|
+
});
|
|
174
|
+
});
|