vueless 1.2.4 → 1.2.5-beta.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.
@@ -2,293 +2,98 @@ import { mount } from "@vue/test-utils";
2
2
  import { describe, it, expect } from "vitest";
3
3
 
4
4
  import UAccordion from "../UAccordion.vue";
5
- import UIcon from "../../ui.image-icon/UIcon.vue";
6
-
7
- import type { Props } from "../types";
5
+ import UAccordionItem from "../../ui.container-accordion-item/UAccordionItem.vue";
8
6
 
9
7
  describe("UAccordion", () => {
8
+ const options = [
9
+ { value: "a", title: "A" },
10
+ { value: "b", title: "B" },
11
+ ];
12
+
10
13
  // Props
11
14
  describe("Props", () => {
12
- // Title prop
13
- it("renders with title prop", () => {
14
- const title = "Accordion Title";
15
-
15
+ it("renders items from options", () => {
16
16
  const component = mount(UAccordion, {
17
- props: {
18
- title,
19
- },
17
+ props: { options },
20
18
  });
21
19
 
22
- expect(component.find("[vl-key='title']").text()).toContain(title);
23
- });
24
-
25
- // Description prop
26
- it("renders with description prop", () => {
27
- const description = "Accordion Description";
20
+ const items = component.findAllComponents(UAccordionItem);
28
21
 
29
- const component = mount(UAccordion, {
30
- props: {
31
- description,
32
- },
33
- });
34
-
35
- expect(component.find("[vl-key='description']").text()).toBe(description);
22
+ expect(items.length).toBe(options.length);
36
23
  });
37
24
 
38
- // Size prop
39
- it("applies correct size classes", () => {
40
- const sizeClasses = {
41
- sm: "text-small",
42
- md: "text-medium",
43
- lg: "text-large",
44
- };
45
-
46
- Object.entries(sizeClasses).forEach(([size, classes]) => {
47
- const component = mount(UAccordion, {
48
- props: {
49
- size: size as Props["size"],
50
- },
51
- });
52
-
53
- expect(component.find("[vl-key='title']").classes()).toContain(classes);
54
- });
55
- });
56
-
57
- // ToggleIcon prop
58
- it("applies correct toggle icon behavior", () => {
59
- const toggleIconTests = [
60
- { toggleIcon: true, exists: true, iconName: "keyboard_arrow_down" },
61
- { toggleIcon: "custom_icon", exists: true, iconName: "custom_icon" },
62
- { toggleIcon: false, exists: false, iconName: undefined },
63
- ];
64
-
65
- toggleIconTests.forEach(({ toggleIcon, exists, iconName }) => {
66
- const component = mount(UAccordion, {
67
- props: {
68
- toggleIcon: toggleIcon as Props["toggleIcon"],
69
- },
70
- });
71
-
72
- const icon = component.findComponent(UIcon);
73
-
74
- expect(icon.exists()).toBe(exists);
75
-
76
- if (exists) {
77
- expect(icon.props("name")).toBe(iconName);
78
- }
79
- });
80
- });
81
-
82
- // ID prop
83
- it("uses provided id prop", () => {
84
- const id = "custom-id";
85
- const description = "some text";
86
-
25
+ it("passes base props down to items", () => {
87
26
  const component = mount(UAccordion, {
88
- props: {
89
- id,
90
- description,
91
- },
27
+ props: { size: "md", disabled: true, options },
92
28
  });
93
29
 
94
- expect(component.find(`[id="description-${id}"]`).exists()).toBe(true);
30
+ const item = component.findComponent(UAccordionItem);
31
+
32
+ expect(item.props("size")).toBe("md");
33
+ expect(item.props("disabled")).toBe(true);
95
34
  });
96
35
 
97
- // DataTest prop
98
- it("applies data-test attribute", () => {
36
+ it("applies data-test attribute to wrapper", () => {
99
37
  const dataTest = "accordion-test";
100
38
 
101
39
  const component = mount(UAccordion, {
102
- props: {
103
- dataTest,
104
- },
40
+ props: { dataTest },
105
41
  });
106
42
 
107
43
  expect(component.attributes("data-test")).toBe(dataTest);
108
44
  });
109
45
  });
110
46
 
111
- // Slots
112
- describe("Slots", () => {
113
- // Toggle slot
114
- it("renders default toggle icon when toggle slot is not provided", () => {
115
- const toggleIcon = true;
116
-
117
- const component = mount(UAccordion, {
118
- props: {
119
- toggleIcon,
120
- },
121
- });
122
-
123
- const icon = component.findComponent(UIcon);
124
-
125
- expect(icon.exists()).toBe(true);
126
- });
127
-
128
- // Custom toggle slot
129
- it("renders custom content in toggle slot", () => {
130
- const toggleIcon = true;
131
- const slotClass = "custom-toggle";
132
- const slotContent = "Custom Toggle";
133
-
134
- const component = mount(UAccordion, {
135
- props: {
136
- toggleIcon,
137
- },
138
- slots: {
139
- toggle: `<div class="${slotClass}">${slotContent}</div>`,
140
- },
141
- });
142
-
143
- expect(component.find(`.${slotClass}`).exists()).toBe(true);
144
- expect(component.find(`.${slotClass}`).text()).toBe(slotContent);
145
- expect(component.findComponent(UIcon).exists()).toBe(false);
146
- });
147
-
148
- // Toggle slot bindings
149
- it("provides icon-name and opened bindings to toggle slot", async () => {
150
- const toggleIcon = true;
151
- const toggleClass = "custom-toggle";
152
- const defaultIconName = "keyboard_arrow_down";
153
-
154
- const component = mount(UAccordion, {
155
- props: {
156
- toggleIcon,
157
- },
158
- slots: {
159
- toggle: `
160
- <template #default="{ iconName, opened }">
161
- <div class="${toggleClass}" :data-icon="iconName" :data-opened="opened"></div>
162
- </template>
163
- `,
164
- },
165
- });
166
-
167
- const toggleElement = component.find(`.${toggleClass}`);
168
-
169
- expect(toggleElement.exists()).toBe(true);
170
- expect(toggleElement.attributes("data-icon")).toBe(defaultIconName);
171
- expect(toggleElement.attributes("data-opened")).toBe("false");
172
-
173
- // Click to toggle
174
- await component.trigger("click");
175
-
176
- expect(toggleElement.attributes("data-opened")).toBe("true");
177
- });
178
-
179
- // Default slot
180
- it("renders default slot content when accordion is opened", async () => {
181
- const slotContent = "Custom accordion content";
182
- const slotClass = "custom-content";
183
-
184
- const component = mount(UAccordion, {
185
- slots: {
186
- default: `<div class="${slotClass}">${slotContent}</div>`,
187
- },
188
- });
189
-
190
- expect(component.find(`.${slotClass}`).exists()).toBe(false);
191
-
192
- await component.trigger("click");
193
-
194
- expect(component.find(`.${slotClass}`).exists()).toBe(true);
195
- expect(component.find(`.${slotClass}`).text()).toBe(slotContent);
196
-
197
- await component.trigger("click");
198
-
199
- expect(component.find(`.${slotClass}`).exists()).toBe(false);
200
- });
201
-
202
- it("does not render default slot content when accordion is closed", () => {
203
- const slotContent = "Custom accordion content";
204
- const slotClass = "custom-content";
205
-
206
- const component = mount(UAccordion, {
207
- slots: {
208
- default: `<div class="${slotClass}">${slotContent}</div>`,
209
- },
210
- });
211
-
212
- expect(component.find(`.${slotClass}`).exists()).toBe(false);
213
- });
214
-
215
- it("does not render content wrapper when default slot is empty", async () => {
47
+ // Exposed refs
48
+ describe("Exposed refs", () => {
49
+ it("exposes accordionRef", () => {
216
50
  const component = mount(UAccordion);
217
51
 
218
- await component.trigger("click");
219
-
220
- expect(component.find("[vl-key='content']").exists()).toBe(false);
52
+ expect(component.vm.accordionRef).toBeDefined();
53
+ expect(component.vm.accordionRef instanceof HTMLDivElement).toBe(true);
221
54
  });
222
55
  });
223
56
 
224
57
  // Events
225
58
  describe("Events", () => {
226
- // Click event
227
- it("emits click event with id and opened state when clicked", async () => {
228
- const id = "test-id";
229
-
59
+ it("emits update:modelValue when an item is toggled (single)", async () => {
230
60
  const component = mount(UAccordion, {
231
- props: {
232
- id,
233
- },
61
+ props: { options },
234
62
  });
235
63
 
236
- await component.trigger("click");
237
-
238
- const emitted = component.emitted("click");
64
+ const firstItem = component.findAllComponents(UAccordionItem)[0];
239
65
 
240
- expect(emitted).toBeTruthy();
241
- expect(emitted?.[0]).toEqual([id, true]);
66
+ await firstItem.trigger("click");
242
67
 
243
- // Click again to toggle back
244
- await component.trigger("click");
68
+ const updates = component.emitted("update:modelValue");
245
69
 
246
- const emittedAgain = component.emitted("click");
70
+ expect(updates).toBeTruthy();
71
+ expect(updates?.[0]).toEqual(["a"]);
247
72
 
248
- expect(emittedAgain?.[1]).toEqual([id, false]);
249
- });
250
- });
73
+ await firstItem.trigger("click");
74
+ const updates2 = component.emitted("update:modelValue");
251
75
 
252
- // Exposed refs
253
- describe("Exposed refs", () => {
254
- // WrapperRef
255
- it("exposes wrapperRef", () => {
256
- const component = mount(UAccordion);
257
-
258
- expect(component.vm.wrapperRef).toBeDefined();
259
- expect(component.vm.wrapperRef instanceof HTMLDivElement).toBe(true);
76
+ expect(updates2?.[1]).toEqual([null]);
260
77
  });
261
- });
262
-
263
- // Component behavior
264
- describe("Component behavior", () => {
265
- // Toggle behavior
266
- it("toggles opened state when clicked", async () => {
267
- const description = "Test Description";
268
- const openedClass = "opacity-100";
269
78
 
79
+ it("emits update:modelValue with arrays when multiple=true", async () => {
270
80
  const component = mount(UAccordion, {
271
- props: {
272
- description,
273
- },
81
+ props: { options, multiple: true },
274
82
  });
275
83
 
276
- const descriptionElement = component.find("[id^='description-']");
277
-
278
- // Initially not opened
279
- expect(descriptionElement.classes()).not.toContain(openedClass);
84
+ const [firstItem, secondItem] = component.findAllComponents(UAccordionItem);
280
85
 
281
- // Click to open
282
- await component.trigger("click");
86
+ await firstItem.trigger("click");
87
+ await secondItem.trigger("click");
88
+ const updates = component.emitted("update:modelValue");
283
89
 
284
- // Should be opened
285
- expect(descriptionElement.classes()).toContain(openedClass);
90
+ expect(updates?.[0]).toEqual([["a"]]);
91
+ expect(updates?.[1]).toEqual([["a", "b"]]);
286
92
 
287
- // Click to close
288
- await component.trigger("click");
93
+ await firstItem.trigger("click");
94
+ const updates2 = component.emitted("update:modelValue");
289
95
 
290
- // Should be closed again
291
- expect(descriptionElement.classes()).not.toContain(openedClass);
96
+ expect(updates2?.[2]).toEqual([["b"]]);
292
97
  });
293
98
  });
294
99
  });
@@ -3,16 +3,25 @@ import type { ComponentConfig } from "../types";
3
3
 
4
4
  export type Config = typeof defaultConfig;
5
5
 
6
+ export interface UAccordionOption {
7
+ value: string;
8
+ title: string;
9
+ description?: string;
10
+ opened?: boolean;
11
+ }
12
+
13
+ export type SetAccordionSelectedItem = (value: string, opened: boolean) => void;
14
+
6
15
  export interface Props {
7
16
  /**
8
- * Accordion title.
17
+ * Accordion items state control.
9
18
  */
10
- title?: string;
19
+ modelValue?: string | string[] | null;
11
20
 
12
21
  /**
13
- * Accordion description.
22
+ * Accordion options.
14
23
  */
15
- description?: string;
24
+ options?: UAccordionOption[];
16
25
 
17
26
  /**
18
27
  * Accordion size.
@@ -20,9 +29,14 @@ export interface Props {
20
29
  size?: "sm" | "md" | "lg";
21
30
 
22
31
  /**
23
- * Accordion toggle icon.
32
+ * Allow multiple items to be opened at the same time.
33
+ */
34
+ multiple?: boolean;
35
+
36
+ /**
37
+ * Disable an accordion.
24
38
  */
25
- toggleIcon?: boolean | string;
39
+ disabled?: boolean;
26
40
 
27
41
  /**
28
42
  * Unique element id.
@@ -0,0 +1,158 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, inject, useId, useSlots, useTemplateRef, toValue, watchEffect } from "vue";
3
+
4
+ import { isEqual } from "lodash-es";
5
+
6
+ import useUI from "../composables/useUI";
7
+ import { getDefaults } from "../utils/ui";
8
+ import { hasSlotContent } from "../utils/helper";
9
+
10
+ import UIcon from "../ui.image-icon/UIcon.vue";
11
+
12
+ import { COMPONENT_NAME } from "./constants";
13
+ import defaultConfig from "./config";
14
+
15
+ import type { Props, Config } from "./types";
16
+ import type { SetAccordionSelectedItem } from "../ui.container-accordion/types";
17
+
18
+ defineOptions({ inheritAttrs: false });
19
+
20
+ const setAccordionSelectedItem = inject<SetAccordionSelectedItem | null>(
21
+ "setAccordionSelectedItem",
22
+ null,
23
+ );
24
+ const getAccordionSize = inject("getAccordionSize", null);
25
+ const getAccordionDisabled = inject("getAccordionDisabled", null);
26
+ const getAccordionSelectedItem = inject<(() => string | string[] | null) | null>(
27
+ "getAccordionSelectedItem",
28
+ null,
29
+ );
30
+
31
+ const props = withDefaults(defineProps<Props>(), {
32
+ ...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
33
+ });
34
+
35
+ const emit = defineEmits([
36
+ /**
37
+ * Triggers when the accordion item is toggled.
38
+ * @property {string} elementId
39
+ * @property {boolean} isOpened
40
+ */
41
+ "click",
42
+ ]);
43
+
44
+ const wrapperRef = useTemplateRef<HTMLDivElement>("wrapper");
45
+ const descriptionRef = useTemplateRef<HTMLDivElement>("description");
46
+ const contentRef = useTemplateRef<HTMLDivElement>("content");
47
+
48
+ const accordionSize = ref(toValue(getAccordionSize) || props.size);
49
+ const accordionDisabled = ref(toValue(getAccordionDisabled) || props.disabled);
50
+
51
+ watchEffect(() => (accordionSize.value = toValue(getAccordionSize) || props.size));
52
+ watchEffect(() => (accordionDisabled.value = toValue(getAccordionDisabled) || props.disabled));
53
+
54
+ const slots = useSlots();
55
+ const elementId = props.id || useId();
56
+
57
+ const internalOpened = ref(false);
58
+
59
+ const isOpened = computed(() => {
60
+ const selectedItem = toValue(getAccordionSelectedItem);
61
+
62
+ if (selectedItem !== null) {
63
+ if (Array.isArray(selectedItem)) {
64
+ return selectedItem.includes(props.value ?? "");
65
+ }
66
+
67
+ return isEqual(selectedItem, props.value);
68
+ }
69
+
70
+ return props.opened || internalOpened.value;
71
+ });
72
+
73
+ const toggleIconName = computed(() => {
74
+ if (typeof props.toggleIcon === "string") {
75
+ return props.toggleIcon;
76
+ }
77
+
78
+ return props.toggleIcon ? config.value.defaults.toggleIcon : "";
79
+ });
80
+
81
+ function onClickItem(event: MouseEvent) {
82
+ const clickedInsideContent = contentRef.value?.contains(event.target as Node);
83
+ const clickedDescription = descriptionRef.value?.contains(event.target as Node);
84
+
85
+ if (props.disabled || clickedDescription || clickedInsideContent) return;
86
+
87
+ emit("click", elementId, !isOpened.value);
88
+
89
+ if (setAccordionSelectedItem) {
90
+ setAccordionSelectedItem(props.value ?? "", !isOpened.value);
91
+
92
+ return;
93
+ }
94
+
95
+ internalOpened.value = !internalOpened.value;
96
+ }
97
+
98
+ defineExpose({
99
+ /**
100
+ * A reference to the component's wrapper element for direct DOM manipulation.
101
+ * @property {HTMLDivElement}
102
+ */
103
+ wrapperRef,
104
+ });
105
+
106
+ const mutatedProps = computed(() => ({
107
+ /* component state, not a props */
108
+ opened: isOpened.value,
109
+ }));
110
+
111
+ const {
112
+ getDataTest,
113
+ config,
114
+ wrapperAttrs,
115
+ descriptionAttrs,
116
+ bodyAttrs,
117
+ titleAttrs,
118
+ contentAttrs,
119
+ toggleIconAttrs,
120
+ } = useUI<Config>(defaultConfig, mutatedProps);
121
+ </script>
122
+
123
+ <template>
124
+ <div ref="wrapper" v-bind="wrapperAttrs" :data-test="getDataTest()" @click="onClickItem">
125
+ <div v-bind="bodyAttrs">
126
+ <div v-bind="titleAttrs">
127
+ {{ title }}
128
+ <!--
129
+ @slot Use it to add something instead of the toggle icon.
130
+ @binding {string} icon-name
131
+ @binding {boolean} opened
132
+ -->
133
+ <slot name="toggle" :icon-name="toggleIconName" :opened="isOpened">
134
+ <UIcon
135
+ v-if="toggleIconName"
136
+ :name="toggleIconName"
137
+ :size="size"
138
+ color="neutral"
139
+ v-bind="toggleIconAttrs"
140
+ />
141
+ </slot>
142
+ </div>
143
+
144
+ <div
145
+ v-if="description"
146
+ :id="`description-${elementId}`"
147
+ ref="description"
148
+ v-bind="descriptionAttrs"
149
+ v-text="description"
150
+ />
151
+
152
+ <div v-if="isOpened && hasSlotContent(slots['default'])" ref="content" v-bind="contentAttrs">
153
+ <!-- @slot Use it to add accordion content. -->
154
+ <slot />
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </template>
@@ -0,0 +1,52 @@
1
+ export default /*tw*/ {
2
+ wrapper: {
3
+ base: "group cursor-pointer",
4
+ variants: {
5
+ disabled: {
6
+ true: "cursor-not-allowed text-default/(--vl-disabled-opacity)",
7
+ },
8
+ },
9
+ },
10
+ body: "",
11
+ title: {
12
+ base: "flex items-center justify-between font-medium",
13
+ variants: {
14
+ size: {
15
+ sm: "text-small",
16
+ md: "text-medium",
17
+ lg: "text-large",
18
+ },
19
+ },
20
+ },
21
+ description: {
22
+ base: "text-accented h-0 opacity-0 transition-all cursor-default",
23
+ variants: {
24
+ size: {
25
+ sm: "text-tiny",
26
+ md: "text-small",
27
+ lg: "text-medium",
28
+ },
29
+ opened: {
30
+ true: "pt-2 h-fit opacity-100",
31
+ },
32
+ disabled: {
33
+ true: "text-accented/(--vl-disabled-opacity)",
34
+ },
35
+ },
36
+ },
37
+ content: "pt-3 cursor-default",
38
+ toggleIcon: {
39
+ base: "{UIcon} transition duration-300",
40
+ variants: {
41
+ opened: {
42
+ true: "group-[*]:rotate-180",
43
+ },
44
+ },
45
+ },
46
+ defaults: {
47
+ size: "md",
48
+ disabled: false,
49
+ /* icons */
50
+ toggleIcon: "keyboard_arrow_down",
51
+ },
52
+ };
@@ -0,0 +1,5 @@
1
+ /*
2
+ This const is needed to prevent the issue in script setup:
3
+ `defineProps` is referencing locally declared variables. (vue/valid-define-props)
4
+ */
5
+ export const COMPONENT_NAME = "UAccordionItem";
@@ -0,0 +1,16 @@
1
+ import { Meta, Title, Subtitle, Description, Primary, Controls, Stories, Source } from "@storybook/addon-docs/blocks";
2
+ import { getSource } from "../../utils/storybook";
3
+
4
+ import * as stories from "./stories";
5
+ import defaultConfig from "../config?raw"
6
+
7
+ <Meta of={stories} />
8
+ <Title of={stories} />
9
+ <Subtitle of={stories} />
10
+ <Description of={stories} />
11
+ <Primary of={stories} />
12
+ <Controls of={stories.Default} />
13
+ <Stories of={stories} />
14
+
15
+ ## Default config
16
+ <Source code={getSource(defaultConfig)} language="jsx" dark />