birus-ui 0.0.0-stage → 0.3.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/README.md +232 -2
- package/dist/birus-ui.js +6919 -0
- package/dist/birus-ui.umd.cjs +10 -0
- package/dist/style.css +1 -0
- package/dist/tailwind-preset.js +70 -0
- package/dist/themes.css +217 -0
- package/package.json +41 -4
- package/src/components/BirusAccordion.vue +112 -0
- package/src/components/BirusAlert.vue +114 -0
- package/src/components/BirusAvatar.vue +67 -0
- package/src/components/BirusBadge.vue +64 -0
- package/src/components/BirusBreadcrumb.vue +61 -0
- package/src/components/BirusButton.vue +84 -0
- package/src/components/BirusCard.vue +89 -0
- package/src/components/BirusCheckbox.vue +106 -0
- package/src/components/BirusDialog.vue +127 -0
- package/src/components/BirusDrawer.vue +158 -0
- package/src/components/BirusDropdownMenu.vue +184 -0
- package/src/components/BirusEmptyState.vue +64 -0
- package/src/components/BirusHeading.vue +70 -0
- package/src/components/BirusInput.vue +197 -0
- package/src/components/BirusKbd.vue +43 -0
- package/src/components/BirusPagination.vue +132 -0
- package/src/components/BirusProgress.vue +70 -0
- package/src/components/BirusRadioGroup.vue +157 -0
- package/src/components/BirusSelect.vue +272 -0
- package/src/components/BirusSeparator.vue +72 -0
- package/src/components/BirusSkeleton.vue +46 -0
- package/src/components/BirusSlider.vue +96 -0
- package/src/components/BirusStatCard.vue +106 -0
- package/src/components/BirusStepper.vue +114 -0
- package/src/components/BirusSwitch.vue +76 -0
- package/src/components/BirusTable.vue +126 -0
- package/src/components/BirusTabs.vue +121 -0
- package/src/components/BirusText.vue +74 -0
- package/src/components/BirusTextarea.vue +84 -0
- package/src/components/BirusTimeline.vue +61 -0
- package/src/components/BirusToaster.vue +130 -0
- package/src/components/BirusTooltip.vue +63 -0
- package/src/composables/useBirusTheme.js +144 -0
- package/src/composables/useToast.js +63 -0
- package/src/index.js +116 -0
- package/src/lib-entry.js +3 -0
- package/src/style.css +5 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { sliderTrackVariants, sliderFillVariants, cn } from '@birus-ui/core';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
modelValue: { type: Number, default: 50 },
|
|
7
|
+
min: { type: Number, default: 0 },
|
|
8
|
+
max: { type: Number, default: 100 },
|
|
9
|
+
step: { type: Number, default: 1 },
|
|
10
|
+
size: { type: String, default: 'md' },
|
|
11
|
+
variant: { type: String, default: 'default' },
|
|
12
|
+
label: { type: String, default: '' },
|
|
13
|
+
showValue: { type: Boolean, default: true },
|
|
14
|
+
valueSuffix: { type: String, default: '' },
|
|
15
|
+
disabled: { type: Boolean, default: false },
|
|
16
|
+
class: { type: String, default: '' },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits(['update:modelValue', 'change']);
|
|
20
|
+
|
|
21
|
+
const percentage = computed(() => {
|
|
22
|
+
const range = props.max - props.min;
|
|
23
|
+
if (range <= 0) return 0;
|
|
24
|
+
const clamped = Math.min(props.max, Math.max(props.min, props.modelValue));
|
|
25
|
+
return ((clamped - props.min) / range) * 100;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const thumbSizeClass = computed(() => {
|
|
29
|
+
switch (props.size) {
|
|
30
|
+
case 'sm':
|
|
31
|
+
return 'h-4 w-4';
|
|
32
|
+
case 'lg':
|
|
33
|
+
return 'h-6 w-6';
|
|
34
|
+
default:
|
|
35
|
+
return 'h-5 w-5';
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function onInput(event) {
|
|
40
|
+
const val = Number(event.target.value);
|
|
41
|
+
emit('update:modelValue', val);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function onChange(event) {
|
|
45
|
+
emit('change', Number(event.target.value));
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div :class="cn('w-full space-y-2', disabled && 'opacity-50', props.class)">
|
|
51
|
+
<div v-if="label || showValue" class="flex items-center justify-between gap-2">
|
|
52
|
+
<label v-if="label" class="text-sm font-medium text-fg leading-none">
|
|
53
|
+
{{ label }}
|
|
54
|
+
</label>
|
|
55
|
+
<span
|
|
56
|
+
v-if="showValue"
|
|
57
|
+
class="inline-flex items-center rounded-birus-sm border border-border bg-surface px-2 py-0.5 text-xs font-mono font-semibold text-fg tabular-nums ml-auto"
|
|
58
|
+
>
|
|
59
|
+
{{ modelValue }}{{ valueSuffix }}
|
|
60
|
+
</span>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="relative flex items-center py-2">
|
|
64
|
+
<!-- Trilha visual -->
|
|
65
|
+
<div :class="sliderTrackVariants({ size })">
|
|
66
|
+
<div
|
|
67
|
+
:class="sliderFillVariants({ variant })"
|
|
68
|
+
:style="{ width: `${percentage}%` }"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<!-- Thumb visual posicionado -->
|
|
73
|
+
<span
|
|
74
|
+
:class="[
|
|
75
|
+
'pointer-events-none absolute -translate-x-1/2 rounded-full border-2 border-primary bg-card shadow-md transition-transform duration-75',
|
|
76
|
+
thumbSizeClass,
|
|
77
|
+
]"
|
|
78
|
+
:style="{ left: `${percentage}%` }"
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
<!-- Input range nativo invisível para acessibilidade total (teclado + touch + mouse) -->
|
|
82
|
+
<input
|
|
83
|
+
type="range"
|
|
84
|
+
:value="modelValue"
|
|
85
|
+
:min="min"
|
|
86
|
+
:max="max"
|
|
87
|
+
:step="step"
|
|
88
|
+
:disabled="disabled"
|
|
89
|
+
:aria-label="label || 'Controle deslizante'"
|
|
90
|
+
class="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
91
|
+
@input="onInput"
|
|
92
|
+
@change="onChange"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { cn, statCardVariants, statTrendVariants } from '@birus-ui/core';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
label: {
|
|
7
|
+
type: String,
|
|
8
|
+
required: true,
|
|
9
|
+
},
|
|
10
|
+
value: {
|
|
11
|
+
type: [String, Number],
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
trend: {
|
|
15
|
+
type: String,
|
|
16
|
+
default: '',
|
|
17
|
+
},
|
|
18
|
+
trendDirection: {
|
|
19
|
+
type: String,
|
|
20
|
+
default: 'neutral',
|
|
21
|
+
},
|
|
22
|
+
caption: {
|
|
23
|
+
type: String,
|
|
24
|
+
default: '',
|
|
25
|
+
},
|
|
26
|
+
progress: {
|
|
27
|
+
type: Number,
|
|
28
|
+
default: null,
|
|
29
|
+
},
|
|
30
|
+
variant: {
|
|
31
|
+
type: String,
|
|
32
|
+
default: 'default',
|
|
33
|
+
},
|
|
34
|
+
class: {
|
|
35
|
+
type: String,
|
|
36
|
+
default: '',
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const cardClasses = computed(() =>
|
|
41
|
+
cn(statCardVariants({ variant: props.variant }), props.class)
|
|
42
|
+
);
|
|
43
|
+
const trendClasses = computed(() =>
|
|
44
|
+
cn(statTrendVariants({ direction: props.trendDirection }))
|
|
45
|
+
);
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div :class="cardClasses">
|
|
50
|
+
<div class="flex items-start justify-between gap-3">
|
|
51
|
+
<div class="space-y-1">
|
|
52
|
+
<p class="text-xs font-medium uppercase tracking-wider text-birus-muted-fg">
|
|
53
|
+
{{ label }}
|
|
54
|
+
</p>
|
|
55
|
+
<div class="flex items-baseline gap-2.5">
|
|
56
|
+
<span class="text-2xl sm:text-3xl font-bold tracking-tight text-birus-fg">
|
|
57
|
+
{{ value }}
|
|
58
|
+
</span>
|
|
59
|
+
<span v-if="trend" :class="trendClasses">
|
|
60
|
+
<svg
|
|
61
|
+
v-if="trendDirection === 'up'"
|
|
62
|
+
class="h-3 w-3"
|
|
63
|
+
fill="none"
|
|
64
|
+
viewBox="0 0 24 24"
|
|
65
|
+
stroke="currentColor"
|
|
66
|
+
stroke-width="2.5"
|
|
67
|
+
>
|
|
68
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
|
69
|
+
</svg>
|
|
70
|
+
<svg
|
|
71
|
+
v-else-if="trendDirection === 'down'"
|
|
72
|
+
class="h-3 w-3"
|
|
73
|
+
fill="none"
|
|
74
|
+
viewBox="0 0 24 24"
|
|
75
|
+
stroke="currentColor"
|
|
76
|
+
stroke-width="2.5"
|
|
77
|
+
>
|
|
78
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
|
79
|
+
</svg>
|
|
80
|
+
{{ trend }}
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div
|
|
86
|
+
v-if="$slots.icon"
|
|
87
|
+
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-birus bg-birus-primary/10 text-birus-primary"
|
|
88
|
+
>
|
|
89
|
+
<slot name="icon" />
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div v-if="progress !== null && progress !== undefined" class="mt-4">
|
|
94
|
+
<div class="h-1.5 w-full overflow-hidden rounded-full bg-birus-muted">
|
|
95
|
+
<div
|
|
96
|
+
class="h-full rounded-full bg-birus-primary transition-all duration-300"
|
|
97
|
+
:style="{ width: `${Math.min(100, Math.max(0, progress))}%` }"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<p v-if="caption || $slots.footer" class="mt-3 text-xs text-birus-muted-fg">
|
|
103
|
+
<slot name="footer">{{ caption }}</slot>
|
|
104
|
+
</p>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { cn } from '@birus-ui/core';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
steps: {
|
|
6
|
+
type: Array,
|
|
7
|
+
default: () => [],
|
|
8
|
+
},
|
|
9
|
+
modelValue: {
|
|
10
|
+
type: Number,
|
|
11
|
+
default: 0,
|
|
12
|
+
},
|
|
13
|
+
orientation: {
|
|
14
|
+
type: String,
|
|
15
|
+
default: 'horizontal',
|
|
16
|
+
},
|
|
17
|
+
clickable: {
|
|
18
|
+
type: Boolean,
|
|
19
|
+
default: true,
|
|
20
|
+
},
|
|
21
|
+
class: {
|
|
22
|
+
type: String,
|
|
23
|
+
default: '',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits(['update:modelValue', 'change']);
|
|
28
|
+
|
|
29
|
+
function selectStep(index) {
|
|
30
|
+
if (!props.clickable) return;
|
|
31
|
+
emit('update:modelValue', index);
|
|
32
|
+
emit('change', index);
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<nav
|
|
38
|
+
aria-label="Progresso de etapas"
|
|
39
|
+
:class="
|
|
40
|
+
cn(
|
|
41
|
+
orientation === 'vertical'
|
|
42
|
+
? 'flex flex-col space-y-4'
|
|
43
|
+
: 'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 w-full',
|
|
44
|
+
props.class
|
|
45
|
+
)
|
|
46
|
+
"
|
|
47
|
+
>
|
|
48
|
+
<div
|
|
49
|
+
v-for="(step, idx) in steps"
|
|
50
|
+
:key="step.id || idx"
|
|
51
|
+
:class="
|
|
52
|
+
cn(
|
|
53
|
+
'flex items-center gap-3',
|
|
54
|
+
orientation === 'horizontal' && idx < steps.length - 1 ? 'flex-1 w-full' : ''
|
|
55
|
+
)
|
|
56
|
+
"
|
|
57
|
+
>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
:disabled="!clickable"
|
|
61
|
+
:aria-current="idx === modelValue ? 'step' : undefined"
|
|
62
|
+
class="group flex items-center gap-3 text-left focus:outline-none disabled:cursor-default"
|
|
63
|
+
@click="selectStep(idx)"
|
|
64
|
+
>
|
|
65
|
+
<span
|
|
66
|
+
:class="
|
|
67
|
+
cn(
|
|
68
|
+
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-all border',
|
|
69
|
+
idx < modelValue
|
|
70
|
+
? 'bg-birus-primary text-birus-primary-fg border-birus-primary shadow-sm'
|
|
71
|
+
: idx === modelValue
|
|
72
|
+
? 'bg-birus-primary/15 text-birus-primary border-birus-primary ring-4 ring-birus-primary/15'
|
|
73
|
+
: 'bg-birus-surface text-birus-muted-fg border-birus-border group-hover:border-birus-primary/40'
|
|
74
|
+
)
|
|
75
|
+
"
|
|
76
|
+
>
|
|
77
|
+
<svg
|
|
78
|
+
v-if="idx < modelValue"
|
|
79
|
+
class="h-4 w-4"
|
|
80
|
+
fill="none"
|
|
81
|
+
viewBox="0 0 24 24"
|
|
82
|
+
stroke="currentColor"
|
|
83
|
+
stroke-width="2.5"
|
|
84
|
+
>
|
|
85
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
86
|
+
</svg>
|
|
87
|
+
<span v-else>{{ idx + 1 }}</span>
|
|
88
|
+
</span>
|
|
89
|
+
|
|
90
|
+
<div class="min-w-0">
|
|
91
|
+
<p
|
|
92
|
+
:class="
|
|
93
|
+
cn(
|
|
94
|
+
'text-xs sm:text-sm font-semibold transition-colors',
|
|
95
|
+
idx <= modelValue ? 'text-birus-fg' : 'text-birus-muted-fg'
|
|
96
|
+
)
|
|
97
|
+
"
|
|
98
|
+
>
|
|
99
|
+
{{ step.title }}
|
|
100
|
+
</p>
|
|
101
|
+
<p v-if="step.description" class="text-[11px] text-birus-muted-fg truncate max-w-[180px]">
|
|
102
|
+
{{ step.description }}
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
</button>
|
|
106
|
+
|
|
107
|
+
<div
|
|
108
|
+
v-if="orientation === 'horizontal' && idx < steps.length - 1"
|
|
109
|
+
class="hidden sm:block flex-1 h-[2px] mx-2 rounded-full transition-colors"
|
|
110
|
+
:class="idx < modelValue ? 'bg-birus-primary' : 'bg-birus-border'"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</nav>
|
|
114
|
+
</template>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { switchTrackVariants, switchThumbVariants, cn } from '@birus-ui/core';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
modelValue: { type: Boolean, default: false },
|
|
7
|
+
size: { type: String, default: 'md' },
|
|
8
|
+
label: { type: String, default: '' },
|
|
9
|
+
description: { type: String, default: '' },
|
|
10
|
+
disabled: { type: Boolean, default: false },
|
|
11
|
+
id: { type: String, default: undefined },
|
|
12
|
+
class: { type: String, default: '' },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits(['update:modelValue']);
|
|
16
|
+
|
|
17
|
+
const uid = props.id || `birus-switch-${Math.random().toString(36).slice(2, 9)}`;
|
|
18
|
+
|
|
19
|
+
const trackClasses = computed(() =>
|
|
20
|
+
cn(
|
|
21
|
+
switchTrackVariants({
|
|
22
|
+
size: props.size,
|
|
23
|
+
checked: props.modelValue,
|
|
24
|
+
}),
|
|
25
|
+
props.class,
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const thumbClasses = computed(() =>
|
|
30
|
+
switchThumbVariants({
|
|
31
|
+
size: props.size,
|
|
32
|
+
checked: props.modelValue,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
function toggle() {
|
|
37
|
+
if (props.disabled) return;
|
|
38
|
+
emit('update:modelValue', !props.modelValue);
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div class="inline-flex items-start gap-3">
|
|
44
|
+
<button
|
|
45
|
+
:id="uid"
|
|
46
|
+
type="button"
|
|
47
|
+
role="switch"
|
|
48
|
+
:aria-checked="modelValue ? 'true' : 'false'"
|
|
49
|
+
:aria-labelledby="label ? `${uid}-label` : undefined"
|
|
50
|
+
:disabled="disabled"
|
|
51
|
+
:class="trackClasses"
|
|
52
|
+
@click="toggle"
|
|
53
|
+
@keydown.enter.prevent="toggle"
|
|
54
|
+
>
|
|
55
|
+
<span :class="thumbClasses" />
|
|
56
|
+
</button>
|
|
57
|
+
|
|
58
|
+
<div v-if="label || description || $slots.default" class="flex flex-col gap-0.5">
|
|
59
|
+
<label
|
|
60
|
+
v-if="label || $slots.default"
|
|
61
|
+
:id="`${uid}-label`"
|
|
62
|
+
:for="uid"
|
|
63
|
+
:class="[
|
|
64
|
+
'text-sm font-medium leading-snug text-fg select-none',
|
|
65
|
+
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
66
|
+
]"
|
|
67
|
+
@click.prevent="toggle"
|
|
68
|
+
>
|
|
69
|
+
<slot>{{ label }}</slot>
|
|
70
|
+
</label>
|
|
71
|
+
<p v-if="description" class="text-xs text-muted-fg leading-relaxed">
|
|
72
|
+
{{ description }}
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref } from 'vue';
|
|
3
|
+
import { tableCellVariants, cn } from '@birus-ui/core';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
columns: { type: Array, default: () => [] }, // [{ key, label, sortable, align }]
|
|
7
|
+
rows: { type: Array, default: () => [] },
|
|
8
|
+
striped: { type: Boolean, default: false },
|
|
9
|
+
hoverable: { type: Boolean, default: true },
|
|
10
|
+
density: { type: String, default: 'default' }, // 'compact' | 'default' | 'relaxed'
|
|
11
|
+
emptyText: { type: String, default: 'Nenhum registro encontrado.' },
|
|
12
|
+
caption: { type: String, default: '' },
|
|
13
|
+
class: { type: String, default: '' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits(['row-click', 'sort']);
|
|
17
|
+
|
|
18
|
+
const sortKey = ref('');
|
|
19
|
+
const sortOrder = ref('asc'); // 'asc' | 'desc'
|
|
20
|
+
|
|
21
|
+
const sortedRows = computed(() => {
|
|
22
|
+
if (!sortKey.value) return props.rows;
|
|
23
|
+
const key = sortKey.value;
|
|
24
|
+
const dir = sortOrder.value === 'asc' ? 1 : -1;
|
|
25
|
+
|
|
26
|
+
return [...props.rows].sort((a, b) => {
|
|
27
|
+
const valA = a[key];
|
|
28
|
+
const valB = b[key];
|
|
29
|
+
if (valA === valB) return 0;
|
|
30
|
+
if (typeof valA === 'number' && typeof valB === 'number') {
|
|
31
|
+
return (valA - valB) * dir;
|
|
32
|
+
}
|
|
33
|
+
return String(valA ?? '').localeCompare(String(valB ?? '')) * dir;
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function toggleSort(col) {
|
|
38
|
+
if (!col.sortable) return;
|
|
39
|
+
if (sortKey.value === col.key) {
|
|
40
|
+
if (sortOrder.value === 'asc') {
|
|
41
|
+
sortOrder.value = 'desc';
|
|
42
|
+
} else {
|
|
43
|
+
sortKey.value = '';
|
|
44
|
+
sortOrder.value = 'asc';
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
sortKey.value = col.key;
|
|
48
|
+
sortOrder.value = 'asc';
|
|
49
|
+
}
|
|
50
|
+
emit('sort', { key: sortKey.value, order: sortOrder.value });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getAlignClass(align) {
|
|
54
|
+
if (align === 'right') return 'text-right';
|
|
55
|
+
if (align === 'center') return 'text-center';
|
|
56
|
+
return 'text-left';
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<div :class="cn('w-full overflow-x-auto rounded-birus-lg border border-border bg-card', props.class)">
|
|
62
|
+
<table class="w-full border-collapse text-sm">
|
|
63
|
+
<caption
|
|
64
|
+
v-if="caption"
|
|
65
|
+
class="border-b border-border bg-surface/50 px-4 py-2.5 text-left text-xs text-muted-fg"
|
|
66
|
+
>
|
|
67
|
+
{{ caption }}
|
|
68
|
+
</caption>
|
|
69
|
+
|
|
70
|
+
<thead class="border-b border-border bg-surface text-xs uppercase tracking-wider text-muted-fg">
|
|
71
|
+
<tr>
|
|
72
|
+
<th
|
|
73
|
+
v-for="col in columns"
|
|
74
|
+
:key="col.key"
|
|
75
|
+
:class="[
|
|
76
|
+
tableCellVariants({ density }),
|
|
77
|
+
getAlignClass(col.align),
|
|
78
|
+
'font-semibold select-none',
|
|
79
|
+
col.sortable && 'cursor-pointer hover:text-fg transition-colors',
|
|
80
|
+
]"
|
|
81
|
+
@click="toggleSort(col)"
|
|
82
|
+
>
|
|
83
|
+
<span class="inline-flex items-center gap-1.5">
|
|
84
|
+
<span>{{ col.label }}</span>
|
|
85
|
+
<span v-if="col.sortable" class="text-[10px]">
|
|
86
|
+
<template v-if="sortKey === col.key">
|
|
87
|
+
{{ sortOrder === 'asc' ? '▲' : '▼' }}
|
|
88
|
+
</template>
|
|
89
|
+
<span v-else class="opacity-35">⇅</span>
|
|
90
|
+
</span>
|
|
91
|
+
</span>
|
|
92
|
+
</th>
|
|
93
|
+
</tr>
|
|
94
|
+
</thead>
|
|
95
|
+
|
|
96
|
+
<tbody class="divide-y divide-border">
|
|
97
|
+
<tr
|
|
98
|
+
v-for="(row, rIdx) in sortedRows"
|
|
99
|
+
:key="row.id || rIdx"
|
|
100
|
+
:class="[
|
|
101
|
+
'transition-colors',
|
|
102
|
+
striped && rIdx % 2 === 1 && 'bg-surface/40',
|
|
103
|
+
hoverable && 'hover:bg-surface-hover/70',
|
|
104
|
+
]"
|
|
105
|
+
@click="emit('row-click', row)"
|
|
106
|
+
>
|
|
107
|
+
<td
|
|
108
|
+
v-for="col in columns"
|
|
109
|
+
:key="col.key"
|
|
110
|
+
:class="[tableCellVariants({ density }), getAlignClass(col.align), 'text-fg']"
|
|
111
|
+
>
|
|
112
|
+
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
|
|
113
|
+
{{ row[col.key] }}
|
|
114
|
+
</slot>
|
|
115
|
+
</td>
|
|
116
|
+
</tr>
|
|
117
|
+
|
|
118
|
+
<tr v-if="sortedRows.length === 0">
|
|
119
|
+
<td :colspan="Math.max(1, columns.length)" class="px-4 py-8 text-center text-sm text-muted-fg">
|
|
120
|
+
{{ emptyText }}
|
|
121
|
+
</td>
|
|
122
|
+
</tr>
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref, watch } from 'vue';
|
|
3
|
+
import { tabsListVariants, tabsTriggerVariants, cn } from '@birus-ui/core';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
modelValue: { type: String, default: '' },
|
|
7
|
+
defaultValue: { type: String, default: '' },
|
|
8
|
+
tabs: { type: Array, default: () => [] },
|
|
9
|
+
variant: { type: String, default: 'segmented' },
|
|
10
|
+
fullWidth: { type: Boolean, default: false },
|
|
11
|
+
class: { type: String, default: '' },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits(['update:modelValue', 'change']);
|
|
15
|
+
|
|
16
|
+
const triggerRefs = ref([]);
|
|
17
|
+
const internalValue = ref(
|
|
18
|
+
props.modelValue || props.defaultValue || props.tabs[0]?.value || ''
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
watch(
|
|
22
|
+
() => props.modelValue,
|
|
23
|
+
(newVal) => {
|
|
24
|
+
if (newVal !== undefined && newVal !== '') {
|
|
25
|
+
internalValue.value = newVal;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const activeValue = computed(() => {
|
|
31
|
+
if (internalValue.value && props.tabs.some((t) => t.value === internalValue.value)) {
|
|
32
|
+
return internalValue.value;
|
|
33
|
+
}
|
|
34
|
+
return props.tabs[0]?.value ?? '';
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const listClasses = computed(() =>
|
|
38
|
+
cn(
|
|
39
|
+
tabsListVariants({ variant: props.variant, fullWidth: props.fullWidth }),
|
|
40
|
+
props.class,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function selectTab(tab) {
|
|
45
|
+
if (tab.disabled) return;
|
|
46
|
+
internalValue.value = tab.value;
|
|
47
|
+
emit('update:modelValue', tab.value);
|
|
48
|
+
emit('change', tab.value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handleKeydown(event, index) {
|
|
52
|
+
const enabledIndexes = props.tabs
|
|
53
|
+
.map((t, i) => (t.disabled ? -1 : i))
|
|
54
|
+
.filter((i) => i !== -1);
|
|
55
|
+
|
|
56
|
+
if (enabledIndexes.length === 0) return;
|
|
57
|
+
|
|
58
|
+
const currentPos = enabledIndexes.indexOf(index);
|
|
59
|
+
let targetPos = currentPos;
|
|
60
|
+
|
|
61
|
+
if (event.key === 'ArrowRight') {
|
|
62
|
+
targetPos = (currentPos + 1) % enabledIndexes.length;
|
|
63
|
+
} else if (event.key === 'ArrowLeft') {
|
|
64
|
+
targetPos = (currentPos - 1 + enabledIndexes.length) % enabledIndexes.length;
|
|
65
|
+
} else if (event.key === 'Home') {
|
|
66
|
+
targetPos = 0;
|
|
67
|
+
} else if (event.key === 'End') {
|
|
68
|
+
targetPos = enabledIndexes.length - 1;
|
|
69
|
+
} else {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
const nextIndex = enabledIndexes[targetPos];
|
|
75
|
+
const nextTab = props.tabs[nextIndex];
|
|
76
|
+
if (nextTab) {
|
|
77
|
+
selectTab(nextTab);
|
|
78
|
+
triggerRefs.value[nextIndex]?.focus();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<div class="w-full">
|
|
85
|
+
<div role="tablist" :class="listClasses">
|
|
86
|
+
<button
|
|
87
|
+
v-for="(tab, index) in tabs"
|
|
88
|
+
:key="tab.value"
|
|
89
|
+
:ref="(el) => { if (el) triggerRefs[index] = el; }"
|
|
90
|
+
type="button"
|
|
91
|
+
role="tab"
|
|
92
|
+
:aria-selected="activeValue === tab.value ? 'true' : 'false'"
|
|
93
|
+
:tabindex="activeValue === tab.value ? 0 : -1"
|
|
94
|
+
:disabled="tab.disabled"
|
|
95
|
+
:class="tabsTriggerVariants({ variant, active: activeValue === tab.value, fullWidth })"
|
|
96
|
+
@click="selectTab(tab)"
|
|
97
|
+
@keydown="handleKeydown($event, index)"
|
|
98
|
+
>
|
|
99
|
+
<slot :name="`icon-${tab.value}`" />
|
|
100
|
+
<span>{{ tab.label }}</span>
|
|
101
|
+
<span
|
|
102
|
+
v-if="tab.badge !== undefined"
|
|
103
|
+
:class="[
|
|
104
|
+
'inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none',
|
|
105
|
+
activeValue === tab.value
|
|
106
|
+
? 'bg-primary/15 text-primary'
|
|
107
|
+
: 'bg-surface-hover text-muted-fg',
|
|
108
|
+
]"
|
|
109
|
+
>
|
|
110
|
+
{{ tab.badge }}
|
|
111
|
+
</span>
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div v-if="$slots.default || $slots[`panel-${activeValue}`]" class="mt-4" role="tabpanel">
|
|
116
|
+
<slot :name="`panel-${activeValue}`" :active-tab="activeValue">
|
|
117
|
+
<slot :active-tab="activeValue" />
|
|
118
|
+
</slot>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</template>
|