@fiscozen/checkbox 1.0.1 → 1.1.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.
@@ -0,0 +1,423 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { defineComponent, ref, nextTick } from "vue";
4
+ import { FzCheckboxCard, FzCheckboxGroup } from "..";
5
+ import type { FzCheckboxCardProps } from "../types";
6
+
7
+ const createWrapper = (
8
+ props: Partial<FzCheckboxCardProps> & { modelValue?: (string | number | boolean)[] },
9
+ ) => {
10
+ return mount(FzCheckboxCard, {
11
+ props: {
12
+ label: "Checkbox",
13
+ title: "Card title",
14
+ modelValue: [],
15
+ ...props,
16
+ } as any,
17
+ });
18
+ };
19
+
20
+ describe("FzCheckboxCard", () => {
21
+ beforeEach(() => {
22
+ const mockIntersectionObserver = vi.fn();
23
+ mockIntersectionObserver.mockReturnValue({
24
+ observe: () => null,
25
+ unobserve: () => null,
26
+ disconnect: () => null,
27
+ });
28
+ window.IntersectionObserver = mockIntersectionObserver;
29
+
30
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
31
+ matches: false,
32
+ media: query,
33
+ onchange: null,
34
+ addListener: vi.fn(),
35
+ removeListener: vi.fn(),
36
+ addEventListener: vi.fn(),
37
+ removeEventListener: vi.fn(),
38
+ dispatchEvent: vi.fn(),
39
+ }));
40
+ });
41
+
42
+ describe("Rendering", () => {
43
+ it("should render correctly", () => {
44
+ const wrapper = createWrapper({});
45
+ expect(wrapper.html()).toMatchSnapshot();
46
+ });
47
+
48
+ it("should render title", () => {
49
+ const wrapper = createWrapper({ title: "My Card Title" });
50
+ expect(wrapper.text()).toContain("My Card Title");
51
+ });
52
+
53
+ it("should render subtitle when provided", () => {
54
+ const wrapper = createWrapper({
55
+ title: "Title",
56
+ subtitle: "A subtitle",
57
+ });
58
+ expect(wrapper.text()).toContain("A subtitle");
59
+ });
60
+
61
+ it("should not render subtitle when not provided", () => {
62
+ const wrapper = createWrapper({ title: "Title" });
63
+ const paragraphs = wrapper.findAll("p");
64
+ expect(paragraphs.length).toBe(1);
65
+ });
66
+
67
+ it("should render image when imageUrl is provided", () => {
68
+ const wrapper = createWrapper({
69
+ imageUrl: "test.jpg",
70
+ imageAlt: "Test image",
71
+ });
72
+ const img = wrapper.find("img");
73
+ expect(img.exists()).toBe(true);
74
+ expect(img.attributes("src")).toBe("test.jpg");
75
+ expect(img.attributes("alt")).toBe("Test image");
76
+ });
77
+
78
+ it("should not render image when imageUrl is not provided", () => {
79
+ const wrapper = createWrapper({});
80
+ expect(wrapper.find("img").exists()).toBe(false);
81
+ });
82
+
83
+ it("should render a checkbox input", () => {
84
+ const wrapper = createWrapper({});
85
+ const input = wrapper.find("input[type='checkbox']");
86
+ expect(input.exists()).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe("Checked state", () => {
91
+ it("should be checked when value is in modelValue array", async () => {
92
+ const wrapper = createWrapper({
93
+ value: "option1",
94
+ modelValue: ["option1"],
95
+ });
96
+ await wrapper.vm.$nextTick();
97
+ expect(wrapper.find("input").element.checked).toBe(true);
98
+ });
99
+
100
+ it("should not be checked when value is not in modelValue array", () => {
101
+ const wrapper = createWrapper({
102
+ value: "option1",
103
+ modelValue: ["option2"],
104
+ });
105
+ expect(wrapper.find("input").element.checked).toBe(false);
106
+ });
107
+
108
+ it("should not be checked when modelValue is empty", () => {
109
+ const wrapper = createWrapper({
110
+ value: "option1",
111
+ modelValue: [],
112
+ });
113
+ expect(wrapper.find("input").element.checked).toBe(false);
114
+ });
115
+
116
+ it("should fall back to label as value when value prop is not provided", async () => {
117
+ const wrapper = createWrapper({
118
+ label: "My Label",
119
+ modelValue: [],
120
+ });
121
+
122
+ await wrapper.find("input").trigger("change");
123
+ expect(wrapper.emitted("update:modelValue")).toBeTruthy();
124
+ expect(wrapper.emitted("update:modelValue")![0][0]).toEqual([
125
+ "My Label",
126
+ ]);
127
+ });
128
+ });
129
+
130
+ describe("Events", () => {
131
+ it("should emit update:modelValue with value added when clicking unchecked card", async () => {
132
+ const wrapper = createWrapper({
133
+ value: "option1",
134
+ modelValue: [],
135
+ });
136
+
137
+ await wrapper.find("input").trigger("change");
138
+ expect(wrapper.emitted("update:modelValue")).toBeTruthy();
139
+ expect(wrapper.emitted("update:modelValue")![0][0]).toEqual(["option1"]);
140
+ });
141
+
142
+ it("should emit update:modelValue with value removed when clicking checked card", async () => {
143
+ const wrapper = createWrapper({
144
+ value: "option1",
145
+ modelValue: ["option1", "option2"],
146
+ });
147
+
148
+ await wrapper.find("input").trigger("change");
149
+ expect(wrapper.emitted("update:modelValue")).toBeTruthy();
150
+ expect(wrapper.emitted("update:modelValue")![0][0]).toEqual(["option2"]);
151
+ });
152
+
153
+ it("should not emit when disabled", async () => {
154
+ const wrapper = createWrapper({
155
+ value: "option1",
156
+ modelValue: [],
157
+ disabled: true,
158
+ });
159
+
160
+ await wrapper.find("input").trigger("change");
161
+ expect(wrapper.emitted("update:modelValue")).toBeFalsy();
162
+ });
163
+ });
164
+
165
+ describe("Disabled state", () => {
166
+ it("should set disabled attribute on input", () => {
167
+ const wrapper = createWrapper({ disabled: true });
168
+ expect(wrapper.find("input").element.disabled).toBe(true);
169
+ });
170
+
171
+ it("should apply disabled border styling", () => {
172
+ const wrapper = createWrapper({
173
+ disabled: true,
174
+ value: "opt",
175
+ modelValue: ["opt"],
176
+ });
177
+ const label = wrapper.find("label");
178
+ expect(label.classes()).toContain("border-grey-300");
179
+ });
180
+ });
181
+
182
+ describe("hasCheckbox prop", () => {
183
+ it("should render checkbox icon by default", () => {
184
+ const wrapper = createWrapper({});
185
+ const icons = wrapper.findAllComponents({ name: "FzIcon" });
186
+ const checkboxIcon = icons.find(
187
+ (icon) =>
188
+ icon.props("name") === "square" ||
189
+ icon.props("name") === "square-check",
190
+ );
191
+ expect(checkboxIcon).toBeDefined();
192
+ });
193
+
194
+ it("should hide checkbox icon when hasCheckbox is false", () => {
195
+ const wrapper = createWrapper({ hasCheckbox: false });
196
+ const icons = wrapper.findAllComponents({ name: "FzIcon" });
197
+ const checkboxIcon = icons.find(
198
+ (icon) =>
199
+ icon.props("name") === "square" ||
200
+ icon.props("name") === "square-check",
201
+ );
202
+ expect(checkboxIcon).toBeUndefined();
203
+ });
204
+ });
205
+
206
+ describe("Variant prop", () => {
207
+ it("should apply horizontal classes by default", () => {
208
+ const wrapper = createWrapper({});
209
+ const label = wrapper.find("label");
210
+ expect(label.classes()).toContain("flex-row");
211
+ });
212
+
213
+ it("should apply vertical classes when variant is vertical", () => {
214
+ const wrapper = createWrapper({ variant: "vertical", imageUrl: "test.jpg" });
215
+ const label = wrapper.find("label");
216
+ expect(label.classes()).toContain("flex-col");
217
+ });
218
+
219
+ it("should apply horizontal classes when variant is horizontal", () => {
220
+ const wrapper = createWrapper({ variant: "horizontal" });
221
+ const label = wrapper.find("label");
222
+ expect(label.classes()).toContain("flex-row");
223
+ });
224
+ });
225
+
226
+ describe("Selected styling", () => {
227
+ it("should apply selected border when checked and not disabled", () => {
228
+ const wrapper = createWrapper({
229
+ value: "opt",
230
+ modelValue: ["opt"],
231
+ });
232
+ const label = wrapper.find("label");
233
+ expect(label.classes()).toContain("border-blue-500");
234
+ expect(label.classes()).toContain("border-2");
235
+ });
236
+
237
+ it("should apply default border when not checked", () => {
238
+ const wrapper = createWrapper({
239
+ value: "opt",
240
+ modelValue: [],
241
+ });
242
+ const label = wrapper.find("label");
243
+ expect(label.classes()).toContain("border-grey-300");
244
+ });
245
+ });
246
+
247
+ describe("Emphasis styling", () => {
248
+ it("should apply emphasis color to checkbox icon when emphasis is true", () => {
249
+ const wrapper = createWrapper({ emphasis: true });
250
+ const icons = wrapper.findAllComponents({ name: "FzIcon" });
251
+ const checkboxIcon = icons.find(
252
+ (icon) =>
253
+ icon.props("name") === "square" ||
254
+ icon.props("name") === "square-check",
255
+ );
256
+ expect(checkboxIcon?.classes()).toContain("text-blue-500");
257
+ });
258
+ });
259
+
260
+ describe("Error styling", () => {
261
+ it("should apply error color to checkbox icon when error is true", () => {
262
+ const wrapper = createWrapper({ error: true });
263
+ const icons = wrapper.findAllComponents({ name: "FzIcon" });
264
+ const checkboxIcon = icons.find(
265
+ (icon) =>
266
+ icon.props("name") === "square" ||
267
+ icon.props("name") === "square-check",
268
+ );
269
+ expect(checkboxIcon?.classes()).toContain("text-semantic-error-200");
270
+ });
271
+ });
272
+
273
+ describe("Accessibility", () => {
274
+ it("should have aria-checked attribute", () => {
275
+ const wrapper = createWrapper({
276
+ value: "opt",
277
+ modelValue: ["opt"],
278
+ });
279
+ expect(wrapper.find("input").attributes("aria-checked")).toBe("true");
280
+ });
281
+
282
+ it("should have aria-required when required", () => {
283
+ const wrapper = createWrapper({ required: true });
284
+ expect(wrapper.find("input").attributes("aria-required")).toBe("true");
285
+ });
286
+
287
+ it("should have aria-invalid when in error state", () => {
288
+ const wrapper = createWrapper({ error: true });
289
+ expect(wrapper.find("input").attributes("aria-invalid")).toBe("true");
290
+ });
291
+
292
+ it("should connect label to input via for/id", () => {
293
+ const wrapper = createWrapper({});
294
+ const input = wrapper.find("input");
295
+ const label = wrapper.find("label");
296
+ expect(label.attributes("for")).toBe(input.attributes("id"));
297
+ });
298
+ });
299
+
300
+ describe("Tooltip", () => {
301
+ it("should render tooltip when tooltip prop is provided", () => {
302
+ const wrapper = createWrapper({ tooltip: "Help text" });
303
+ const tooltip = wrapper.findComponent({ name: "FzTooltip" });
304
+ expect(tooltip.exists()).toBe(true);
305
+ });
306
+
307
+ it("should not render tooltip when tooltip prop is not provided", () => {
308
+ const wrapper = createWrapper({});
309
+ const tooltip = wrapper.findComponent({ name: "FzTooltip" });
310
+ expect(tooltip.exists()).toBe(false);
311
+ });
312
+ });
313
+
314
+ describe("Inside FzCheckboxGroup (integration)", () => {
315
+ function mountGroupWithCards(initialSelected: (string | number | boolean)[] = []) {
316
+ const Wrapper = defineComponent({
317
+ components: { FzCheckboxGroup, FzCheckboxCard },
318
+ setup() {
319
+ const selected = ref<(string | number | boolean)[]>(initialSelected);
320
+ const handleUpdate = (val: (string | number | boolean)[]) => {
321
+ selected.value = val;
322
+ };
323
+ return { selected, handleUpdate };
324
+ },
325
+ template: `
326
+ <FzCheckboxGroup label="Test Group">
327
+ <template #default="{ checkboxGroupProps }">
328
+ <FzCheckboxCard
329
+ label="Card 1"
330
+ title="Card 1"
331
+ value="card1"
332
+ :modelValue="selected"
333
+ @update:modelValue="handleUpdate"
334
+ v-bind="checkboxGroupProps"
335
+ />
336
+ <FzCheckboxCard
337
+ label="Card 2"
338
+ title="Card 2"
339
+ value="card2"
340
+ :modelValue="selected"
341
+ @update:modelValue="handleUpdate"
342
+ v-bind="checkboxGroupProps"
343
+ />
344
+ <FzCheckboxCard
345
+ label="Card 3"
346
+ title="Card 3"
347
+ value="card3"
348
+ :modelValue="selected"
349
+ @update:modelValue="handleUpdate"
350
+ v-bind="checkboxGroupProps"
351
+ />
352
+ </template>
353
+ </FzCheckboxGroup>
354
+ `,
355
+ });
356
+
357
+ return mount(Wrapper);
358
+ }
359
+
360
+ function getCheckboxInputs(wrapper: ReturnType<typeof mount>) {
361
+ return wrapper
362
+ .findAll("input[type='checkbox']")
363
+ .map((w) => w.element as HTMLInputElement);
364
+ }
365
+
366
+ it("should show pre-checked cards when modelValue contains their values", async () => {
367
+ const wrapper = mountGroupWithCards(["card1", "card3"]);
368
+ await nextTick();
369
+
370
+ const inputs = getCheckboxInputs(wrapper);
371
+ expect(inputs[0].checked).toBe(true);
372
+ expect(inputs[1].checked).toBe(false);
373
+ expect(inputs[2].checked).toBe(true);
374
+ });
375
+
376
+ it("should check a card when clicking it", async () => {
377
+ const wrapper = mountGroupWithCards();
378
+ await nextTick();
379
+
380
+ const wrappers = wrapper.findAll("input[type='checkbox']");
381
+ const inputs = getCheckboxInputs(wrapper);
382
+ expect(inputs[0].checked).toBe(false);
383
+
384
+ await wrappers[0].trigger("change");
385
+ await nextTick();
386
+
387
+ expect(wrapper.vm.selected).toContain("card1");
388
+ expect(inputs[0].checked).toBe(true);
389
+ });
390
+
391
+ it("should uncheck a card when clicking a checked card", async () => {
392
+ const wrapper = mountGroupWithCards(["card2"]);
393
+ await nextTick();
394
+
395
+ const wrappers = wrapper.findAll("input[type='checkbox']");
396
+ const inputs = getCheckboxInputs(wrapper);
397
+ expect(inputs[1].checked).toBe(true);
398
+
399
+ await wrappers[1].trigger("change");
400
+ await nextTick();
401
+
402
+ expect(wrapper.vm.selected).not.toContain("card2");
403
+ expect(inputs[1].checked).toBe(false);
404
+ });
405
+
406
+ it("should support selecting multiple cards", async () => {
407
+ const wrapper = mountGroupWithCards();
408
+ await nextTick();
409
+
410
+ const wrappers = wrapper.findAll("input[type='checkbox']");
411
+ const inputs = getCheckboxInputs(wrapper);
412
+ await wrappers[0].trigger("change");
413
+ await nextTick();
414
+ await wrappers[2].trigger("change");
415
+ await nextTick();
416
+
417
+ expect(wrapper.vm.selected).toEqual(["card1", "card3"]);
418
+ expect(inputs[0].checked).toBe(true);
419
+ expect(inputs[1].checked).toBe(false);
420
+ expect(inputs[2].checked).toBe(true);
421
+ });
422
+ });
423
+ });
@@ -0,0 +1,17 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`FzCheckboxCard > Rendering > should render correctly 1`] = `
4
+ "<div data-v-7eaaa48f=""><input data-v-7eaaa48f="" type="checkbox" id="fz-checkbox-100000000001-2a84k" class="w-0 h-0 peer fz-hidden-input" tabindex="0" aria-checked="false" aria-required="false" aria-invalid="false" value="Checkbox"><label data-v-7eaaa48f="" class="relative flex block rounded-lg border-solid pt-12 px-12 cursor-pointer w-full flex-row pb-12 gap-12 border-1 border-grey-300 hover:bg-[#f9faff] peer-focus:outline peer-focus:bg-[#f9faff] peer-focus:outline-blue-200 peer-focus:outline-2 peer-checked:bg-[#f9faff]" for="fz-checkbox-100000000001-2a84k">
5
+ <div data-v-7eaaa48f="" class="flex items-center justify-center w-[20px] h-[20px] shrink-0 text-grey-400 self-start" aria-hidden="true"><svg class="svg-inline--fa fa-square h-[16px]" aria-hidden="true" focusable="false" data-prefix="far" data-icon="square" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
6
+ <path class="" fill="currentColor" d="M384 80c8.8 0 16 7.2 16 16l0 320c0 8.8-7.2 16-16 16L64 432c-8.8 0-16-7.2-16-16L48 96c0-8.8 7.2-16 16-16l320 0zM64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32z"></path>
7
+ </svg></div>
8
+ <!--v-if-->
9
+ <div data-v-7eaaa48f="" class="flex flex-row w-full justify-between min-w-0">
10
+ <div data-v-7eaaa48f="" class="justify-center flex flex-col w-full grow-0 min-w-0 gap-4">
11
+ <p data-v-7eaaa48f="" class="font-medium break-words !m-0 !leading-[20px]">Card title</p>
12
+ <!--v-if-->
13
+ </div>
14
+ <!--v-if-->
15
+ </div>
16
+ </label></div>"
17
+ `;
package/src/common.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * @module @fiscozen/checkbox/common
5
5
  */
6
6
 
7
+ import type { ComputedRef, InjectionKey, Ref } from "vue";
8
+
7
9
  /**
8
10
  * Maps checkbox size variants to corresponding Tailwind CSS text size classes.
9
11
  *
@@ -22,3 +24,20 @@ export const mapSizeToClasses = {
22
24
  /** Medium size: 16px font size (1rem) - default */
23
25
  md: "text-base",
24
26
  };
