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.
Files changed (44) hide show
  1. package/README.md +232 -2
  2. package/dist/birus-ui.js +6919 -0
  3. package/dist/birus-ui.umd.cjs +10 -0
  4. package/dist/style.css +1 -0
  5. package/dist/tailwind-preset.js +70 -0
  6. package/dist/themes.css +217 -0
  7. package/package.json +41 -4
  8. package/src/components/BirusAccordion.vue +112 -0
  9. package/src/components/BirusAlert.vue +114 -0
  10. package/src/components/BirusAvatar.vue +67 -0
  11. package/src/components/BirusBadge.vue +64 -0
  12. package/src/components/BirusBreadcrumb.vue +61 -0
  13. package/src/components/BirusButton.vue +84 -0
  14. package/src/components/BirusCard.vue +89 -0
  15. package/src/components/BirusCheckbox.vue +106 -0
  16. package/src/components/BirusDialog.vue +127 -0
  17. package/src/components/BirusDrawer.vue +158 -0
  18. package/src/components/BirusDropdownMenu.vue +184 -0
  19. package/src/components/BirusEmptyState.vue +64 -0
  20. package/src/components/BirusHeading.vue +70 -0
  21. package/src/components/BirusInput.vue +197 -0
  22. package/src/components/BirusKbd.vue +43 -0
  23. package/src/components/BirusPagination.vue +132 -0
  24. package/src/components/BirusProgress.vue +70 -0
  25. package/src/components/BirusRadioGroup.vue +157 -0
  26. package/src/components/BirusSelect.vue +272 -0
  27. package/src/components/BirusSeparator.vue +72 -0
  28. package/src/components/BirusSkeleton.vue +46 -0
  29. package/src/components/BirusSlider.vue +96 -0
  30. package/src/components/BirusStatCard.vue +106 -0
  31. package/src/components/BirusStepper.vue +114 -0
  32. package/src/components/BirusSwitch.vue +76 -0
  33. package/src/components/BirusTable.vue +126 -0
  34. package/src/components/BirusTabs.vue +121 -0
  35. package/src/components/BirusText.vue +74 -0
  36. package/src/components/BirusTextarea.vue +84 -0
  37. package/src/components/BirusTimeline.vue +61 -0
  38. package/src/components/BirusToaster.vue +130 -0
  39. package/src/components/BirusTooltip.vue +63 -0
  40. package/src/composables/useBirusTheme.js +144 -0
  41. package/src/composables/useToast.js +63 -0
  42. package/src/index.js +116 -0
  43. package/src/lib-entry.js +3 -0
  44. package/src/style.css +5 -0
