@weni/unnnic-system 3.27.1 → 3.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +142 -30
- package/dist/style.css +1 -1
- package/dist/unnnic.mjs +7872 -7805
- package/dist/unnnic.umd.js +31 -31
- package/package.json +1 -1
- package/src/components/Select/__tests__/Select.spec.js +139 -0
- package/src/components/Select/__tests__/__snapshots__/Select.spec.js.snap +3 -3
- package/src/components/Select/index.vue +148 -3
- package/src/components/ui/popover/PopoverContent.vue +9 -31
- package/src/components/ui/popover/PopoverFooter.vue +21 -6
- package/src/components/ui/popover/PopoverOption.vue +20 -10
- package/src/components/ui/popover/__tests__/PopoverFooter.spec.js +116 -0
- package/src/components/ui/popover/context.ts +4 -0
- package/src/stories/PopoverOption.stories.js +53 -0
- package/src/stories/Select.stories.js +119 -0
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mount } from '@vue/test-utils';
|
|
2
2
|
import { beforeEach, describe, expect, afterEach, test } from 'vitest';
|
|
3
|
+
import { h } from 'vue';
|
|
3
4
|
import UnnnicSelect from '../index.vue';
|
|
4
5
|
import i18n from '@/utils/plugins/i18n';
|
|
5
6
|
|
|
@@ -484,4 +485,142 @@ describe('UnnnicSelect.vue', () => {
|
|
|
484
485
|
expect(wrapper.html()).toMatchSnapshot();
|
|
485
486
|
});
|
|
486
487
|
});
|
|
488
|
+
|
|
489
|
+
describe('option slot', () => {
|
|
490
|
+
test('renders custom content for each option through the option slot', async () => {
|
|
491
|
+
const slotWrapper = mountWrapper(
|
|
492
|
+
{},
|
|
493
|
+
{
|
|
494
|
+
option: (slotProps) =>
|
|
495
|
+
h('span', { class: 'custom-option' }, `custom-${slotProps.label}`),
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
slotWrapper.vm.setOpenPopover(true);
|
|
500
|
+
await slotWrapper.vm.$nextTick();
|
|
501
|
+
|
|
502
|
+
const options = slotWrapper.findAllComponents({
|
|
503
|
+
name: 'UnnnicPopoverOption',
|
|
504
|
+
});
|
|
505
|
+
expect(options.length).toBe(3);
|
|
506
|
+
expect(options[0].find('.custom-option').exists()).toBe(true);
|
|
507
|
+
expect(options[0].find('.custom-option').text()).toBe('custom-Option 1');
|
|
508
|
+
|
|
509
|
+
slotWrapper.unmount();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('falls back to default label rendering without option slot', async () => {
|
|
513
|
+
wrapper.vm.setOpenPopover(true);
|
|
514
|
+
await wrapper.vm.$nextTick();
|
|
515
|
+
|
|
516
|
+
const options = wrapper.findAllComponents({
|
|
517
|
+
name: 'UnnnicPopoverOption',
|
|
518
|
+
});
|
|
519
|
+
expect(options[0].find('.custom-option').exists()).toBe(false);
|
|
520
|
+
expect(options[0].find('.unnnic-popover-option__label').exists()).toBe(
|
|
521
|
+
true,
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
describe('selected slot (custom trigger)', () => {
|
|
527
|
+
const selectedSlot = {
|
|
528
|
+
selected: (slotProps) =>
|
|
529
|
+
h('span', { class: 'custom-selected' }, `selected-${slotProps.label}`),
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
test('renders custom trigger when selected slot is used and an item is selected', () => {
|
|
533
|
+
const slotWrapper = mountWrapper({ modelValue: 'option1' }, selectedSlot);
|
|
534
|
+
|
|
535
|
+
expect(slotWrapper.find('.unnnic-select__trigger').exists()).toBe(true);
|
|
536
|
+
expect(slotWrapper.find('.custom-selected').text()).toBe(
|
|
537
|
+
'selected-Option 1',
|
|
538
|
+
);
|
|
539
|
+
expect(slotWrapper.findComponent({ name: 'UnnnicInput' }).exists()).toBe(
|
|
540
|
+
false,
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
slotWrapper.unmount();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('falls back to UnnnicInput when no item is selected', () => {
|
|
547
|
+
const slotWrapper = mountWrapper({ modelValue: null }, selectedSlot);
|
|
548
|
+
|
|
549
|
+
expect(slotWrapper.find('.unnnic-select__trigger').exists()).toBe(false);
|
|
550
|
+
expect(slotWrapper.findComponent({ name: 'UnnnicInput' }).exists()).toBe(
|
|
551
|
+
true,
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
slotWrapper.unmount();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('falls back to UnnnicInput when selected slot is not provided', () => {
|
|
558
|
+
const fallbackWrapper = mountWrapper({ modelValue: 'option1' });
|
|
559
|
+
|
|
560
|
+
expect(fallbackWrapper.find('.unnnic-select__trigger').exists()).toBe(
|
|
561
|
+
false,
|
|
562
|
+
);
|
|
563
|
+
expect(
|
|
564
|
+
fallbackWrapper.findComponent({ name: 'UnnnicInput' }).exists(),
|
|
565
|
+
).toBe(true);
|
|
566
|
+
|
|
567
|
+
fallbackWrapper.unmount();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('shows the field label above the custom trigger', () => {
|
|
571
|
+
const slotWrapper = mountWrapper(
|
|
572
|
+
{ modelValue: 'option1', label: 'Representative' },
|
|
573
|
+
selectedSlot,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const label = slotWrapper.find('.unnnic-select__trigger-label');
|
|
577
|
+
expect(label.exists()).toBe(true);
|
|
578
|
+
expect(label.text()).toBe('Representative');
|
|
579
|
+
|
|
580
|
+
slotWrapper.unmount();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test('reflects the popover state through the chevron icon', async () => {
|
|
584
|
+
const slotWrapper = mountWrapper({ modelValue: 'option1' }, selectedSlot);
|
|
585
|
+
|
|
586
|
+
const arrow = slotWrapper.find('.unnnic-select__trigger-arrow');
|
|
587
|
+
expect(arrow.exists()).toBe(true);
|
|
588
|
+
|
|
589
|
+
slotWrapper.vm.setOpenPopover(true);
|
|
590
|
+
await slotWrapper.vm.$nextTick();
|
|
591
|
+
expect(slotWrapper.vm.openPopover).toBe(true);
|
|
592
|
+
|
|
593
|
+
slotWrapper.unmount();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('emits update:modelValue with empty value when clear is clicked', async () => {
|
|
597
|
+
const slotWrapper = mountWrapper(
|
|
598
|
+
{ modelValue: 'option1', clearable: true },
|
|
599
|
+
selectedSlot,
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const clear = slotWrapper.find('.unnnic-select__trigger-clear');
|
|
603
|
+
expect(clear.exists()).toBe(true);
|
|
604
|
+
|
|
605
|
+
await clear.trigger('click');
|
|
606
|
+
|
|
607
|
+
expect(slotWrapper.emitted('update:modelValue')).toBeTruthy();
|
|
608
|
+
expect(slotWrapper.emitted('update:modelValue')[0]).toEqual(['']);
|
|
609
|
+
|
|
610
|
+
slotWrapper.unmount();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test('does not render clear icon when clearable is false', () => {
|
|
614
|
+
const slotWrapper = mountWrapper(
|
|
615
|
+
{ modelValue: 'option1', clearable: false },
|
|
616
|
+
selectedSlot,
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
expect(slotWrapper.find('.unnnic-select__trigger-clear').exists()).toBe(
|
|
620
|
+
false,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
slotWrapper.unmount();
|
|
624
|
+
});
|
|
625
|
+
});
|
|
487
626
|
});
|
|
@@ -19,7 +19,7 @@ exports[`UnnnicSelect.vue > snapshot testing > matches snapshot with default pro
|
|
|
19
19
|
`;
|
|
20
20
|
|
|
21
21
|
exports[`UnnnicSelect.vue > snapshot testing > matches snapshot with disabled state 1`] = `
|
|
22
|
-
"<div data-v-6077efb7="" class="unnnic-select"><button data-v-9d52eef8="" data-v-6077efb7="" class="unnnic-popover-trigger w-full" id="reka-popover-trigger-v-0" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="" data-state="closed">
|
|
22
|
+
"<div data-v-6077efb7="" class="unnnic-select"><button data-v-9d52eef8="" data-v-6077efb7="" class="unnnic-popover-trigger w-full" id="reka-popover-trigger-v-0" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="reka-popover-content-v-1" data-state="closed">
|
|
23
23
|
<section data-v-9f8d6c86="" data-v-d890ad85="" data-v-6077efb7="" class="unnnic-form-element unnnic-form-element--disabled unnnic-form md unnnic-select__input" data-testid="form-element">
|
|
24
24
|
<!--v-if-->
|
|
25
25
|
<div data-v-a0d36167="" data-v-d890ad85="" class="text-input size--md unnnic-select__input unnnic-form-input" hascloudycolor="false" mask=""><input data-v-86533b41="" data-v-a0d36167="" class="unnnic-select__input unnnic-form-input input-itself input size-md normal input--has-icon-right use-focus-prop unnnic-select__input unnnic-form-input input-itself" hascloudycolor="false" placeholder="" iconleft="" iconright="keyboard_arrow_down" iconleftclickable="false" iconrightclickable="false" showclear="false" type="text" readonly="" value="" disabled="">
|
|
@@ -55,7 +55,7 @@ exports[`UnnnicSelect.vue > snapshot testing > matches snapshot with infinite sc
|
|
|
55
55
|
`;
|
|
56
56
|
|
|
57
57
|
exports[`UnnnicSelect.vue > snapshot testing > matches snapshot with search enabled 1`] = `
|
|
58
|
-
"<div data-v-6077efb7="" class="unnnic-select"><button data-v-9d52eef8="" data-v-6077efb7="" class="unnnic-popover-trigger w-full" id="reka-popover-trigger-v-0" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="" data-state="closed">
|
|
58
|
+
"<div data-v-6077efb7="" class="unnnic-select"><button data-v-9d52eef8="" data-v-6077efb7="" class="unnnic-popover-trigger w-full" id="reka-popover-trigger-v-0" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="reka-popover-content-v-1" data-state="closed">
|
|
59
59
|
<section data-v-9f8d6c86="" data-v-d890ad85="" data-v-6077efb7="" class="unnnic-form-element unnnic-form md unnnic-select__input" data-testid="form-element">
|
|
60
60
|
<!--v-if-->
|
|
61
61
|
<div data-v-a0d36167="" data-v-d890ad85="" class="text-input size--md unnnic-select__input unnnic-form-input" hascloudycolor="false" mask=""><input data-v-86533b41="" data-v-a0d36167="" class="unnnic-select__input unnnic-form-input input-itself input size-md normal input--has-icon-right use-focus-prop unnnic-select__input unnnic-form-input input-itself" hascloudycolor="false" placeholder="" iconleft="" iconright="keyboard_arrow_down" iconleftclickable="false" iconrightclickable="false" showclear="false" type="text" readonly="" value="">
|
|
@@ -73,7 +73,7 @@ exports[`UnnnicSelect.vue > snapshot testing > matches snapshot with search enab
|
|
|
73
73
|
`;
|
|
74
74
|
|
|
75
75
|
exports[`UnnnicSelect.vue > snapshot testing > matches snapshot with selected value 1`] = `
|
|
76
|
-
"<div data-v-6077efb7="" class="unnnic-select"><button data-v-9d52eef8="" data-v-6077efb7="" class="unnnic-popover-trigger w-full" id="reka-popover-trigger-v-0" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="" data-state="closed">
|
|
76
|
+
"<div data-v-6077efb7="" class="unnnic-select"><button data-v-9d52eef8="" data-v-6077efb7="" class="unnnic-popover-trigger w-full" id="reka-popover-trigger-v-0" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="reka-popover-content-v-1" data-state="closed">
|
|
77
77
|
<section data-v-9f8d6c86="" data-v-d890ad85="" data-v-6077efb7="" class="unnnic-form-element unnnic-form md unnnic-select__input" data-testid="form-element">
|
|
78
78
|
<!--v-if-->
|
|
79
79
|
<div data-v-a0d36167="" data-v-d890ad85="" class="text-input size--md unnnic-select__input unnnic-form-input" hascloudycolor="false" mask=""><input data-v-86533b41="" data-v-a0d36167="" class="unnnic-select__input unnnic-form-input input-itself input size-md normal input--has-icon-right use-focus-prop unnnic-select__input unnnic-form-input input-itself" hascloudycolor="false" placeholder="" iconleft="" iconright="keyboard_arrow_down" iconleftclickable="false" iconrightclickable="false" showclear="false" type="text" readonly="" value="Option 1">
|
|
@@ -5,7 +5,53 @@
|
|
|
5
5
|
@update:open="setOpenPopover"
|
|
6
6
|
>
|
|
7
7
|
<PopoverTrigger class="w-full">
|
|
8
|
+
<section
|
|
9
|
+
v-if="hasSelectedSlot && selectedItem"
|
|
10
|
+
ref="selectInputRef"
|
|
11
|
+
class="unnnic-select__trigger-wrapper"
|
|
12
|
+
>
|
|
13
|
+
<span
|
|
14
|
+
v-if="props.label"
|
|
15
|
+
class="unnnic-select__trigger-label"
|
|
16
|
+
>
|
|
17
|
+
{{ props.label }}
|
|
18
|
+
</span>
|
|
19
|
+
<section
|
|
20
|
+
:class="[
|
|
21
|
+
'unnnic-select__trigger',
|
|
22
|
+
`unnnic-select__trigger--${props.size}`,
|
|
23
|
+
{
|
|
24
|
+
'unnnic-select__trigger--focused': openPopover,
|
|
25
|
+
'unnnic-select__trigger--disabled': props.disabled,
|
|
26
|
+
},
|
|
27
|
+
]"
|
|
28
|
+
>
|
|
29
|
+
<section class="unnnic-select__trigger-content">
|
|
30
|
+
<slot
|
|
31
|
+
name="selected"
|
|
32
|
+
:option="selectedItem"
|
|
33
|
+
:label="inputValue as string"
|
|
34
|
+
/>
|
|
35
|
+
</section>
|
|
36
|
+
<UnnnicIcon
|
|
37
|
+
v-if="props.clearable && !props.disabled"
|
|
38
|
+
class="unnnic-select__trigger-clear"
|
|
39
|
+
icon="close"
|
|
40
|
+
size="ant"
|
|
41
|
+
scheme="fg-base"
|
|
42
|
+
clickable
|
|
43
|
+
@click.stop="emit('update:modelValue', '')"
|
|
44
|
+
/>
|
|
45
|
+
<UnnnicIcon
|
|
46
|
+
class="unnnic-select__trigger-arrow"
|
|
47
|
+
:icon="openPopover ? 'keyboard_arrow_up' : 'keyboard_arrow_down'"
|
|
48
|
+
size="ant"
|
|
49
|
+
scheme="fg-base"
|
|
50
|
+
/>
|
|
51
|
+
</section>
|
|
52
|
+
</section>
|
|
8
53
|
<UnnnicInput
|
|
54
|
+
v-else
|
|
9
55
|
ref="selectInputRef"
|
|
10
56
|
:modelValue="inputValue as string"
|
|
11
57
|
class="unnnic-select__input"
|
|
@@ -60,7 +106,23 @@
|
|
|
60
106
|
:focused="focusedOptionIndex === index"
|
|
61
107
|
:disabled="option.disabled"
|
|
62
108
|
@click="handleSelectOption(option)"
|
|
63
|
-
|
|
109
|
+
>
|
|
110
|
+
<template
|
|
111
|
+
v-if="$slots.option"
|
|
112
|
+
#default
|
|
113
|
+
>
|
|
114
|
+
<slot
|
|
115
|
+
name="option"
|
|
116
|
+
:option="option"
|
|
117
|
+
:label="option[props.itemLabel] as string"
|
|
118
|
+
:active="
|
|
119
|
+
option[props.itemValue] === selectedItem?.[props.itemValue]
|
|
120
|
+
"
|
|
121
|
+
:focused="focusedOptionIndex === index"
|
|
122
|
+
:index="index"
|
|
123
|
+
/>
|
|
124
|
+
</template>
|
|
125
|
+
</PopoverOption>
|
|
64
126
|
<div
|
|
65
127
|
v-if="props.infiniteScroll && infiniteScrollLoading"
|
|
66
128
|
class="unnnic-select__infinite-loading"
|
|
@@ -85,11 +147,13 @@ import {
|
|
|
85
147
|
nextTick,
|
|
86
148
|
onBeforeUnmount,
|
|
87
149
|
useTemplateRef,
|
|
150
|
+
useSlots,
|
|
88
151
|
} from 'vue';
|
|
89
152
|
|
|
90
153
|
import { useInfiniteScroll } from '@vueuse/core';
|
|
91
154
|
|
|
92
155
|
import UnnnicInput from '../Input/Input.vue';
|
|
156
|
+
import UnnnicIcon from '../Icon.vue';
|
|
93
157
|
import UnnnicIconLoading from '../IconLoading/IconLoading.vue';
|
|
94
158
|
import {
|
|
95
159
|
Popover,
|
|
@@ -141,6 +205,20 @@ const emit = defineEmits<{
|
|
|
141
205
|
'scroll-end': [];
|
|
142
206
|
}>();
|
|
143
207
|
|
|
208
|
+
defineSlots<{
|
|
209
|
+
option?: (props: {
|
|
210
|
+
option: SelectOption;
|
|
211
|
+
label: string;
|
|
212
|
+
active: boolean;
|
|
213
|
+
focused: boolean;
|
|
214
|
+
index: number;
|
|
215
|
+
}) => unknown;
|
|
216
|
+
selected?: (props: { option: SelectOption; label: string }) => unknown;
|
|
217
|
+
}>();
|
|
218
|
+
|
|
219
|
+
const slots = useSlots();
|
|
220
|
+
const hasSelectedSlot = computed(() => !!slots.selected);
|
|
221
|
+
|
|
144
222
|
const selectInputRef = useTemplateRef<HTMLElement>('selectInputRef');
|
|
145
223
|
const contentRef = useTemplateRef<HTMLDivElement>('contentRef');
|
|
146
224
|
|
|
@@ -155,8 +233,11 @@ function setOpenPopover(value: boolean) {
|
|
|
155
233
|
base.openPopover.value = value;
|
|
156
234
|
}
|
|
157
235
|
|
|
158
|
-
const selectedItem = computed(() => {
|
|
159
|
-
if (props.returnObject)
|
|
236
|
+
const selectedItem = computed((): SelectOption | undefined => {
|
|
237
|
+
if (props.returnObject) {
|
|
238
|
+
if (props.modelValue == null || props.modelValue === '') return undefined;
|
|
239
|
+
return props.modelValue as SelectOption;
|
|
240
|
+
}
|
|
160
241
|
|
|
161
242
|
return props.options.find(
|
|
162
243
|
(option) => option[props.itemValue] === props.modelValue,
|
|
@@ -289,6 +370,70 @@ defineExpose({
|
|
|
289
370
|
}
|
|
290
371
|
|
|
291
372
|
.unnnic-select {
|
|
373
|
+
&__trigger-wrapper {
|
|
374
|
+
display: flex;
|
|
375
|
+
flex-direction: column;
|
|
376
|
+
gap: $unnnic-space-1;
|
|
377
|
+
width: 100%;
|
|
378
|
+
text-align: left;
|
|
379
|
+
font: $unnnic-font-body;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
&__trigger-label {
|
|
383
|
+
font: $unnnic-font-body;
|
|
384
|
+
color: $unnnic-color-fg-base;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
&__trigger {
|
|
388
|
+
cursor: pointer;
|
|
389
|
+
display: flex;
|
|
390
|
+
align-items: center;
|
|
391
|
+
gap: $unnnic-space-2;
|
|
392
|
+
width: 100%;
|
|
393
|
+
max-width: 100%;
|
|
394
|
+
overflow: hidden;
|
|
395
|
+
box-sizing: border-box;
|
|
396
|
+
|
|
397
|
+
background: $unnnic-color-bg-base;
|
|
398
|
+
border: 1px solid $unnnic-color-border-base;
|
|
399
|
+
border-radius: $unnnic-radius-2;
|
|
400
|
+
padding: $unnnic-space-3 $unnnic-space-4;
|
|
401
|
+
height: 45px;
|
|
402
|
+
|
|
403
|
+
transition: border-color 0.1s ease-in-out;
|
|
404
|
+
|
|
405
|
+
&--sm {
|
|
406
|
+
padding: $unnnic-space-2 $unnnic-space-4;
|
|
407
|
+
height: 37px;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
&--focused {
|
|
411
|
+
border-color: $unnnic-color-border-accent-strong;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
&--disabled {
|
|
415
|
+
cursor: not-allowed;
|
|
416
|
+
border-color: $unnnic-color-border-muted;
|
|
417
|
+
background-color: $unnnic-color-bg-muted;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
&-content {
|
|
421
|
+
flex: 1 1 0;
|
|
422
|
+
min-width: 0;
|
|
423
|
+
display: flex;
|
|
424
|
+
align-items: center;
|
|
425
|
+
gap: $unnnic-space-2;
|
|
426
|
+
overflow: hidden;
|
|
427
|
+
font: $unnnic-font-body;
|
|
428
|
+
color: $unnnic-color-fg-emphasized;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
&-clear,
|
|
432
|
+
&-arrow {
|
|
433
|
+
flex-shrink: 0;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
292
437
|
&__content {
|
|
293
438
|
display: flex;
|
|
294
439
|
flex-direction: column;
|
|
@@ -11,17 +11,12 @@
|
|
|
11
11
|
"
|
|
12
12
|
>
|
|
13
13
|
<section :class="`unnnic-popover__content ${props.class || ''}`">
|
|
14
|
-
<
|
|
15
|
-
:is="child"
|
|
16
|
-
v-for="(child, index) in contentChildren"
|
|
17
|
-
:key="index"
|
|
18
|
-
/>
|
|
14
|
+
<slot />
|
|
19
15
|
</section>
|
|
20
16
|
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
:key="index"
|
|
17
|
+
<div
|
|
18
|
+
ref="footerContainer"
|
|
19
|
+
data-testid="popover-footer-container"
|
|
25
20
|
/>
|
|
26
21
|
</PopoverContent>
|
|
27
22
|
</PopoverPortal>
|
|
@@ -29,13 +24,14 @@
|
|
|
29
24
|
|
|
30
25
|
<script setup lang="ts">
|
|
31
26
|
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui';
|
|
32
|
-
import type { HTMLAttributes
|
|
33
|
-
import { computed,
|
|
27
|
+
import type { HTMLAttributes } from 'vue';
|
|
28
|
+
import { computed, provide, ref } from 'vue';
|
|
34
29
|
import { reactiveOmit } from '@vueuse/core';
|
|
35
30
|
import { PopoverContent, PopoverPortal, useForwardPropsEmits } from 'reka-ui';
|
|
36
31
|
import { cn } from '@/lib/utils';
|
|
37
32
|
import { useLayerZIndex } from '@/lib/layer-manager';
|
|
38
33
|
import { useTeleportTarget } from '@/lib/teleport-target';
|
|
34
|
+
import { POPOVER_FOOTER_TARGET } from './context';
|
|
39
35
|
|
|
40
36
|
defineOptions({
|
|
41
37
|
inheritAttrs: false,
|
|
@@ -63,29 +59,11 @@ const delegatedProps = reactiveOmit(props, 'class', 'size');
|
|
|
63
59
|
|
|
64
60
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|
65
61
|
|
|
66
|
-
const slots = useSlots() as Slots;
|
|
67
|
-
|
|
68
62
|
const popoverZIndex = useLayerZIndex();
|
|
69
63
|
const portalTarget = useTeleportTarget();
|
|
70
64
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
return componentType?.name || componentType?.__name;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const contentChildren = computed(() => {
|
|
77
|
-
const defaultSlot = slots.default?.() || [];
|
|
78
|
-
return defaultSlot.filter(
|
|
79
|
-
(vnode: VNode) => getComponentName(vnode) !== 'UnnnicPopoverFooter',
|
|
80
|
-
);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const footerChildren = computed(() => {
|
|
84
|
-
const defaultSlot = slots.default?.() || [];
|
|
85
|
-
return defaultSlot.filter(
|
|
86
|
-
(vnode: VNode) => getComponentName(vnode) === 'UnnnicPopoverFooter',
|
|
87
|
-
);
|
|
88
|
-
});
|
|
65
|
+
const footerContainer = ref<HTMLElement | null>(null);
|
|
66
|
+
provide(POPOVER_FOOTER_TARGET, footerContainer);
|
|
89
67
|
|
|
90
68
|
const contentWidth = computed(() => {
|
|
91
69
|
if (props.width) return props.width;
|
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<Teleport
|
|
3
|
+
v-if="target"
|
|
4
|
+
:to="target"
|
|
5
|
+
>
|
|
6
|
+
<footer class="unnnic-popover__footer">
|
|
7
|
+
<slot />
|
|
8
|
+
</footer>
|
|
9
|
+
</Teleport>
|
|
10
|
+
|
|
11
|
+
<footer
|
|
12
|
+
v-else
|
|
13
|
+
class="unnnic-popover__footer"
|
|
14
|
+
>
|
|
3
15
|
<slot />
|
|
4
16
|
</footer>
|
|
5
17
|
</template>
|
|
6
18
|
|
|
7
19
|
<script setup lang="ts">
|
|
20
|
+
import { inject } from 'vue';
|
|
21
|
+
import { POPOVER_FOOTER_TARGET } from './context';
|
|
22
|
+
|
|
8
23
|
defineOptions({
|
|
9
24
|
name: 'UnnnicPopoverFooter',
|
|
10
25
|
});
|
|
26
|
+
|
|
27
|
+
// When rendered inside a PopoverContent, teleport into its footer container.
|
|
28
|
+
// Falls back to inline rendering when used standalone (no target provided).
|
|
29
|
+
const target = inject(POPOVER_FOOTER_TARGET, null);
|
|
11
30
|
</script>
|
|
12
31
|
|
|
13
32
|
<style lang="scss">
|
|
@@ -21,12 +40,8 @@ $popover-space: $unnnic-space-4;
|
|
|
21
40
|
padding: $popover-space;
|
|
22
41
|
|
|
23
42
|
display: flex;
|
|
24
|
-
justify-content:
|
|
43
|
+
justify-content: flex-end;
|
|
25
44
|
align-items: center;
|
|
26
45
|
gap: $unnnic-space-2;
|
|
27
|
-
|
|
28
|
-
> * {
|
|
29
|
-
width: 100%;
|
|
30
|
-
}
|
|
31
46
|
}
|
|
32
47
|
</style>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
'unnnic-popover-option--disabled': props.disabled,
|
|
7
7
|
'unnnic-popover-option--active': props.active,
|
|
8
8
|
'unnnic-popover-option--focused': props.focused,
|
|
9
|
+
'unnnic-popover-option--with-content': hasDefaultSlot,
|
|
9
10
|
},
|
|
10
11
|
]"
|
|
11
12
|
>
|
|
@@ -15,15 +16,17 @@
|
|
|
15
16
|
:scheme="schemeColor"
|
|
16
17
|
size="ant"
|
|
17
18
|
/>
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
<slot>
|
|
20
|
+
<p
|
|
21
|
+
:class="[
|
|
22
|
+
'unnnic-popover-option__label',
|
|
23
|
+
`unnnic-popover-option__label--${schemeColor}`,
|
|
24
|
+
`unnnic-popover-option--disabled: ${props.disabled}`,
|
|
25
|
+
]"
|
|
26
|
+
>
|
|
27
|
+
{{ props.label }}
|
|
28
|
+
</p>
|
|
29
|
+
</slot>
|
|
27
30
|
</div>
|
|
28
31
|
</template>
|
|
29
32
|
|
|
@@ -31,7 +34,7 @@
|
|
|
31
34
|
import UnnnicIcon from '@/components/Icon.vue';
|
|
32
35
|
|
|
33
36
|
import type { SchemeColor } from '@/types/scheme-colors';
|
|
34
|
-
import { computed } from 'vue';
|
|
37
|
+
import { computed, useSlots } from 'vue';
|
|
35
38
|
|
|
36
39
|
defineOptions({
|
|
37
40
|
name: 'UnnnicPopoverOption',
|
|
@@ -54,6 +57,9 @@ const props = withDefaults(defineProps<PopoverOptionProps>(), {
|
|
|
54
57
|
icon: '',
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
const slots = useSlots();
|
|
61
|
+
const hasDefaultSlot = computed(() => !!slots.default);
|
|
62
|
+
|
|
57
63
|
const schemeColor = computed(() => {
|
|
58
64
|
if (props.active) {
|
|
59
65
|
return 'fg-on-primary';
|
|
@@ -82,6 +88,10 @@ const schemeColor = computed(() => {
|
|
|
82
88
|
gap: $unnnic-space-2;
|
|
83
89
|
align-items: center;
|
|
84
90
|
|
|
91
|
+
&--with-content {
|
|
92
|
+
justify-content: space-between;
|
|
93
|
+
}
|
|
94
|
+
|
|
85
95
|
&:hover:not(&--active):not(&--disabled),
|
|
86
96
|
&--focused {
|
|
87
97
|
background-color: $unnnic-color-bg-soft;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mount, flushPromises } from '@vue/test-utils';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import PopoverContent from '../PopoverContent.vue';
|
|
4
|
+
import PopoverFooter from '../PopoverFooter.vue';
|
|
5
|
+
|
|
6
|
+
// Render reka-ui's portal/content inline so the component's own markup
|
|
7
|
+
// (`.unnnic-popover__content` + footer container) is testable. The native
|
|
8
|
+
// Teleport is kept real so we can assert the footer actually moves.
|
|
9
|
+
const inlineSlot = { template: '<div><slot /></div>' };
|
|
10
|
+
|
|
11
|
+
const globalStubs = {
|
|
12
|
+
PopoverPortal: inlineSlot,
|
|
13
|
+
PopoverContent: inlineSlot,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// A child component that renders the footer from its own template, simulating
|
|
17
|
+
// a footer nested inside a consumer component rather than a direct slot child.
|
|
18
|
+
const NestedFooterChild = {
|
|
19
|
+
components: { PopoverFooter },
|
|
20
|
+
template: `
|
|
21
|
+
<section data-testid="nested-wrapper">
|
|
22
|
+
<PopoverFooter>
|
|
23
|
+
<button data-testid="nested-footer-btn">Save</button>
|
|
24
|
+
</PopoverFooter>
|
|
25
|
+
</section>
|
|
26
|
+
`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const mountPopover = (defaultSlot) =>
|
|
30
|
+
mount(PopoverContent, {
|
|
31
|
+
attachTo: document.body,
|
|
32
|
+
slots: { default: defaultSlot },
|
|
33
|
+
global: {
|
|
34
|
+
stubs: globalStubs,
|
|
35
|
+
components: { PopoverFooter, NestedFooterChild },
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const content = (wrapper) => wrapper.find('.unnnic-popover__content');
|
|
40
|
+
const footerContainer = (wrapper) =>
|
|
41
|
+
wrapper.find('[data-testid="popover-footer-container"]');
|
|
42
|
+
|
|
43
|
+
describe('UnnnicPopoverFooter', () => {
|
|
44
|
+
it('renders a direct footer child inside the footer container, outside the content', async () => {
|
|
45
|
+
const wrapper = mountPopover(
|
|
46
|
+
`<p data-testid="body">Body</p>
|
|
47
|
+
<PopoverFooter><button data-testid="footer-btn">Save</button></PopoverFooter>`,
|
|
48
|
+
);
|
|
49
|
+
await flushPromises();
|
|
50
|
+
|
|
51
|
+
expect(
|
|
52
|
+
footerContainer(wrapper).find('.unnnic-popover__footer').exists(),
|
|
53
|
+
).toBe(true);
|
|
54
|
+
expect(content(wrapper).find('.unnnic-popover__footer').exists()).toBe(
|
|
55
|
+
false,
|
|
56
|
+
);
|
|
57
|
+
expect(wrapper.find('[data-testid="footer-btn"]').exists()).toBe(true);
|
|
58
|
+
|
|
59
|
+
wrapper.unmount();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('renders a footer nested inside a child component in the footer container', async () => {
|
|
63
|
+
const wrapper = mountPopover('<NestedFooterChild />');
|
|
64
|
+
await flushPromises();
|
|
65
|
+
|
|
66
|
+
expect(
|
|
67
|
+
footerContainer(wrapper).find('.unnnic-popover__footer').exists(),
|
|
68
|
+
).toBe(true);
|
|
69
|
+
expect(
|
|
70
|
+
footerContainer(wrapper)
|
|
71
|
+
.find('[data-testid="nested-footer-btn"]')
|
|
72
|
+
.exists(),
|
|
73
|
+
).toBe(true);
|
|
74
|
+
expect(content(wrapper).find('.unnnic-popover__footer').exists()).toBe(
|
|
75
|
+
false,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
wrapper.unmount();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders no footer area when no footer is provided', async () => {
|
|
82
|
+
const wrapper = mountPopover('<p data-testid="body">Body</p>');
|
|
83
|
+
await flushPromises();
|
|
84
|
+
|
|
85
|
+
expect(wrapper.find('.unnnic-popover__footer').exists()).toBe(false);
|
|
86
|
+
expect(footerContainer(wrapper).element.children.length).toBe(0);
|
|
87
|
+
|
|
88
|
+
wrapper.unmount();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('teleports every footer into the container when multiple are provided', async () => {
|
|
92
|
+
const wrapper = mountPopover(
|
|
93
|
+
`<PopoverFooter><span>First</span></PopoverFooter>
|
|
94
|
+
<PopoverFooter><span>Second</span></PopoverFooter>`,
|
|
95
|
+
);
|
|
96
|
+
await flushPromises();
|
|
97
|
+
|
|
98
|
+
const footers = footerContainer(wrapper).findAll('.unnnic-popover__footer');
|
|
99
|
+
expect(footers).toHaveLength(2);
|
|
100
|
+
expect(content(wrapper).find('.unnnic-popover__footer').exists()).toBe(
|
|
101
|
+
false,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
wrapper.unmount();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('renders inline as a fallback when used without a PopoverContent target', () => {
|
|
108
|
+
const wrapper = mount(PopoverFooter, {
|
|
109
|
+
slots: { default: '<button data-testid="standalone">Save</button>' },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const footer = wrapper.find('.unnnic-popover__footer');
|
|
113
|
+
expect(footer.exists()).toBe(true);
|
|
114
|
+
expect(footer.find('[data-testid="standalone"]').exists()).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|