@handled-ai/design-system 0.9.28 → 0.10.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/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- 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/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/switch.d.ts +6 -0
- package/dist/components/switch.js +66 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/tabs.d.ts +1 -1
- 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 +12 -1
- package/dist/index.js +12 -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/compliance-badge.tsx +68 -0
- package/src/components/contact-chip.tsx +68 -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/switch.tsx +29 -0
- package/src/components/variable-autocomplete.tsx +178 -0
- package/src/index.ts +12 -1
- package/src/styles/globals.css +60 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import { StickyActionBar } from "../sticky-action-bar";
|
|
5
|
+
|
|
6
|
+
describe("StickyActionBar", () => {
|
|
7
|
+
it("renders with data-slot='sticky-action-bar'", () => {
|
|
8
|
+
const { container } = render(<StickyActionBar />);
|
|
9
|
+
const el = container.querySelector('[data-slot="sticky-action-bar"]');
|
|
10
|
+
expect(el).not.toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders left slot content", () => {
|
|
14
|
+
render(<StickyActionBar left={<span>Left Content</span>} />);
|
|
15
|
+
expect(screen.getByText("Left Content")).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders right slot content", () => {
|
|
19
|
+
render(<StickyActionBar right={<span>Right Content</span>} />);
|
|
20
|
+
expect(screen.getByText("Right Content")).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("applies border-t class when bordered=true (default)", () => {
|
|
24
|
+
const { container } = render(<StickyActionBar />);
|
|
25
|
+
const el = container.querySelector('[data-slot="sticky-action-bar"]')!;
|
|
26
|
+
expect(el.classList.contains("border-t")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("does not apply border-t class when bordered=false", () => {
|
|
30
|
+
const { container } = render(<StickyActionBar bordered={false} />);
|
|
31
|
+
const el = container.querySelector('[data-slot="sticky-action-bar"]')!;
|
|
32
|
+
expect(el.classList.contains("border-t")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not render left wrapper when left not provided", () => {
|
|
36
|
+
const { container } = render(<StickyActionBar />);
|
|
37
|
+
const leftSlot = container.querySelector('[data-slot="sticky-action-bar-left"]');
|
|
38
|
+
expect(leftSlot).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does not render right wrapper when right not provided", () => {
|
|
42
|
+
const { container } = render(<StickyActionBar />);
|
|
43
|
+
const rightSlot = container.querySelector('[data-slot="sticky-action-bar-right"]');
|
|
44
|
+
expect(rightSlot).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("merges custom className", () => {
|
|
48
|
+
const { container } = render(<StickyActionBar className="my-custom" />);
|
|
49
|
+
const el = container.querySelector('[data-slot="sticky-action-bar"]')!;
|
|
50
|
+
expect(el.classList.contains("my-custom")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, fireEvent } from "@testing-library/react";
|
|
4
|
+
import { Switch } from "../switch";
|
|
5
|
+
|
|
6
|
+
describe("Switch", () => {
|
|
7
|
+
it("renders with data-slot='switch'", () => {
|
|
8
|
+
const { container } = render(<Switch />);
|
|
9
|
+
const el = container.querySelector('[data-slot="switch"]');
|
|
10
|
+
expect(el).not.toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders thumb with data-slot='switch-thumb'", () => {
|
|
14
|
+
const { container } = render(<Switch />);
|
|
15
|
+
const thumb = container.querySelector('[data-slot="switch-thumb"]');
|
|
16
|
+
expect(thumb).not.toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("toggles checked state on click", () => {
|
|
20
|
+
const { container } = render(<Switch defaultChecked={false} />);
|
|
21
|
+
const switchEl = container.querySelector('[data-slot="switch"]')!;
|
|
22
|
+
expect(switchEl.getAttribute("data-state")).toBe("unchecked");
|
|
23
|
+
|
|
24
|
+
fireEvent.click(switchEl);
|
|
25
|
+
expect(switchEl.getAttribute("data-state")).toBe("checked");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("respects disabled prop", () => {
|
|
29
|
+
const { container } = render(<Switch disabled />);
|
|
30
|
+
const switchEl = container.querySelector('[data-slot="switch"]')!;
|
|
31
|
+
expect(switchEl).toHaveProperty("disabled", true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("merges custom className", () => {
|
|
35
|
+
const { container } = render(<Switch className="my-custom-class" />);
|
|
36
|
+
const switchEl = container.querySelector('[data-slot="switch"]')!;
|
|
37
|
+
expect(switchEl.classList.contains("my-custom-class")).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
4
|
+
import { VariableAutocomplete, type VariableGroup } from "../variable-autocomplete";
|
|
5
|
+
|
|
6
|
+
const sampleGroups: VariableGroup[] = [
|
|
7
|
+
{
|
|
8
|
+
label: "Contact",
|
|
9
|
+
variables: [
|
|
10
|
+
{ name: "first_name", description: "Contact first name", source: "crm" },
|
|
11
|
+
{ name: "last_name", description: "Contact last name", source: "crm" },
|
|
12
|
+
{ name: "email", description: "Contact email" },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
label: "Company",
|
|
17
|
+
variables: [
|
|
18
|
+
{ name: "company_name", source: "enrichment" },
|
|
19
|
+
{ name: "company_size" },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const defaultProps = {
|
|
25
|
+
groups: sampleGroups,
|
|
26
|
+
onSelect: vi.fn(),
|
|
27
|
+
onClose: vi.fn(),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("VariableAutocomplete", () => {
|
|
31
|
+
it("renders with data-slot='variable-autocomplete'", () => {
|
|
32
|
+
const { container } = render(<VariableAutocomplete {...defaultProps} />);
|
|
33
|
+
const el = container.querySelector('[data-slot="variable-autocomplete"]');
|
|
34
|
+
expect(el).not.toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders header with 'Insert variable' text", () => {
|
|
38
|
+
render(<VariableAutocomplete {...defaultProps} />);
|
|
39
|
+
expect(screen.getByText("Insert variable")).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders variable items with monospace names wrapped in {{ }}", () => {
|
|
43
|
+
render(<VariableAutocomplete {...defaultProps} />);
|
|
44
|
+
expect(screen.getByText("{{first_name}}")).toBeDefined();
|
|
45
|
+
expect(screen.getByText("{{last_name}}")).toBeDefined();
|
|
46
|
+
expect(screen.getByText("{{email}}")).toBeDefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders variable description when provided", () => {
|
|
50
|
+
render(<VariableAutocomplete {...defaultProps} />);
|
|
51
|
+
expect(screen.getByText("Contact first name")).toBeDefined();
|
|
52
|
+
expect(screen.getByText("Contact last name")).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders variable source badge when provided", () => {
|
|
56
|
+
render(<VariableAutocomplete {...defaultProps} />);
|
|
57
|
+
const crmBadges = screen.getAllByText("crm");
|
|
58
|
+
expect(crmBadges.length).toBe(2);
|
|
59
|
+
expect(screen.getByText("enrichment")).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders group labels", () => {
|
|
63
|
+
render(<VariableAutocomplete {...defaultProps} />);
|
|
64
|
+
expect(screen.getByText("Contact")).toBeDefined();
|
|
65
|
+
expect(screen.getByText("Company")).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("filters variables by name (case-insensitive startsWith)", () => {
|
|
69
|
+
const { container } = render(
|
|
70
|
+
<VariableAutocomplete {...defaultProps} filter="FIRST" />
|
|
71
|
+
);
|
|
72
|
+
const items = container.querySelectorAll('[data-slot="variable-autocomplete-item"]');
|
|
73
|
+
expect(items.length).toBe(1);
|
|
74
|
+
expect(screen.getByText("{{first_name}}")).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns null when no matches", () => {
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<VariableAutocomplete {...defaultProps} filter="zzz_no_match" />
|
|
80
|
+
);
|
|
81
|
+
expect(container.firstChild).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("fires onSelect on item click", () => {
|
|
85
|
+
const onSelect = vi.fn();
|
|
86
|
+
const { container } = render(
|
|
87
|
+
<VariableAutocomplete {...defaultProps} onSelect={onSelect} />
|
|
88
|
+
);
|
|
89
|
+
const item = container.querySelector('[data-slot="variable-autocomplete-item"]')!;
|
|
90
|
+
fireEvent.click(item);
|
|
91
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(onSelect).toHaveBeenCalledWith({
|
|
93
|
+
name: "first_name",
|
|
94
|
+
description: "Contact first name",
|
|
95
|
+
source: "crm",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("items use preventDefault on mousedown", () => {
|
|
100
|
+
const { container } = render(<VariableAutocomplete {...defaultProps} />);
|
|
101
|
+
const item = container.querySelector('[data-slot="variable-autocomplete-item"]')!;
|
|
102
|
+
const event = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
|
|
103
|
+
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
|
104
|
+
item.dispatchEvent(event);
|
|
105
|
+
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("fires onClose on Escape key", () => {
|
|
109
|
+
const onClose = vi.fn();
|
|
110
|
+
render(<VariableAutocomplete {...defaultProps} onClose={onClose} />);
|
|
111
|
+
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
|
112
|
+
document.dispatchEvent(event);
|
|
113
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("fires onClose on outside mousedown", () => {
|
|
117
|
+
const onClose = vi.fn();
|
|
118
|
+
render(<VariableAutocomplete {...defaultProps} onClose={onClose} />);
|
|
119
|
+
const event = new MouseEvent("mousedown", { bubbles: true });
|
|
120
|
+
document.dispatchEvent(event);
|
|
121
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("respects maxResults prop", () => {
|
|
125
|
+
const { container } = render(
|
|
126
|
+
<VariableAutocomplete {...defaultProps} maxResults={2} />
|
|
127
|
+
);
|
|
128
|
+
const items = container.querySelectorAll('[data-slot="variable-autocomplete-item"]');
|
|
129
|
+
expect(items.length).toBe(2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("has listbox role on the scrollable list", () => {
|
|
133
|
+
const { container } = render(<VariableAutocomplete {...defaultProps} />);
|
|
134
|
+
const listbox = container.querySelector('[role="listbox"]');
|
|
135
|
+
expect(listbox).not.toBeNull();
|
|
136
|
+
expect(listbox!.getAttribute("aria-label")).toBe("Variables");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("items are buttons with role='option'", () => {
|
|
140
|
+
const { container } = render(<VariableAutocomplete {...defaultProps} />);
|
|
141
|
+
const items = container.querySelectorAll('[data-slot="variable-autocomplete-item"]');
|
|
142
|
+
items.forEach((item) => {
|
|
143
|
+
expect(item.tagName).toBe("BUTTON");
|
|
144
|
+
expect(item.getAttribute("role")).toBe("option");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("renders group containers with role='group'", () => {
|
|
149
|
+
const { container } = render(<VariableAutocomplete {...defaultProps} />);
|
|
150
|
+
const groups = container.querySelectorAll('[role="group"]');
|
|
151
|
+
expect(groups.length).toBe(2);
|
|
152
|
+
expect(groups[0].getAttribute("aria-label")).toBe("Contact");
|
|
153
|
+
expect(groups[1].getAttribute("aria-label")).toBe("Company");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Check, Clock, AlertTriangle, Minus } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
|
|
8
|
+
type ComplianceStatus = "verified" | "pending" | "changed_since_verified" | "never_verified"
|
|
9
|
+
|
|
10
|
+
interface ComplianceBadgeProps extends React.HTMLAttributes<HTMLElement> {
|
|
11
|
+
status: ComplianceStatus
|
|
12
|
+
variant?: "line" | "pill"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const statusConfig = {
|
|
16
|
+
verified: {
|
|
17
|
+
line: { icon: Check, label: "Compliance verified", classes: "text-status-active-fg" },
|
|
18
|
+
pill: { icon: Check, label: "Verified", classes: "bg-status-active-bg text-status-active-fg" },
|
|
19
|
+
},
|
|
20
|
+
pending: {
|
|
21
|
+
line: { icon: Clock, label: "Pending compliance review", classes: "text-status-pending-fg" },
|
|
22
|
+
pill: { icon: AlertTriangle, label: "Pending review", classes: "bg-status-pending-bg text-status-pending-fg" },
|
|
23
|
+
},
|
|
24
|
+
changed_since_verified: {
|
|
25
|
+
line: { icon: AlertTriangle, label: "Changed since last verification", classes: "text-status-warning-fg" },
|
|
26
|
+
pill: { icon: AlertTriangle, label: "Changed", classes: "bg-status-warning-bg text-status-warning-fg" },
|
|
27
|
+
},
|
|
28
|
+
never_verified: {
|
|
29
|
+
line: { icon: Minus, label: "Never verified", classes: "text-muted-foreground" },
|
|
30
|
+
pill: { icon: Minus, label: "Never verified", classes: "bg-muted text-muted-foreground" },
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ComplianceBadge({ status, variant = "line", className, ...rest }: ComplianceBadgeProps) {
|
|
35
|
+
const config = statusConfig[status][variant]
|
|
36
|
+
const Icon = config.icon
|
|
37
|
+
const iconSize = variant === "line" ? 12 : 10
|
|
38
|
+
|
|
39
|
+
if (variant === "pill") {
|
|
40
|
+
return (
|
|
41
|
+
<span
|
|
42
|
+
data-slot="compliance-badge"
|
|
43
|
+
data-status={status}
|
|
44
|
+
data-variant="pill"
|
|
45
|
+
className={cn("text-[10px] px-2 py-0.5 rounded-full inline-flex w-fit items-center gap-1", config.classes, className)}
|
|
46
|
+
{...rest}
|
|
47
|
+
>
|
|
48
|
+
<Icon size={iconSize} />
|
|
49
|
+
<span>{config.label}</span>
|
|
50
|
+
</span>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
data-slot="compliance-badge"
|
|
57
|
+
data-status={status}
|
|
58
|
+
data-variant="line"
|
|
59
|
+
className={cn("text-xs inline-flex w-fit items-center gap-1.5", config.classes, className)}
|
|
60
|
+
{...rest}
|
|
61
|
+
>
|
|
62
|
+
<Icon size={iconSize} />
|
|
63
|
+
<span>{config.label}</span>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { ComplianceBadge, type ComplianceBadgeProps, type ComplianceStatus }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { X } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
|
|
8
|
+
interface ContactChipProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
9
|
+
name: string
|
|
10
|
+
email?: string
|
|
11
|
+
verified?: boolean
|
|
12
|
+
onConfirm?: () => void
|
|
13
|
+
onRemove?: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ContactChip({
|
|
17
|
+
name,
|
|
18
|
+
email,
|
|
19
|
+
verified = true,
|
|
20
|
+
onConfirm,
|
|
21
|
+
onRemove,
|
|
22
|
+
className,
|
|
23
|
+
...rest
|
|
24
|
+
}: ContactChipProps) {
|
|
25
|
+
const isVerified = verified !== false
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
data-slot="contact-chip"
|
|
30
|
+
data-verified={isVerified ? "true" : "false"}
|
|
31
|
+
className={cn(
|
|
32
|
+
"inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs",
|
|
33
|
+
isVerified
|
|
34
|
+
? "border border-border bg-background"
|
|
35
|
+
: "border border-status-warning-border bg-status-warning-bg",
|
|
36
|
+
className
|
|
37
|
+
)}
|
|
38
|
+
{...rest}
|
|
39
|
+
>
|
|
40
|
+
<span className="font-medium">{name}</span>
|
|
41
|
+
{email && <span className="text-muted-foreground">{email}</span>}
|
|
42
|
+
{!isVerified && onConfirm && (
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
data-slot="contact-chip-confirm"
|
|
46
|
+
onClick={onConfirm}
|
|
47
|
+
aria-label={`Confirm ${name}`}
|
|
48
|
+
className="text-[10px] px-1.5 py-0 rounded bg-status-warning-border/50 text-status-warning-fg hover:bg-status-warning-border cursor-pointer font-medium ml-1"
|
|
49
|
+
>
|
|
50
|
+
Confirm
|
|
51
|
+
</button>
|
|
52
|
+
)}
|
|
53
|
+
{onRemove && (
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
data-slot="contact-chip-remove"
|
|
57
|
+
onClick={onRemove}
|
|
58
|
+
aria-label={`Remove ${name}`}
|
|
59
|
+
className="inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
60
|
+
>
|
|
61
|
+
<X size={12} />
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
64
|
+
</span>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { ContactChip, type ContactChipProps }
|