@@ -0,0 +1,184 @@
1
+ <script setup>
2
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
3
+ import { dropdownContentVariants, dropdownItemVariants, cn } from '@birus-ui/core';
4
+ import BirusButton from './BirusButton.vue';
5
+
6
+ const props = defineProps({
7
+ items: { type: Array, default: () => [] },
8
+ align: { type: String, default: 'end' }, // 'start' | 'center' | 'end'
9
+ triggerLabel: { type: String, default: 'Opções' },
10
+ triggerVariant: { type: String, default: 'outline' },
11
+ triggerSize: { type: String, default: 'md' },
12
+ class: { type: String, default: '' },
13
+ });
14
+
15
+ const emit = defineEmits(['select']);
16
+
17
+ const rootRef = ref(null);
18
+ const isOpen = ref(false);
19
+ const highlightedIndex = ref(-1);
20
+
21
+ const contentClasses = computed(() =>
22
+ cn(dropdownContentVariants({ align: props.align }), props.class),
23
+ );
24
+
25
+ const actionableIndexes = computed(() =>
26
+ props.items
27
+ .map((item, idx) => (item.divider || item.header || item.disabled ? -1 : idx))
28
+ .filter((idx) => idx !== -1),
29
+ );
30
+
31
+ function openMenu() {
32
+ isOpen.value = true;
33
+ highlightedIndex.value = actionableIndexes.value[0] ?? -1;
34
+ }
35
+
36
+ function closeMenu() {
37
+ isOpen.value = false;
38
+ highlightedIndex.value = -1;
39
+ }
40
+
41
+ function toggleMenu() {
42
+ if (isOpen.value) {
43
+ closeMenu();
44
+ } else {
45
+ openMenu();
46
+ }
47
+ }
48
+
49
+ function selectItem(item) {
50
+ if (item.disabled || item.divider || item.header) return;
51
+ emit('select', item);
52
+ if (typeof item.onClick === 'function') {
53
+ item.onClick(item);
54
+ }
55
+ closeMenu();
56
+ }
57
+
58
+ function handleKeydown(event) {
59
+ if (!isOpen.value) {
60
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
61
+ event.preventDefault();
62
+ openMenu();
63
+ }
64
+ return;
65
+ }
66
+
67
+ if (event.key === 'Escape') {
68
+ event.preventDefault();
69
+ closeMenu();
70
+ return;
71
+ }
72
+
73
+ const list = actionableIndexes.value;
74
+ if (!list.length) return;
75
+ const currentPos = list.indexOf(highlightedIndex.value);
76
+
77
+ if (event.key === 'ArrowDown') {
78
+ event.preventDefault();
79
+ highlightedIndex.value = list[(currentPos + 1) % list.length];
80
+ } else if (event.key === 'ArrowUp') {
81
+ event.preventDefault();
82
+ highlightedIndex.value = list[(currentPos - 1 + list.length) % list.length];
83
+ } else if (event.key === 'Enter') {
84
+ event.preventDefault();
85
+ const item = props.items[highlightedIndex.value];
86
+ if (item) selectItem(item);
87
+ }
88
+ }
89
+
90
+ function handleClickOutside(event) {
91
+ if (rootRef.value && !rootRef.value.contains(event.target)) {
92
+ closeMenu();
93
+ }
94
+ }
95
+
96
+ onMounted(() => {
97
+ document.addEventListener('mousedown', handleClickOutside);
98
+ });
99
+
100
+ onBeforeUnmount(() => {
101
+ document.removeEventListener('mousedown', handleClickOutside);
102
+ });
103
+ </script>
104
+
105
+ <template>
106
+ <div ref="rootRef" class="relative inline-block text-left" @keydown="handleKeydown">
107
+ <slot name="trigger" :is-open="isOpen" :toggle="toggleMenu" :open="openMenu" :close="closeMenu">
108
+ <BirusButton
109
+ :variant="triggerVariant"
110
+ :size="triggerSize"
111
+ aria-haspopup="menu"
112
+ :aria-expanded="isOpen ? 'true' : 'false'"
113
+ @click="toggleMenu"
114
+ >
115
+ <span>{{ triggerLabel }}</span>
116
+ <template #suffix>
117
+ <svg
118
+ :class="['h-4 w-4 transition-transform duration-150', isOpen && 'rotate-180']"
119
+ viewBox="0 0 24 24"
120
+ fill="none"
121
+ stroke="currentColor"
122
+ stroke-width="2"
123
+ >
124
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
125
+ </svg>
126
+ </template>
127
+ </BirusButton>
128
+ </slot>
129
+
130
+ <Transition
131
+ enter-active-class="transition duration-150 ease-out"
132
+ enter-from-class="opacity-0 scale-95 -translate-y-1"
133
+ enter-to-class="opacity-100 scale-100 translate-y-0"
134
+ leave-active-class="transition duration-100 ease-in"
135
+ leave-from-class="opacity-100 scale-100"
136
+ leave-to-class="opacity-0 scale-95"
137
+ >
138
+ <div v-if="isOpen" role="menu" :class="contentClasses">
139
+ <template v-for="(item, index) in items" :key="item.id || index">
140
+ <div
141
+ v-if="item.divider"
142
+ class="my-1 h-px bg-border"
143
+ role="separator"
144
+ />
145
+ <div
146
+ v-else-if="item.header"
147
+ class="px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-fg"
148
+ >
149
+ {{ item.header }}
150
+ </div>
151
+ <button
152
+ v-else
153
+ type="button"
154
+ role="menuitem"
155
+ :disabled="item.disabled"
156
+ :class="[
157
+ dropdownItemVariants({
158
+ variant: item.variant || 'default',
159
+ disabled: Boolean(item.disabled),
160
+ }),
161
+ highlightedIndex === index && 'bg-surface-hover',
162
+ ]"
163
+ @mouseenter="highlightedIndex = index"
164
+ @click="selectItem(item)"
165
+ >
166
+ <div class="flex flex-col min-w-0">
167
+ <span class="font-medium truncate">{{ item.label }}</span>
168
+ <span v-if="item.description" class="text-xs text-muted-fg truncate">
169
+ {{ item.description }}
170
+ </span>
171
+ </div>
172
+ <kbd
173
+ v-if="item.shortcut"
174
+ class="ml-auto rounded border border-border bg-surface px-1.5 py-0.5 text-[10px] font-mono text-muted-fg shrink-0"
175
+ >
176
+ {{ item.shortcut }}
177
+ </kbd>
178
+ </button>
179
+ </template>
180
+ <slot :close="closeMenu" />
181
+ </div>
182
+ </Transition>
183
+ </div>
184
+ </template>
@@ -0,0 +1,64 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import { cn } from '@birus-ui/core';
4
+
5
+ const props = defineProps({
6
+ title: {
7
+ type: String,
8
+ default: 'Nenhum item encontrado',
9
+ },
10
+ description: {
11
+ type: String,
12
+ default: '',
13
+ },
14
+ variant: {
15
+ type: String,
16
+ default: 'default',
17
+ },
18
+ class: {
19
+ type: String,
20
+ default: '',
21
+ },
22
+ });
23
+
24
+ const containerClasses = computed(() =>
25
+ cn(
26
+ 'flex flex-col items-center justify-center text-center rounded-birus-lg transition-all',
27
+ props.variant === 'default' && 'border border-birus-border bg-birus-card/60 p-8 sm:p-10',
28
+ props.variant === 'dashed' && 'border-2 border-dashed border-birus-border bg-birus-surface/30 p-8 sm:p-10',
29
+ props.variant === 'compact' && 'border border-birus-border bg-birus-surface/20 p-5',
30
+ props.class
31
+ )
32
+ );
33
+ </script>
34
+
35
+ <template>
36
+ <div :class="containerClasses">
37
+ <div
38
+ class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-birus-primary/10 text-birus-primary ring-4 ring-birus-primary/5"
39
+ >
40
+ <slot name="icon">
41
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
42
+ <path
43
+ stroke-linecap="round"
44
+ stroke-linejoin="round"
45
+ d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
46
+ />
47
+ </svg>
48
+ </slot>
49
+ </div>
50
+
51
+ <h3 class="text-base font-semibold text-birus-fg">{{ title }}</h3>
52
+ <p v-if="description" class="mt-1.5 max-w-sm text-sm text-birus-muted-fg leading-relaxed">
53
+ {{ description }}
54
+ </p>
55
+
56
+ <div v-if="$slots.default" class="mt-4 w-full max-w-sm">
57
+ <slot />
58
+ </div>
59
+
60
+ <div v-if="$slots.actions" class="mt-5 flex flex-wrap items-center justify-center gap-2.5">
61
+ <slot name="actions" />
62
+ </div>
63
+ </div>
64
+ </template>
@@ -0,0 +1,70 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import { cn, headingVariants } from '@birus-ui/core';
4
+
5
+ const props = defineProps({
6
+ as: {
7
+ type: String,
8
+ default: 'h2',
9
+ },
10
+ size: {
11
+ type: String,
12
+ default: 'xl',
13
+ },
14
+ weight: {
15
+ type: String,
16
+ default: 'bold',
17
+ },
18
+ tracking: {
19
+ type: String,
20
+ default: 'tight',
21
+ },
22
+ align: {
23
+ type: String,
24
+ default: 'left',
25
+ },
26
+ gradient: {
27
+ type: Boolean,
28
+ default: false,
29
+ },
30
+ balance: {
31
+ type: Boolean,
32
+ default: false,
33
+ },
34
+ accentLine: {
35
+ type: Boolean,
36
+ default: false,
37
+ },
38
+ class: {
39
+ type: String,
40
+ default: '',
41
+ },
42
+ });
43
+
44
+ const classes = computed(() =>
45
+ cn(
46
+ headingVariants({
47
+ size: props.size,
48
+ weight: props.weight,
49
+ tracking: props.tracking,
50
+ align: props.align,
51
+ gradient: props.gradient,
52
+ }),
53
+ props.balance && 'text-balance',
54
+ props.class
55
+ )
56
+ );
57
+ </script>
58
+
59
+ <template>
60
+ <div class="inline-flex flex-col gap-1.5" :class="align === 'center' ? 'items-center w-full' : align === 'right' ? 'items-end w-full' : 'items-start'">
61
+ <component :is="as" :class="classes">
62
+ <slot />
63
+ </component>
64
+ <span
65
+ v-if="accentLine"
66
+ class="h-1 w-12 rounded-full bg-gradient-to-r from-birus-primary to-birus-info"
67
+ aria-hidden="true"
68
+ />
69
+ </div>
70
+ </template>
@@ -0,0 +1,197 @@
1
+ <script setup>
2
+ import { computed, ref, useSlots } from 'vue';
3
+ import { inputVariants, cn } from '@birus-ui/core';
4
+
5
+ const props = defineProps({
6
+ modelValue: { type: [String, Number], default: '' },
7
+ type: { type: String, default: 'text' },
8
+ placeholder: { type: String, default: '' },
9
+ label: { type: String, default: '' },
10
+ hint: { type: String, default: '' },
11
+ errorMessage: { type: String, default: '' },
12
+ state: { type: String, default: 'default' },
13
+ size: { type: String, default: 'md' },
14
+ prefixText: { type: String, default: '' },
15
+ suffixText: { type: String, default: '' },
16
+ clearable: { type: Boolean, default: false },
17
+ maxlength: { type: Number, default: undefined },
18
+ disabled: { type: Boolean, default: false },
19
+ readonly: { type: Boolean, default: false },
20
+ required: { type: Boolean, default: false },
21
+ id: { type: String, default: undefined },
22
+ class: { type: String, default: '' },
23
+ });
24
+
25
+ const emit = defineEmits(['update:modelValue', 'clear', 'focus', 'blur']);
26
+
27
+ const slots = useSlots();
28
+ const showPassword = ref(false);
29
+ const uid = props.id || `birus-input-${Math.random().toString(36).slice(2, 9)}`;
30
+
31
+ const resolvedState = computed(() => (props.errorMessage ? 'error' : props.state));
32
+
33
+ const effectiveType = computed(() => {
34
+ if (props.type === 'password') {
35
+ return showPassword.value ? 'text' : 'password';
36
+ }
37
+ return props.type;
38
+ });
39
+
40
+ const stringLength = computed(() => String(props.modelValue ?? '').length);
41
+ const hasValue = computed(() => stringLength.value > 0);
42
+ const hasLeftSlot = computed(() => Boolean(slots.left));
43
+ const hasRightControls = computed(
44
+ () =>
45
+ Boolean(slots.right) ||
46
+ props.type === 'password' ||
47
+ (props.clearable && hasValue.value && !props.disabled),
48
+ );
49
+
50
+ const inputClasses = computed(() =>
51
+ cn(
52
+ inputVariants({
53
+ state: resolvedState.value,
54
+ size: props.size,
55
+ }),
56
+ hasLeftSlot.value && 'pl-9',
57
+ hasRightControls.value && 'pr-10',
58
+ props.prefixText && 'rounded-l-none',
59
+ props.suffixText && 'rounded-r-none',
60
+ props.class,
61
+ ),
62
+ );
63
+
64
+ function onInput(event) {
65
+ emit('update:modelValue', event.target.value);
66
+ }
67
+
68
+ function handleClear() {
69
+ emit('update:modelValue', '');
70
+ emit('clear');
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <div class="w-full space-y-1.5">
76
+ <div v-if="label || maxlength" class="flex items-center justify-between gap-2">
77
+ <label
78
+ v-if="label"
79
+ :for="uid"
80
+ class="text-sm font-medium text-fg leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
81
+ >
82
+ {{ label }}
83
+ <span v-if="required" class="text-destructive ml-0.5">*</span>
84
+ </label>
85
+ <span v-if="maxlength" class="text-xs text-muted-fg tabular-nums ml-auto">
86
+ {{ stringLength }}/{{ maxlength }}
87
+ </span>
88
+ </div>
89
+
90
+ <div class="flex items-stretch">
91
+ <span
92
+ v-if="prefixText"
93
+ class="inline-flex items-center rounded-l-birus border border-r-0 border-input bg-surface px-3 text-xs font-mono text-muted-fg select-none"
94
+ >
95
+ {{ prefixText }}
96
+ </span>
97
+
98
+ <div class="relative flex flex-1 items-center">
99
+ <span
100
+ v-if="hasLeftSlot"
101
+ class="pointer-events-none absolute left-3 flex items-center text-muted-fg"
102
+ >
103
+ <slot name="left" />
104
+ </span>
105
+
106
+ <input
107
+ :id="uid"
108
+ :type="effectiveType"
109
+ :value="modelValue"
110
+ :placeholder="placeholder"
111
+ :disabled="disabled"
112
+ :readonly="readonly"
113
+ :required="required"
114
+ :maxlength="maxlength"
115
+ :aria-invalid="resolvedState === 'error' ? 'true' : undefined"
116
+ :aria-describedby="errorMessage || hint ? `${uid}-desc` : undefined"
117
+ :class="inputClasses"
118
+ @input="onInput"
119
+ @focus="emit('focus', $event)"
120
+ @blur="emit('blur', $event)"
121
+ />
122
+
123
+ <div
124
+ v-if="hasRightControls"
125
+ class="absolute right-2.5 flex items-center gap-1 text-muted-fg"
126
+ >
127
+ <button
128
+ v-if="clearable && hasValue && !disabled && !readonly"
129
+ type="button"
130
+ class="inline-flex h-6 w-6 items-center justify-center rounded-birus-sm hover:bg-surface-hover hover:text-fg transition-colors"
131
+ aria-label="Limpar campo"
132
+ @click="handleClear"
133
+ >
134
+ <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
135
+ <path stroke-linecap="round" stroke-linejoin="round" d="M18 6L6 18M6 6l12 12" />
136
+ </svg>
137
+ </button>
138
+
139
+ <button
140
+ v-if="type === 'password'"
141
+ type="button"
142
+ class="inline-flex h-6 w-6 items-center justify-center rounded-birus-sm hover:bg-surface-hover hover:text-fg transition-colors"
143
+ :aria-label="showPassword ? 'Ocultar senha' : 'Mostrar senha'"
144
+ @click="showPassword = !showPassword"
145
+ >
146
+ <svg
147
+ v-if="showPassword"
148
+ class="h-4 w-4"
149
+ viewBox="0 0 24 24"
150
+ fill="none"
151
+ stroke="currentColor"
152
+ stroke-width="2"
153
+ >
154
+ <path
155
+ stroke-linecap="round"
156
+ stroke-linejoin="round"
157
+ d="M3 3l18 18M10.584 10.587a2 2 0 002.828 2.83M9.363 5.365A9.466 9.466 0 0112 5c4.478 0 8.268 2.943 9.543 7a9.97 9.97 0 01-1.563 3.029m-5.858.908a3 3 0 01-4.243-4.243M3.735 7.736A9.969 9.969 0 002.458 12c1.274 4.057 5.064 7 9.542 7a9.46 9.46 0 003.32-.6"
158
+ />
159
+ </svg>
160
+ <svg
161
+ v-else
162
+ class="h-4 w-4"
163
+ viewBox="0 0 24 24"
164
+ fill="none"
165
+ stroke="currentColor"
166
+ stroke-width="2"
167
+ >
168
+ <path
169
+ stroke-linecap="round"
170
+ stroke-linejoin="round"
171
+ d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
172
+ />
173
+ <circle cx="12" cy="12" r="3" />
174
+ </svg>
175
+ </button>
176
+
177
+ <slot name="right" />
178
+ </div>
179
+ </div>
180
+
181
+ <span
182
+ v-if="suffixText"
183
+ class="inline-flex items-center rounded-r-birus border border-l-0 border-input bg-surface px-3 text-xs font-mono text-muted-fg select-none"
184
+ >
185
+ {{ suffixText }}
186
+ </span>
187
+ </div>
188
+
189
+ <p
190
+ v-if="errorMessage || hint"
191
+ :id="`${uid}-desc`"
192
+ :class="['text-xs', errorMessage ? 'text-destructive font-medium' : 'text-muted-fg']"
193
+ >
194
+ {{ errorMessage || hint }}
195
+ </p>
196
+ </div>
197
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import { kbdVariants, cn } from '@birus-ui/core';
4
+
5
+ const props = defineProps({
6
+ keys: { type: [Array, String], default: () => [] },
7
+ size: { type: String, default: 'md' }, // 'sm' | 'md' | 'lg'
8
+ variant: { type: String, default: 'default' }, // 'default' | 'outline' | 'primary'
9
+ class: { type: String, default: '' },
10
+ });
11
+
12
+ const normalizedKeys = computed(() => {
13
+ if (Array.isArray(props.keys)) return props.keys;
14
+ if (typeof props.keys === 'string' && props.keys.trim()) {
15
+ return props.keys.split('+').map((k) => k.trim());
16
+ }
17
+ return [];
18
+ });
19
+
20
+ const keyClasses = computed(() =>
21
+ cn(kbdVariants({ size: props.size, variant: props.variant }), props.class),
22
+ );
23
+ </script>
24
+
25
+ <template>
26
+ <span class="inline-flex items-center gap-1 align-middle">
27
+ <template v-if="normalizedKeys.length > 0">
28
+ <template v-for="(k, idx) in normalizedKeys" :key="`${k}-${idx}`">
29
+ <kbd :class="keyClasses">{{ k }}</kbd>
30
+ <span
31
+ v-if="idx < normalizedKeys.length - 1"
32
+ class="text-[10px] text-muted-fg font-mono select-none"
33
+ aria-hidden="true"
34
+ >
35
+ +
36
+ </span>
37
+ </template>
38
+ </template>
39
+ <kbd v-else :class="keyClasses">
40
+ <slot />
41
+ </kbd>
42
+ </span>
43
+ </template>
@@ -0,0 +1,132 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import { paginationItemVariants, cn } from '@birus-ui/core';
4
+
5
+ const props = defineProps({
6
+ modelValue: { type: Number, default: 1 },
7
+ totalPages: { type: Number, default: 10 },
8
+ siblingCount: { type: Number, default: 1 },
9
+ variant: { type: String, default: 'default' }, // 'default' | 'outline' | 'pills'
10
+ size: { type: String, default: 'md' },
11
+ showEdges: { type: Boolean, default: true },
12
+ disabled: { type: Boolean, default: false },
13
+ class: { type: String, default: '' },
14
+ });
15
+
16
+ const emit = defineEmits(['update:modelValue', 'change']);
17
+
18
+ const pages = computed(() => {
19
+ const total = Math.max(1, props.totalPages);
20
+ const current = Math.min(total, Math.max(1, props.modelValue));
21
+ const siblings = props.siblingCount;
22
+
23
+ const totalNumbers = siblings * 2 + 5;
24
+ if (total <= totalNumbers) {
25
+ return Array.from({ length: total }, (_, i) => i + 1);
26
+ }
27
+
28
+ const leftSibling = Math.max(current - siblings, 1);
29
+ const rightSibling = Math.min(current + siblings, total);
30
+
31
+ const showLeftDots = leftSibling > 2;
32
+ const showRightDots = rightSibling < total - 1;
33
+
34
+ if (!showLeftDots && showRightDots) {
35
+ const leftCount = 3 + 2 * siblings;
36
+ const leftRange = Array.from({ length: leftCount }, (_, i) => i + 1);
37
+ return [...leftRange, '...', total];
38
+ }
39
+
40
+ if (showLeftDots && !showRightDots) {
41
+ const rightCount = 3 + 2 * siblings;
42
+ const rightRange = Array.from({ length: rightCount }, (_, i) => total - rightCount + i + 1);
43
+ return [1, '...', ...rightRange];
44
+ }
45
+
46
+ const middleRange = Array.from(
47
+ { length: rightSibling - leftSibling + 1 },
48
+ (_, i) => leftSibling + i,
49
+ );
50
+ return [1, '...', ...middleRange, '...', total];
51
+ });
52
+
53
+ function goToPage(page) {
54
+ if (props.disabled) return;
55
+ const clamped = Math.min(props.totalPages, Math.max(1, page));
56
+ if (clamped !== props.modelValue) {
57
+ emit('update:modelValue', clamped);
58
+ emit('change', clamped);
59
+ }
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <nav role="navigation" aria-label="Paginação" :class="cn('inline-flex items-center gap-1.5 flex-wrap', props.class)">
65
+ <!-- Primeira página -->
66
+ <button
67
+ v-if="showEdges"
68
+ type="button"
69
+ :disabled="disabled || modelValue <= 1"
70
+ :class="paginationItemVariants({ variant, size, active: false })"
71
+ aria-label="Primeira página"
72
+ @click="goToPage(1)"
73
+ >
74
+ «
75
+ </button>
76
+
77
+ <!-- Anterior -->
78
+ <button
79
+ type="button"
80
+ :disabled="disabled || modelValue <= 1"
81
+ :class="paginationItemVariants({ variant, size, active: false })"
82
+ aria-label="Página anterior"
83
+ @click="goToPage(modelValue - 1)"
84
+ >
85
+ ‹ Anterior
86
+ </button>
87
+
88
+ <!-- Números -->
89
+ <template v-for="(item, idx) in pages" :key="`${item}-${idx}`">
90
+ <span
91
+ v-if="item === '...'"
92
+ class="px-2 text-xs text-muted-fg select-none"
93
+ aria-hidden="true"
94
+ >
95
+ •••
96
+ </span>
97
+ <button
98
+ v-else
99
+ type="button"
100
+ :disabled="disabled"
101
+ :aria-current="modelValue === item ? 'page' : undefined"
102
+ :class="paginationItemVariants({ variant, size, active: modelValue === item })"
103
+ @click="goToPage(item)"
104
+ >
105
+ {{ item }}
106
+ </button>
107
+ </template>
108
+
109
+ <!-- Próxima -->
110
+ <button
111
+ type="button"
112
+ :disabled="disabled || modelValue >= totalPages"
113
+ :class="paginationItemVariants({ variant, size, active: false })"
114
+ aria-label="Próxima página"
115
+ @click="goToPage(modelValue + 1)"
116
+ >
117
+ Próxima ›
118
+ </button>
119
+
120
+ <!-- Última página -->
121
+ <button
122
+ v-if="showEdges"
123
+ type="button"
124
+ :disabled="disabled || modelValue >= totalPages"
125
+ :class="paginationItemVariants({ variant, size, active: false })"
126
+ aria-label="Última página"
127
+ @click="goToPage(totalPages)"
128
+ >
129
+ »
130
+ </button>
131
+ </nav>
132
+ </template>