27
+
28
+ export interface CheckedSetProvision {
29
+ /** The group's model ref — consumers compare by reference identity to decide
30
+ * whether the shared Set is built from the same data they hold. */
31
+ source: Ref<(string | number | boolean)[]>;
32
+ /** Pre-built Set for O(1) lookups, derived from `source`. */
33
+ set: ComputedRef<Set<string | number | boolean>>;
34
+ }
35
+
36
+ /**
37
+ * Injection key for the shared checked-values Set.
38
+ * Provided by FzCheckboxGroup so child cards/checkboxes can do O(1) membership
39
+ * checks instead of O(N) Array.includes scans — but only when the group's
40
+ * model and the child's model reference the same array.
41
+ */
42
+ export const CHECKED_SET_KEY: InjectionKey<CheckedSetProvision> =
43
+ Symbol("FzCheckboxCheckedSet");
package/src/index.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  export { default as FzCheckbox } from "./FzCheckbox.vue";
2
2
  export { default as FzCheckboxGroup } from "./FzCheckboxGroup.vue";
3
- export type { FzCheckboxProps, FzCheckboxGroupProps } from "./types";
3
+ export { default as FzCheckboxCard } from "./FzCheckboxCard.vue";
4
+ export type {
5
+ FzCheckboxProps,
6
+ FzCheckboxGroupProps,
7
+ FzCheckboxCardProps,
8
+ } from "./types";
4
9
  export { generateCheckboxId, generateGroupId } from "./utils";
