ads-nuxt 0.1.0 → 0.1.2

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 (74) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +567 -1
  3. package/app/app.config.ts +25 -25
  4. package/app/assets/ads/checkbox-check.svg +5 -5
  5. package/app/assets/ads/checkbox-minus.svg +5 -5
  6. package/app/assets/brand/farmarcas.svg +6 -6
  7. package/app/assets/brand/radar.svg +9 -9
  8. package/app/assets/css/component-tokens.css +403 -403
  9. package/app/assets/css/main.css +617 -617
  10. package/app/components/AdsBadge.vue +48 -48
  11. package/app/components/AdsBroadcast.vue +45 -45
  12. package/app/components/AdsButton.vue +569 -569
  13. package/app/components/AdsButtonGroup.vue +39 -39
  14. package/app/components/AdsButtonMenu.vue +48 -48
  15. package/app/components/AdsCard.vue +117 -117
  16. package/app/components/AdsCharttip.vue +78 -78
  17. package/app/components/AdsCheckbox.vue +77 -77
  18. package/app/components/AdsDatePicker.vue +473 -473
  19. package/app/components/AdsDropdownMenu.vue +19 -19
  20. package/app/components/AdsEmptyState.vue +58 -58
  21. package/app/components/AdsFarmarcasPill.vue +74 -74
  22. package/app/components/AdsFeatureCard.vue +217 -217
  23. package/app/components/AdsFileCard.vue +101 -101
  24. package/app/components/AdsFilterDropdown.vue +174 -174
  25. package/app/components/AdsFilterOption.vue +72 -72
  26. package/app/components/AdsFilterSelect.vue +202 -202
  27. package/app/components/AdsFooter.vue +25 -25
  28. package/app/components/AdsInput.vue +143 -143
  29. package/app/components/AdsListDropdown.vue +44 -44
  30. package/app/components/AdsLoading.vue +109 -109
  31. package/app/components/AdsMenuBar.vue +103 -103
  32. package/app/components/AdsMetricCard.vue +119 -119
  33. package/app/components/AdsMiniPill.vue +40 -40
  34. package/app/components/AdsModal.vue +157 -157
  35. package/app/components/AdsMonthPicker.vue +80 -80
  36. package/app/components/AdsNavbarApp.vue +67 -67
  37. package/app/components/AdsNavbarDesk.vue +62 -62
  38. package/app/components/AdsNotabilityPill.vue +27 -27
  39. package/app/components/AdsNumberInput.vue +166 -166
  40. package/app/components/AdsPagination.vue +91 -91
  41. package/app/components/AdsProductTag.vue +61 -61
  42. package/app/components/AdsRadio.vue +70 -70
  43. package/app/components/AdsSearchInput.vue +113 -113
  44. package/app/components/AdsSegmentedTabs.vue +28 -28
  45. package/app/components/AdsSideMenu.vue +101 -101
  46. package/app/components/AdsSmalltip.vue +44 -44
  47. package/app/components/AdsSteptip.vue +101 -101
  48. package/app/components/AdsSwitch.vue +59 -59
  49. package/app/components/AdsTable.vue +100 -100
  50. package/app/components/AdsTableToolbar.vue +44 -44
  51. package/app/components/AdsTabs.vue +51 -51
  52. package/app/components/AdsTextarea.vue +109 -109
  53. package/app/components/AdsToast.vue +181 -181
  54. package/app/components/AdsTooltip.vue +66 -66
  55. package/app/components/InvertedTooltip.vue +40 -40
  56. package/app/components/OverlayLoading.vue +173 -173
  57. package/app/components/ProgressBarLoading.vue +256 -256
  58. package/app/composables/useOverlayLoading.ts +66 -66
  59. package/app/locale/pt-br.ts +6 -6
  60. package/app/plugins/ads-toast.client.ts +24 -24
  61. package/app/theme/ads-theme.ts +370 -370
  62. package/app/theme/component-tokens.json +402 -402
  63. package/app/theme/component-tokens.ts +815 -815
  64. package/app/types/overlay-loading.types.ts +26 -26
  65. package/app/utils/ads-filter-select.ts +172 -172
  66. package/app/utils/ads-table.ts +204 -204
  67. package/app/utils/calendar-date.ts +128 -128
  68. package/app/utils/category-role.ts +45 -45
  69. package/app/utils/money.ts +77 -77
  70. package/app/utils/notability.ts +8 -8
  71. package/app/utils/parser.ts +134 -134
  72. package/app/utils/percentage.ts +54 -54
  73. package/nuxt.config.ts +31 -32
  74. package/package.json +48 -46