package/src/types.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * @module @fiscozen/checkbox/types
8
8
  */
9
9
  import { FzTooltipProps } from "@fiscozen/tooltip";
10
+ import { FzTooltipStatus } from "@fiscozen/tooltip";
10
11
 
11
12
  /**
12
13
  * Props for the FzCheckbox component.
@@ -155,7 +156,7 @@ export type FzCheckboxGroupProps = {
155
156
  * }
156
157
  * ]
157
158
  */
158
- options: ParentCheckbox[];
159
+ options?: ParentCheckbox[];
159
160
 
160
161
  /**
161
162
  * Applies emphasis styling to all checkboxes in the group.
@@ -216,3 +217,87 @@ export type ParentCheckbox = ChildCheckbox & {
216
217
  * Inherits all FzCheckboxProps except 'size' which is controlled by the parent group.
217
218
  */
218
219
  export type ChildCheckbox = Omit<FzCheckboxProps, "size">;
220
+
221
+ /**
222
+ * Shared props for all FzCheckboxCard variants.
223
+ */
224
+ type FzCheckboxCardBaseProps = Omit<
225
+ FzCheckboxProps,
226
+ "standalone" | "indeterminate" | "ariaOwns" | "checkboxId" | "value" | "tooltip"
227
+ > & {
228
+ /**
229
+ * Value associated with the card when used in array v-model.
230
+ * Falls back to label if not provided.
231
+ */
232
+ value?: string | number;
233
+
234
+ /**
235
+ * Primary title text displayed in the card.
236
+ */
237
+ title: string;
238
+
239
+ /**
240
+ * Optional secondary description text below the title.
241
+ */
242
+ subtitle?: string;
243
+
244
+ /**
245
+ * Alt text for the card image.
246
+ */
247
+ imageAlt?: string;
248
+
249
+ /**
250
+ * Text to display in the tooltip.
251
+ */
252
+ tooltip?: string;
253
+
254
+ /**
255
+ * Status of the tooltip (determines color and icon).
256
+ */
257
+ tooltipStatus?: FzTooltipStatus;
258
+
259
+ /**
260
+ * Controls whether the checkbox icon is shown inside the card.
261
+ *
262
+ * @default true
263
+ */
264
+ hasCheckbox?: boolean;
265
+
266
+ /**
267
+ * Group name for the checkbox, used for form submission.
268
+ */
269
+ name?: string;
270
+ };
271
+
272
+ /**
273
+ * Horizontal card layout: image left, text right (compact).
274
+ * Image is optional.
275
+ *
276
+ * @default variant is 'horizontal' when omitted
277
+ */
278
+ type FzCheckboxCardHorizontal = FzCheckboxCardBaseProps & {
279
+ variant?: "horizontal";
280
+ imageUrl?: string;
281
+ };
282
+
283
+ /**
284
+ * Vertical card layout: image top, text bottom (full-width image).
285
+ * Image is required — the vertical layout is designed around the image.
286
+ */
287
+ type FzCheckboxCardVertical = FzCheckboxCardBaseProps & {
288
+ variant: "vertical";
289
+ imageUrl: string;
290
+ };
291
+
292
+ /**
293
+ * Props for the FzCheckboxCard component.
294
+ *
295
+ * A card-style checkbox with title, subtitle, optional image and tooltip.
296
+ * Uses a discriminated union on `variant` to enforce that the vertical layout
297
+ * always includes an image (since the layout is designed around it).
298
+ *
299
+ * Uses array v-model for multi-select.
300
+ */
301
+ export type FzCheckboxCardProps =
302
+ | FzCheckboxCardHorizontal
303
+ | FzCheckboxCardVertical;