@@ -1,473 +1,473 @@
1
- <script setup lang="ts">
2
- import { isEqualMonth, type CalendarDate } from "@internationalized/date";
3
- import { computed, nextTick, ref, shallowRef, watch } from "vue";
4
- import AdsButton from "./AdsButton.vue";
5
- import { UI_LOCALE_CODE } from "../locale/pt-br";
6
- import { formatDateOnlyToPtBr, getTodayDateString } from "../utils/parser";
7
- import {
8
- calendarDateToDateString,
9
- calendarMonthTitle,
10
- calendarRangeToDateStrings,
11
- calendarRangesEqual,
12
- dateStringToCalendarDate,
13
- dateStringsToCalendarRange,
14
- inclusiveCalendarDays,
15
- isCalendarDateRange,
16
- isOutsideMaximumWindow,
17
- shiftVisibleMonth,
18
- visibleMonthAfterPlaceholder,
19
- type CalendarDateRangeValue,
20
- } from "../utils/calendar-date";
21
-
22
- const DATE_PLACEHOLDER = "dd/mm/aaaa";
23
- const RANGE_SEPARATOR = " – ";
24
-
25
- const props = withDefaults(
26
- defineProps<{
27
- range?: boolean;
28
- min?: string;
29
- max?: string;
30
- disabled?: boolean;
31
- readonly?: boolean;
32
- size?: "sm" | "md" | "lg";
33
- maximumDays?: number;
34
- class?: string;
35
- }>(),
36
- {
37
- range: false,
38
- size: "md",
39
- },
40
- );
41
-
42
- const modelValue = defineModel<string>({ default: "" });
43
- const start = defineModel<string>("start", { default: "" });
44
- const end = defineModel<string>("end", { default: "" });
45
-
46
- const inputDateRef = useTemplateRef("inputDateRef");
47
- const syncingFromCalendar = ref(false);
48
- const rangeOpen = ref(false);
49
- const rangeCalendarKey = ref(0);
50
- const ignoreDraftUpdates = ref(false);
51
- const rangeSessionStart = ref("");
52
- const rangeSessionEnd = ref("");
53
-
54
- type CalendarModel = CalendarDate | CalendarDateRangeValue | undefined;
55
-
56
- const calendarModel = shallowRef<CalendarModel>();
57
- const draftRange = shallowRef<CalendarDateRangeValue | undefined>();
58
- const leftPlaceholder = shallowRef<CalendarDate>(
59
- dateStringToCalendarDate(getTodayDateString())!.set({ day: 1 }),
60
- );
61
-
62
- const minCalendarDate = computed(() =>
63
- props.min ? dateStringToCalendarDate(props.min) : undefined,
64
- );
65
-
66
- const maxCalendarDate = computed(() =>
67
- props.max ? dateStringToCalendarDate(props.max) : undefined,
68
- );
69
-
70
- const leftMonth = computed(() => leftPlaceholder.value.set({ day: 1 }));
71
- const rightMonth = computed(() => leftMonth.value.add({ months: 1 }));
72
-
73
- const navigationMax = computed(() => {
74
- const hardMax = maxCalendarDate.value;
75
- const draft = draftRange.value;
76
- if (!props.maximumDays || !draft?.start || draft.end) return hardMax;
77
-
78
- const windowEnd = draft.start.add({ days: props.maximumDays - 1 });
79
- if (!hardMax || windowEnd.compare(hardMax) < 0) return windowEnd;
80
- return hardMax;
81
- });
82
-
83
- const canShiftToPreviousMonth = computed(
84
- () =>
85
- shiftVisibleMonth(
86
- leftPlaceholder.value,
87
- -1,
88
- minCalendarDate.value,
89
- navigationMax.value,
90
- ).compare(leftMonth.value) !== 0,
91
- );
92
-
93
- const canShiftToNextMonth = computed(
94
- () =>
95
- shiftVisibleMonth(
96
- leftPlaceholder.value,
97
- 1,
98
- minCalendarDate.value,
99
- navigationMax.value,
100
- ).compare(leftMonth.value) !== 0,
101
- );
102
-
103
- const rangeStepLabel = computed(() => {
104
- const draft = draftRange.value;
105
- if (!draft?.start) return "Escolha o início";
106
-
107
- const startLabel = formatDateOnlyToPtBr(calendarDateToDateString(draft.start));
108
- if (!draft.end) {
109
- return props.maximumDays
110
- ? `Escolha o fim. Máximo de ${props.maximumDays} dias.`
111
- : "Escolha o fim";
112
- }
113
-
114
- const endLabel = formatDateOnlyToPtBr(calendarDateToDateString(draft.end));
115
- const days = inclusiveCalendarDays(draft.start, draft.end);
116
- const dayLabel = days === 1 ? "dia" : "dias";
117
- return `${startLabel} – ${endLabel} · ${days} ${dayLabel}`;
118
- });
119
-
120
- const canApplyRange = computed(
121
- () => Boolean(draftRange.value?.start && draftRange.value?.end),
122
- );
123
-
124
- const rangeDisplayText = computed(() => {
125
- const startLabel = start.value
126
- ? formatDateOnlyToPtBr(start.value)
127
- : DATE_PLACEHOLDER;
128
- const endLabel = end.value
129
- ? formatDateOnlyToPtBr(end.value)
130
- : DATE_PLACEHOLDER;
131
-
132
- if (!start.value && !end.value) {
133
- return `${DATE_PLACEHOLDER}${RANGE_SEPARATOR}${DATE_PLACEHOLDER}`;
134
- }
135
-
136
- return `${startLabel}${RANGE_SEPARATOR}${endLabel}`;
137
- });
138
-
139
- const popoverReference = computed(() => {
140
- const input = inputDateRef.value;
141
- const segments = input?.inputsRef;
142
- if (!segments?.length) return undefined;
143
- return segments[segments.length - 1]?.$el;
144
- });
145
-
146
- const rangeFieldClass = computed(() => [
147
- "group relative inline-flex w-full cursor-pointer items-center text-left transition-colors",
148
- "h-[var(--ads-date-height)] rounded-[var(--ads-date-radius)] px-[var(--ads-date-padding-x)] text-[length:var(--ads-date-font-size)] [font-weight:var(--ads-date-weight)] leading-[var(--ads-date-line-height)] text-[var(--ads-date-text)] bg-[var(--ads-date-background)] ring ring-inset ring-[var(--ads-date-border)]",
149
- "enabled:hover:ring-[var(--ads-date-hover-border)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--ads-date-focus-border)]",
150
- "disabled:cursor-not-allowed disabled:opacity-75",
151
- props.class,
152
- ]);
153
-
154
- const popoverUi = { content: "w-auto max-w-none p-0" };
155
-
156
- /**
157
- * Grade do range no Figma (5712:6691): mês com 258px, 7 colunas de 35,14px e 2px entre elas,
158
- * linhas de 36px (semana + 5 semanas = 216px) e dia de 32px de altura.
159
- */
160
- const rangeCalendarUi = {
161
- body: "flex-row gap-14 space-x-0 space-y-0 pt-0 sm:space-x-0",
162
- grid: "w-[258px] space-y-0",
163
- gridWeekDaysRow: "mb-0 h-9 grid-cols-7 items-center gap-x-0.5",
164
- gridBody: "grid",
165
- gridRow: "h-9 grid-cols-7 items-center gap-x-0.5",
166
- cell: "flex h-9 w-full items-center justify-self-stretch",
167
- cellTrigger: "m-0 h-8 w-full",
168
- };
169
-
170
- /** Intl em pt-BR devolve o mês em minúscula ("outubro de 2026"). Só a primeira letra muda; o ano fica igual. */
171
- function capitalizeFirst(value: string) {
172
- return value ? value.charAt(0).toLocaleUpperCase(UI_LOCALE_CODE) + value.slice(1) : value;
173
- }
174
-
175
- /** Cabeçalho do mês no Figma (_Date Picker Header, 5709:19265): Inter Bold 14, linha 130%, text/primary. */
176
- const singleCalendarUi = {
177
- heading: "text-sm font-bold leading-[1.3] text-[var(--ads-text-primary)]",
178
- };
179
-
180
- function monthTitle(date: CalendarDate) {
181
- return calendarMonthTitle(date, UI_LOCALE_CODE);
182
- }
183
-
184
- function onRangePlaceholderUpdate(value: CalendarDate) {
185
- const next = visibleMonthAfterPlaceholder(leftPlaceholder.value, value);
186
- if (next.compare(leftPlaceholder.value) !== 0) {
187
- leftPlaceholder.value = next;
188
- return;
189
- }
190
- if (isEqualMonth(value, leftPlaceholder.value) && value.day === 1) return;
191
- leftPlaceholder.value = leftPlaceholder.value.copy();
192
- }
193
-
194
- function moveVisibleMonth(delta: -1 | 1) {
195
- const next = shiftVisibleMonth(
196
- leftPlaceholder.value,
197
- delta,
198
- minCalendarDate.value,
199
- navigationMax.value,
200
- );
201
- if (next.compare(leftPlaceholder.value) === 0) return;
202
- leftPlaceholder.value = next;
203
- }
204
-
205
- function weekdayLetter(day: string) {
206
- return day.replace(".", "").charAt(0).toUpperCase();
207
- }
208
-
209
- function applyExternalModels() {
210
- if (syncingFromCalendar.value || props.range) return;
211
-
212
- const next = dateStringToCalendarDate(modelValue.value);
213
- if (!calendarRangesEqual(calendarModel.value, next)) {
214
- calendarModel.value = next;
215
- }
216
- }
217
-
218
- function onCalendarModelUpdate(value: CalendarModel) {
219
- if (calendarRangesEqual(calendarModel.value, value)) return;
220
-
221
- syncingFromCalendar.value = true;
222
- calendarModel.value = value;
223
-
224
- if (!props.range && value && !isCalendarDateRange(value)) {
225
- modelValue.value = calendarDateToDateString(value);
226
- }
227
-
228
- nextTick(() => {
229
- syncingFromCalendar.value = false;
230
- });
231
- }
232
-
233
- function resetDraftFromCommitted() {
234
- draftRange.value = dateStringsToCalendarRange(start.value, end.value);
235
- const anchor =
236
- draftRange.value?.start ?? dateStringToCalendarDate(getTodayDateString())!;
237
- leftPlaceholder.value = anchor.set({ day: 1 });
238
- }
239
-
240
- function onDraftUpdate(value: CalendarDateRangeValue | undefined) {
241
- if (ignoreDraftUpdates.value) return;
242
- draftRange.value = value;
243
- }
244
-
245
- function clearDraft() {
246
- // Os dois calendários guardam o intervalo por conta própria. Remontar
247
- // descarta o destaque; o flag ignora o eco desse estado ao desmontar.
248
- ignoreDraftUpdates.value = true;
249
- draftRange.value = undefined;
250
- start.value = "";
251
- end.value = "";
252
- rangeCalendarKey.value += 1;
253
- nextTick(() => {
254
- draftRange.value = undefined;
255
- nextTick(() => {
256
- ignoreDraftUpdates.value = false;
257
- });
258
- });
259
- }
260
-
261
- function applyDraft(close: () => void) {
262
- if (!draftRange.value?.start || !draftRange.value.end) return;
263
- const next = calendarRangeToDateStrings({
264
- start: draftRange.value.start,
265
- end: draftRange.value.end,
266
- });
267
- start.value = next.start;
268
- end.value = next.end;
269
- close();
270
- }
271
-
272
- function cancelDraft(close: () => void) {
273
- start.value = rangeSessionStart.value;
274
- end.value = rangeSessionEnd.value;
275
- resetDraftFromCommitted();
276
- close();
277
- }
278
-
279
- watch(rangeOpen, (isOpen) => {
280
- if (!isOpen) return;
281
- rangeSessionStart.value = start.value;
282
- rangeSessionEnd.value = end.value;
283
- resetDraftFromCommitted();
284
- });
285
-
286
- watch([modelValue, start, end, () => props.range], applyExternalModels, {
287
- immediate: true,
288
- });
289
-
290
- function isDateUnavailable(date: CalendarDate) {
291
- if (minCalendarDate.value && date.compare(minCalendarDate.value) < 0) {
292
- return true;
293
- }
294
- if (maxCalendarDate.value && date.compare(maxCalendarDate.value) > 0) {
295
- return true;
296
- }
297
- if (!props.range || !props.maximumDays) return false;
298
- const draft = draftRange.value;
299
- if (!draft?.start || draft.end) return false;
300
- return isOutsideMaximumWindow(date, draft.start, props.maximumDays);
301
- }
302
- </script>
303
-
304
- <template>
305
- <UPopover
306
- v-if="range"
307
- v-model:open="rangeOpen"
308
- :content="{ align: 'start', side: 'bottom' }"
309
- :ui="popoverUi"
310
- >
311
- <button
312
- type="button"
313
- :disabled="disabled || readonly"
314
- :aria-label="`Selecionar período. ${rangeDisplayText}`"
315
- :class="rangeFieldClass"
316
- >
317
- <span
318
- class="min-w-0 flex-1 truncate tabular-nums"
319
- :class="start || end ? 'text-[var(--ads-date-text)]' : 'text-[var(--ads-date-placeholder)]'"
320
- >
321
- {{ rangeDisplayText }}
322
- </span>
323
- <UIcon name="i-lucide-calendar" class="size-5 shrink-0 text-[var(--ads-date-placeholder)]" />
324
- </button>
325
-
326
- <template #content="{ close }">
327
- <div class="ads-date-range-panel max-w-[calc(100vw-2rem)] overflow-x-auto bg-white">
328
- <p class="px-4 pt-4 text-sm font-medium text-neutral-900" aria-live="polite">
329
- {{ rangeStepLabel }}
330
- </p>
331
- <!-- Figma Range Date Picker (5712:6691): dois painéis de 290×284, 24px entre eles. -->
332
- <div class="relative w-[604px]">
333
- <div class="pointer-events-none absolute inset-0 grid grid-cols-[290px_290px] gap-6" aria-hidden="true">
334
- <div class="rounded-s-[var(--ads-radius-xsm)] bg-[var(--ads-surface-card)]" />
335
- <div class="rounded-e-[var(--ads-radius-xsm)] bg-[var(--ads-surface-card)]" />
336
- </div>
337
- <div class="relative flex flex-col gap-3 p-4">
338
- <div class="grid grid-cols-[258px_258px] gap-x-14">
339
- <div class="flex h-6 items-center justify-between">
340
- <button
341
- type="button"
342
- class="ads-date-range-nav"
343
- aria-label="Mês anterior"
344
- :disabled="!canShiftToPreviousMonth"
345
- @click="moveVisibleMonth(-1)"
346
- >
347
- <UIcon name="i-lucide-chevron-left" class="size-6" />
348
- </button>
349
- <span class="ads-date-range-title">{{ monthTitle(leftMonth) }}</span>
350
- <span class="size-6 shrink-0" aria-hidden="true" />
351
- </div>
352
- <div class="flex h-6 items-center justify-between">
353
- <span class="size-6 shrink-0" aria-hidden="true" />
354
- <span class="ads-date-range-title">{{ monthTitle(rightMonth) }}</span>
355
- <button
356
- type="button"
357
- class="ads-date-range-nav"
358
- aria-label="Próximo mês"
359
- :disabled="!canShiftToNextMonth"
360
- @click="moveVisibleMonth(1)"
361
- >
362
- <UIcon name="i-lucide-chevron-right" class="size-6" />
363
- </button>
364
- </div>
365
- </div>
366
- <UCalendar
367
- :key="`range-${rangeCalendarKey}`"
368
- :model-value="draftRange"
369
- :placeholder="leftMonth"
370
- :locale="UI_LOCALE_CODE"
371
- range
372
- :number-of-months="2"
373
- :month-controls="false"
374
- :year-controls="false"
375
- weekday-format="narrow"
376
- :week-starts-on="0"
377
- :min-value="minCalendarDate"
378
- :max-value="maxCalendarDate"
379
- disable-days-outside-current-view
380
- :is-date-unavailable="isDateUnavailable"
381
- color="neutral"
382
- variant="outline"
383
- class="ads-date-picker"
384
- :ui="rangeCalendarUi"
385
- @update:model-value="onDraftUpdate"
386
- @update:placeholder="onRangePlaceholderUpdate"
387
- >
388
- <template #week-day="{ day }">
389
- {{ weekdayLetter(day) }}
390
- </template>
391
- </UCalendar>
392
- </div>
393
- </div>
394
-
395
- <div class="flex items-center justify-between gap-3 px-4 pb-4">
396
- <AdsButton
397
- type="unfilled"
398
- size="sm"
399
- leading
400
- icon="i-lucide-x"
401
- label="Limpar seleção"
402
- @click="clearDraft"
403
- />
404
- <div class="flex items-center gap-2">
405
- <AdsButton
406
- type="unfilled"
407
- label="Cancelar"
408
- @click="cancelDraft(close)"
409
- />
410
- <AdsButton
411
- label="Aplicar"
412
- :disabled="!canApplyRange"
413
- @click="applyDraft(close)"
414
- />
415
- </div>
416
- </div>
417
- </div>
418
- </template>
419
- </UPopover>
420
-
421
- <UInputDate
422
- v-else
423
- ref="inputDateRef"
424
- :model-value="calendarModel"
425
- :locale="UI_LOCALE_CODE"
426
- :size="size"
427
- :disabled="disabled"
428
- :readonly="readonly"
429
- :min-value="minCalendarDate"
430
- :max-value="maxCalendarDate"
431
- :is-date-unavailable="isDateUnavailable"
432
- trailing
433
- :class="props.class"
434
- @update:model-value="onCalendarModelUpdate"
435
- >
436
- <template #trailing>
437
- <UPopover
438
- :reference="popoverReference"
439
- :content="{ align: 'end', side: 'bottom' }"
440
- :ui="popoverUi"
441
- >
442
- <AdsButton
443
- type="unfilled"
444
- size="sm"
445
- leading
446
- icon="i-lucide-calendar"
447
- aria-label="Selecionar data"
448
- class="px-0"
449
- :disabled="disabled || readonly"
450
- />
451
-
452
- <template #content>
453
- <UCalendar
454
- :model-value="calendarModel"
455
- :locale="UI_LOCALE_CODE"
456
- :min-value="minCalendarDate"
457
- :max-value="maxCalendarDate"
458
- :is-date-unavailable="isDateUnavailable"
459
- color="primary"
460
- variant="solid"
461
- class="ads-date-picker p-2"
462
- :ui="singleCalendarUi"
463
- @update:model-value="onCalendarModelUpdate"
464
- >
465
- <template #heading="{ value }">
466
- {{ capitalizeFirst(value) }}
467
- </template>
468
- </UCalendar>
469
- </template>
470
- </UPopover>
471
- </template>
472
- </UInputDate>
473
- </template>
1
+ <script setup lang="ts">
2
+ import { isEqualMonth, type CalendarDate } from "@internationalized/date";
3
+ import { computed, nextTick, ref, shallowRef, watch } from "vue";
4
+ import AdsButton from "./AdsButton.vue";
5
+ import { UI_LOCALE_CODE } from "../locale/pt-br";
6
+ import { formatDateOnlyToPtBr, getTodayDateString } from "../utils/parser";
7
+ import {
8
+ calendarDateToDateString,
9
+ calendarMonthTitle,
10
+ calendarRangeToDateStrings,
11
+ calendarRangesEqual,
12
+ dateStringToCalendarDate,
13
+ dateStringsToCalendarRange,
14
+ inclusiveCalendarDays,
15
+ isCalendarDateRange,
16
+ isOutsideMaximumWindow,
17
+ shiftVisibleMonth,
18
+ visibleMonthAfterPlaceholder,
19
+ type CalendarDateRangeValue,
20
+ } from "../utils/calendar-date";
21
+
22
+ const DATE_PLACEHOLDER = "dd/mm/aaaa";
23
+ const RANGE_SEPARATOR = " – ";
24
+
25
+ const props = withDefaults(
26
+ defineProps<{
27
+ range?: boolean;
28
+ min?: string;
29
+ max?: string;
30
+ disabled?: boolean;
31
+ readonly?: boolean;
32
+ size?: "sm" | "md" | "lg";
33
+ maximumDays?: number;
34
+ class?: string;
35
+ }>(),
36
+ {
37
+ range: false,
38
+ size: "md",
39
+ },
40
+ );
41
+
42
+ const modelValue = defineModel<string>({ default: "" });
43
+ const start = defineModel<string>("start", { default: "" });
44
+ const end = defineModel<string>("end", { default: "" });
45
+
46
+ const inputDateRef = useTemplateRef("inputDateRef");
47
+ const syncingFromCalendar = ref(false);
48
+ const rangeOpen = ref(false);
49
+ const rangeCalendarKey = ref(0);
50
+ const ignoreDraftUpdates = ref(false);
51
+ const rangeSessionStart = ref("");
52
+ const rangeSessionEnd = ref("");
53
+
54
+ type CalendarModel = CalendarDate | CalendarDateRangeValue | undefined;
55
+
56
+ const calendarModel = shallowRef<CalendarModel>();
57
+ const draftRange = shallowRef<CalendarDateRangeValue | undefined>();
58
+ const leftPlaceholder = shallowRef<CalendarDate>(
59
+ dateStringToCalendarDate(getTodayDateString())!.set({ day: 1 }),
60
+ );
61
+
62
+ const minCalendarDate = computed(() =>
63
+ props.min ? dateStringToCalendarDate(props.min) : undefined,
64
+ );
65
+
66
+ const maxCalendarDate = computed(() =>
67
+ props.max ? dateStringToCalendarDate(props.max) : undefined,
68
+ );
69
+
70
+ const leftMonth = computed(() => leftPlaceholder.value.set({ day: 1 }));
71
+ const rightMonth = computed(() => leftMonth.value.add({ months: 1 }));
72
+
73
+ const navigationMax = computed(() => {
74
+ const hardMax = maxCalendarDate.value;
75
+ const draft = draftRange.value;
76
+ if (!props.maximumDays || !draft?.start || draft.end) return hardMax;
77
+
78
+ const windowEnd = draft.start.add({ days: props.maximumDays - 1 });
79
+ if (!hardMax || windowEnd.compare(hardMax) < 0) return windowEnd;
80
+ return hardMax;
81
+ });
82
+
83
+ const canShiftToPreviousMonth = computed(
84
+ () =>
85
+ shiftVisibleMonth(
86
+ leftPlaceholder.value,
87
+ -1,
88
+ minCalendarDate.value,
89
+ navigationMax.value,
90
+ ).compare(leftMonth.value) !== 0,
91
+ );
92
+
93
+ const canShiftToNextMonth = computed(
94
+ () =>
95
+ shiftVisibleMonth(
96
+ leftPlaceholder.value,
97
+ 1,
98
+ minCalendarDate.value,
99
+ navigationMax.value,
100
+ ).compare(leftMonth.value) !== 0,
101
+ );
102
+
103
+ const rangeStepLabel = computed(() => {
104
+ const draft = draftRange.value;
105
+ if (!draft?.start) return "Escolha o início";
106
+
107
+ const startLabel = formatDateOnlyToPtBr(calendarDateToDateString(draft.start));
108
+ if (!draft.end) {
109
+ return props.maximumDays
110
+ ? `Escolha o fim. Máximo de ${props.maximumDays} dias.`
111
+ : "Escolha o fim";
112
+ }
113
+
114
+ const endLabel = formatDateOnlyToPtBr(calendarDateToDateString(draft.end));
115
+ const days = inclusiveCalendarDays(draft.start, draft.end);
116
+ const dayLabel = days === 1 ? "dia" : "dias";
117
+ return `${startLabel} – ${endLabel} · ${days} ${dayLabel}`;
118
+ });
119
+
120
+ const canApplyRange = computed(
121
+ () => Boolean(draftRange.value?.start && draftRange.value?.end),
122
+ );
123
+
124
+ const rangeDisplayText = computed(() => {
125
+ const startLabel = start.value
126
+ ? formatDateOnlyToPtBr(start.value)
127
+ : DATE_PLACEHOLDER;
128
+ const endLabel = end.value
129
+ ? formatDateOnlyToPtBr(end.value)
130
+ : DATE_PLACEHOLDER;
131
+
132
+ if (!start.value && !end.value) {
133
+ return `${DATE_PLACEHOLDER}${RANGE_SEPARATOR}${DATE_PLACEHOLDER}`;
134
+ }
135
+
136
+ return `${startLabel}${RANGE_SEPARATOR}${endLabel}`;
137
+ });
138
+
139
+ const popoverReference = computed(() => {
140
+ const input = inputDateRef.value;
141
+ const segments = input?.inputsRef;
142
+ if (!segments?.length) return undefined;
143
+ return segments[segments.length - 1]?.$el;
144
+ });
145
+
146
+ const rangeFieldClass = computed(() => [
147
+ "group relative inline-flex w-full cursor-pointer items-center text-left transition-colors",
148
+ "h-[var(--ads-date-height)] rounded-[var(--ads-date-radius)] px-[var(--ads-date-padding-x)] text-[length:var(--ads-date-font-size)] [font-weight:var(--ads-date-weight)] leading-[var(--ads-date-line-height)] text-[var(--ads-date-text)] bg-[var(--ads-date-background)] ring ring-inset ring-[var(--ads-date-border)]",
149
+ "enabled:hover:ring-[var(--ads-date-hover-border)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--ads-date-focus-border)]",
150
+ "disabled:cursor-not-allowed disabled:opacity-75",
151
+ props.class,
152
+ ]);
153
+
154
+ const popoverUi = { content: "w-auto max-w-none p-0" };
155
+
156
+ /**
157
+ * Grade do range no Figma (5712:6691): mês com 258px, 7 colunas de 35,14px e 2px entre elas,
158
+ * linhas de 36px (semana + 5 semanas = 216px) e dia de 32px de altura.
159
+ */
160
+ const rangeCalendarUi = {
161
+ body: "flex-row gap-14 space-x-0 space-y-0 pt-0 sm:space-x-0",
162
+ grid: "w-[258px] space-y-0",
163
+ gridWeekDaysRow: "mb-0 h-9 grid-cols-7 items-center gap-x-0.5",
164
+ gridBody: "grid",
165
+ gridRow: "h-9 grid-cols-7 items-center gap-x-0.5",
166
+ cell: "flex h-9 w-full items-center justify-self-stretch",
167
+ cellTrigger: "m-0 h-8 w-full",
168
+ };
169
+
170
+ /** Intl em pt-BR devolve o mês em minúscula ("outubro de 2026"). Só a primeira letra muda; o ano fica igual. */
171
+ function capitalizeFirst(value: string) {
172
+ return value ? value.charAt(0).toLocaleUpperCase(UI_LOCALE_CODE) + value.slice(1) : value;
173
+ }
174
+
175
+ /** Cabeçalho do mês no Figma (_Date Picker Header, 5709:19265): Inter Bold 14, linha 130%, text/primary. */
176
+ const singleCalendarUi = {
177
+ heading: "text-sm font-bold leading-[1.3] text-[var(--ads-text-primary)]",
178
+ };
179
+
180
+ function monthTitle(date: CalendarDate) {
181
+ return calendarMonthTitle(date, UI_LOCALE_CODE);
182
+ }
183
+
184
+ function onRangePlaceholderUpdate(value: CalendarDate) {
185
+ const next = visibleMonthAfterPlaceholder(leftPlaceholder.value, value);
186
+ if (next.compare(leftPlaceholder.value) !== 0) {
187
+ leftPlaceholder.value = next;
188
+ return;
189
+ }
190
+ if (isEqualMonth(value, leftPlaceholder.value) && value.day === 1) return;
191
+ leftPlaceholder.value = leftPlaceholder.value.copy();
192
+ }
193
+
194
+ function moveVisibleMonth(delta: -1 | 1) {
195
+ const next = shiftVisibleMonth(
196
+ leftPlaceholder.value,
197
+ delta,
198
+ minCalendarDate.value,
199
+ navigationMax.value,
200
+ );
201
+ if (next.compare(leftPlaceholder.value) === 0) return;
202
+ leftPlaceholder.value = next;
203
+ }
204
+
205
+ function weekdayLetter(day: string) {
206
+ return day.replace(".", "").charAt(0).toUpperCase();
207
+ }
208
+
209
+ function applyExternalModels() {
210
+ if (syncingFromCalendar.value || props.range) return;
211
+
212
+ const next = dateStringToCalendarDate(modelValue.value);
213
+ if (!calendarRangesEqual(calendarModel.value, next)) {
214
+ calendarModel.value = next;
215
+ }
216
+ }
217
+
218
+ function onCalendarModelUpdate(value: CalendarModel) {
219
+ if (calendarRangesEqual(calendarModel.value, value)) return;
220
+
221
+ syncingFromCalendar.value = true;
222
+ calendarModel.value = value;
223
+
224
+ if (!props.range && value && !isCalendarDateRange(value)) {
225
+ modelValue.value = calendarDateToDateString(value);
226
+ }
227
+
228
+ nextTick(() => {
229
+ syncingFromCalendar.value = false;
230
+ });
231
+ }
232
+
233
+ function resetDraftFromCommitted() {
234
+ draftRange.value = dateStringsToCalendarRange(start.value, end.value);
235
+ const anchor =
236
+ draftRange.value?.start ?? dateStringToCalendarDate(getTodayDateString())!;
237
+ leftPlaceholder.value = anchor.set({ day: 1 });
238
+ }
239
+
240
+ function onDraftUpdate(value: CalendarDateRangeValue | undefined) {
241
+ if (ignoreDraftUpdates.value) return;
242
+ draftRange.value = value;
243
+ }
244
+
245
+ function clearDraft() {
246
+ // Os dois calendários guardam o intervalo por conta própria. Remontar
247
+ // descarta o destaque; o flag ignora o eco desse estado ao desmontar.
248
+ ignoreDraftUpdates.value = true;
249
+ draftRange.value = undefined;
250
+ start.value = "";
251
+ end.value = "";
252
+ rangeCalendarKey.value += 1;
253
+ nextTick(() => {
254
+ draftRange.value = undefined;
255
+ nextTick(() => {
256
+ ignoreDraftUpdates.value = false;
257
+ });
258
+ });
259
+ }
260
+
261
+ function applyDraft(close: () => void) {
262
+ if (!draftRange.value?.start || !draftRange.value.end) return;
263
+ const next = calendarRangeToDateStrings({
264
+ start: draftRange.value.start,
265
+ end: draftRange.value.end,
266
+ });
267
+ start.value = next.start;
268
+ end.value = next.end;
269
+ close();
270
+ }
271
+
272
+ function cancelDraft(close: () => void) {
273
+ start.value = rangeSessionStart.value;
274
+ end.value = rangeSessionEnd.value;
275
+ resetDraftFromCommitted();
276
+ close();
277
+ }
278
+
279
+ watch(rangeOpen, (isOpen) => {
280
+ if (!isOpen) return;
281
+ rangeSessionStart.value = start.value;
282
+ rangeSessionEnd.value = end.value;
283
+ resetDraftFromCommitted();
284
+ });
285
+
286
+ watch([modelValue, start, end, () => props.range], applyExternalModels, {
287
+ immediate: true,
288
+ });
289
+
290
+ function isDateUnavailable(date: CalendarDate) {
291
+ if (minCalendarDate.value && date.compare(minCalendarDate.value) < 0) {
292
+ return true;
293
+ }
294
+ if (maxCalendarDate.value && date.compare(maxCalendarDate.value) > 0) {
295
+ return true;
296
+ }
297
+ if (!props.range || !props.maximumDays) return false;
298
+ const draft = draftRange.value;
299
+ if (!draft?.start || draft.end) return false;
300
+ return isOutsideMaximumWindow(date, draft.start, props.maximumDays);
301
+ }
302
+ </script>
303
+
304
+ <template>
305
+ <UPopover
306
+ v-if="range"
307
+ v-model:open="rangeOpen"
308
+ :content="{ align: 'start', side: 'bottom' }"
309
+ :ui="popoverUi"
310
+ >
311
+ <button
312
+ type="button"
313
+ :disabled="disabled || readonly"
314
+ :aria-label="`Selecionar período. ${rangeDisplayText}`"
315
+ :class="rangeFieldClass"
316
+ >
317
+ <span
318
+ class="min-w-0 flex-1 truncate tabular-nums"
319
+ :class="start || end ? 'text-[var(--ads-date-text)]' : 'text-[var(--ads-date-placeholder)]'"
320
+ >
321
+ {{ rangeDisplayText }}
322
+ </span>
323
+ <UIcon name="i-lucide-calendar" class="size-5 shrink-0 text-[var(--ads-date-placeholder)]" />
324
+ </button>
325
+
326
+ <template #content="{ close }">
327
+ <div class="ads-date-range-panel max-w-[calc(100vw-2rem)] overflow-x-auto bg-white">
328
+ <p class="px-4 pt-4 text-sm font-medium text-neutral-900" aria-live="polite">
329
+ {{ rangeStepLabel }}
330
+ </p>
331
+ <!-- Figma Range Date Picker (5712:6691): dois painéis de 290×284, 24px entre eles. -->
332
+ <div class="relative w-[604px]">
333
+ <div class="pointer-events-none absolute inset-0 grid grid-cols-[290px_290px] gap-6" aria-hidden="true">
334
+ <div class="rounded-s-[var(--ads-radius-xsm)] bg-[var(--ads-surface-card)]" />
335
+ <div class="rounded-e-[var(--ads-radius-xsm)] bg-[var(--ads-surface-card)]" />
336
+ </div>
337
+ <div class="relative flex flex-col gap-3 p-4">
338
+ <div class="grid grid-cols-[258px_258px] gap-x-14">
339
+ <div class="flex h-6 items-center justify-between">
340
+ <button
341
+ type="button"
342
+ class="ads-date-range-nav"
343
+ aria-label="Mês anterior"
344
+ :disabled="!canShiftToPreviousMonth"
345
+ @click="moveVisibleMonth(-1)"
346
+ >
347
+ <UIcon name="i-lucide-chevron-left" class="size-6" />
348
+ </button>
349
+ <span class="ads-date-range-title">{{ monthTitle(leftMonth) }}</span>
350
+ <span class="size-6 shrink-0" aria-hidden="true" />
351
+ </div>
352
+ <div class="flex h-6 items-center justify-between">
353
+ <span class="size-6 shrink-0" aria-hidden="true" />
354
+ <span class="ads-date-range-title">{{ monthTitle(rightMonth) }}</span>
355
+ <button
356
+ type="button"
357
+ class="ads-date-range-nav"
358
+ aria-label="Próximo mês"
359
+ :disabled="!canShiftToNextMonth"
360
+ @click="moveVisibleMonth(1)"
361
+ >
362
+ <UIcon name="i-lucide-chevron-right" class="size-6" />
363
+ </button>
364
+ </div>
365
+ </div>
366
+ <UCalendar
367
+ :key="`range-${rangeCalendarKey}`"
368
+ :model-value="draftRange"
369
+ :placeholder="leftMonth"
370
+ :locale="UI_LOCALE_CODE"
371
+ range
372
+ :number-of-months="2"
373
+ :month-controls="false"
374
+ :year-controls="false"
375
+ weekday-format="narrow"
376
+ :week-starts-on="0"
377
+ :min-value="minCalendarDate"
378
+ :max-value="maxCalendarDate"
379
+ disable-days-outside-current-view
380
+ :is-date-unavailable="isDateUnavailable"
381
+ color="neutral"
382
+ variant="outline"
383
+ class="ads-date-picker"
384
+ :ui="rangeCalendarUi"
385
+ @update:model-value="onDraftUpdate"
386
+ @update:placeholder="onRangePlaceholderUpdate"
387
+ >
388
+ <template #week-day="{ day }">
389
+ {{ weekdayLetter(day) }}
390
+ </template>
391
+ </UCalendar>
392
+ </div>
393
+ </div>
394
+
395
+ <div class="flex items-center justify-between gap-3 px-4 pb-4">
396
+ <AdsButton
397
+ type="unfilled"
398
+ size="sm"
399
+ leading
400
+ icon="i-lucide-x"
401
+ label="Limpar seleção"
402
+ @click="clearDraft"
403
+ />
404
+ <div class="flex items-center gap-2">
405
+ <AdsButton
406
+ type="unfilled"
407
+ label="Cancelar"
408
+ @click="cancelDraft(close)"
409
+ />
410
+ <AdsButton
411
+ label="Aplicar"
412
+ :disabled="!canApplyRange"
413
+ @click="applyDraft(close)"
414
+ />
415
+ </div>
416
+ </div>
417
+ </div>
418
+ </template>
419
+ </UPopover>
420
+
421
+ <UInputDate
422
+ v-else
423
+ ref="inputDateRef"
424
+ :model-value="calendarModel"
425
+ :locale="UI_LOCALE_CODE"
426
+ :size="size"
427
+ :disabled="disabled"
428
+ :readonly="readonly"
429
+ :min-value="minCalendarDate"
430
+ :max-value="maxCalendarDate"
431
+ :is-date-unavailable="isDateUnavailable"
432
+ trailing
433
+ :class="props.class"
434
+ @update:model-value="onCalendarModelUpdate"
435
+ >
436
+ <template #trailing>
437
+ <UPopover
438
+ :reference="popoverReference"
439
+ :content="{ align: 'end', side: 'bottom' }"
440
+ :ui="popoverUi"
441
+ >
442
+ <AdsButton
443
+ type="unfilled"
444
+ size="sm"
445
+ leading
446
+ icon="i-lucide-calendar"
447
+ aria-label="Selecionar data"
448
+ class="px-0"
449
+ :disabled="disabled || readonly"
450
+ />
451
+
452
+ <template #content>
453
+ <UCalendar
454
+ :model-value="calendarModel"
455
+ :locale="UI_LOCALE_CODE"
456
+ :min-value="minCalendarDate"
457
+ :max-value="maxCalendarDate"
458
+ :is-date-unavailable="isDateUnavailable"
459
+ color="primary"
460
+ variant="solid"
461
+ class="ads-date-picker p-2"
462
+ :ui="singleCalendarUi"
463
+ @update:model-value="onCalendarModelUpdate"
464
+ >
465
+ <template #heading="{ value }">
466
+ {{ capitalizeFirst(value) }}
467
+ </template>
468
+ </UCalendar>
469
+ </template>
470
+ </UPopover>
471
+ </template>
472
+ </UInputDate>
473
+ </template>