@brumaombra/ui-vintage 0.8.1 → 0.9.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 (142) hide show
  1. package/AGENTS.md +41 -0
  2. package/COMPONENTS.md +91 -17
  3. package/README.md +296 -262
  4. package/module.mjs +5 -11
  5. package/package.json +31 -13
  6. package/src/components/blog/AllPostsSection.vue +1 -2
  7. package/src/components/blog/BlogContentRenderer.vue +144 -49
  8. package/src/components/blog/BlogFAQSection.vue +13 -15
  9. package/src/components/blog/BlogHeaderSection.vue +103 -11
  10. package/src/components/blog/BlogInfoSection.vue +46 -42
  11. package/src/components/blog/BlogPostHeader.vue +181 -0
  12. package/src/components/blog/BlogProgressBar.vue +3 -2
  13. package/src/components/blog/BlogSectionTitle.vue +27 -6
  14. package/src/components/blog/BlogTagsSection.vue +20 -8
  15. package/src/components/blog/CategoriesList.vue +2 -2
  16. package/src/components/blog/CategoryCard.vue +23 -19
  17. package/src/components/blog/HeaderCarousel.vue +117 -145
  18. package/src/components/blog/PostCard.vue +25 -33
  19. package/src/components/blog/PostsList.vue +3 -2
  20. package/src/components/blog/SocialShareSidebar.vue +62 -42
  21. package/src/components/blog/TableOfContents.vue +71 -34
  22. package/src/components/blog/index.ts +1 -0
  23. package/src/components/card-grid/CardGrid.vue +2 -2
  24. package/src/components/confirm-dialog/ConfirmDialog.vue +5 -0
  25. package/src/components/confirm-dialog/confirm-dialog-state.ts +22 -22
  26. package/src/components/content/BlogList.vue +30 -42
  27. package/src/components/content/BlogTable.vue +26 -30
  28. package/src/components/content/CodeBlock.vue +96 -22
  29. package/src/components/content/Flow.vue +30 -36
  30. package/src/components/content/ProseHr.vue +6 -6
  31. package/src/components/content/ProsePre.vue +9 -4
  32. package/src/components/content/Terminal.vue +42 -14
  33. package/src/components/cookie-consent/CookieConsent.vue +1 -1
  34. package/src/components/dashboard-shell/DashboardShell.vue +24 -7
  35. package/src/components/empty-state-card/EmptyStateCard.vue +2 -2
  36. package/src/components/error-page/ErrorPage.vue +3 -3
  37. package/src/components/info-card/InfoCard.vue +4 -4
  38. package/src/components/landing/landing-footer/LandingFooter.vue +16 -6
  39. package/src/components/landing/landing-navbar/LandingNavbar.vue +63 -51
  40. package/src/components/language-flag/index.ts +1 -0
  41. package/src/components/language-selector/LanguageSelector.vue +18 -10
  42. package/src/components/load-more-button/LoadMoreButton.vue +7 -12
  43. package/src/components/loading-state-card/LoadingStateCard.vue +2 -2
  44. package/src/components/message-dialog/MessageDialog.vue +11 -11
  45. package/src/components/message-dialog/message-dialog-state.ts +1 -4
  46. package/src/components/message-toast/MessageToast.vue +24 -9
  47. package/src/components/message-toast/message-toast-state.ts +20 -0
  48. package/src/components/page-header/PageHeader.vue +1 -1
  49. package/src/components/progress-component/ProgressComponent.vue +7 -3
  50. package/src/components/single-value-card/SingleValueCard.vue +37 -39
  51. package/src/components/slider-form-component/SliderFormComponent.vue +1 -1
  52. package/src/components/stat-strip/StatStrip.vue +36 -0
  53. package/src/components/stat-strip/index.ts +2 -0
  54. package/src/components/switch-form-component/SwitchFormComponent.vue +5 -5
  55. package/src/components/text-link/TextLink.vue +1 -1
  56. package/src/components/theme-selector/ThemeSelector.vue +2 -2
  57. package/src/components/theme-selector/theme-transition.ts +15 -3
  58. package/src/components/ui/accordion/Accordion.vue +18 -18
  59. package/src/components/ui/alert/AlertTitle.vue +1 -1
  60. package/src/components/ui/alert/index.ts +1 -1
  61. package/src/components/ui/avatar/AvatarFallback.vue +2 -1
  62. package/src/components/ui/avatar/AvatarGroup.vue +13 -3
  63. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +2 -2
  64. package/src/components/ui/breadcrumb/BreadcrumbLink.vue +1 -1
  65. package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
  66. package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
  67. package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
  68. package/src/components/ui/busy/Busy.vue +5 -7
  69. package/src/components/ui/button/Button.vue +4 -2
  70. package/src/components/ui/button/index.ts +1 -4
  71. package/src/components/ui/calendar/Calendar.vue +12 -7
  72. package/src/components/ui/calendar/CalendarCell.vue +1 -1
  73. package/src/components/ui/calendar/CalendarCellTrigger.vue +1 -1
  74. package/src/components/ui/calendar/CalendarHeadCell.vue +1 -1
  75. package/src/components/ui/calendar/CalendarNextButton.vue +1 -1
  76. package/src/components/ui/calendar/CalendarPrevButton.vue +1 -1
  77. package/src/components/ui/card/Card.vue +5 -15
  78. package/src/components/ui/checkbox/Checkbox.vue +5 -1
  79. package/src/components/ui/combobox/ComboboxInput.vue +5 -1
  80. package/src/components/ui/command/CommandGroup.vue +17 -3
  81. package/src/components/ui/command/CommandInput.vue +1 -1
  82. package/src/components/ui/command/CommandItem.vue +2 -2
  83. package/src/components/ui/command/CommandList.vue +5 -1
  84. package/src/components/ui/data-table/DataTable.vue +11 -3
  85. package/src/components/ui/date-picker/DatePicker.vue +7 -1
  86. package/src/components/ui/date-time-picker/DateTimePicker.vue +5 -1
  87. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  88. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +1 -1
  89. package/src/components/ui/dropdown-menu/classes.ts +2 -0
  90. package/src/components/ui/field/Field.vue +4 -0
  91. package/src/components/ui/field/FieldLabel.vue +6 -2
  92. package/src/components/ui/field/field-context.ts +62 -0
  93. package/src/components/ui/field/index.ts +1 -0
  94. package/src/components/ui/file-dropzone/FileDropzone.vue +4 -4
  95. package/src/components/ui/input/Input.vue +5 -1
  96. package/src/components/ui/native-select/NativeSelect.vue +6 -2
  97. package/src/components/ui/number-field/NumberField.vue +5 -1
  98. package/src/components/ui/pagination/PaginationItem.vue +1 -1
  99. package/src/components/ui/pin-input/PinInputInput.vue +2 -2
  100. package/src/components/ui/progress/Progress.vue +2 -2
  101. package/src/components/ui/radio-group/RadioGroupCard.vue +1 -1
  102. package/src/components/ui/select/SelectItem.vue +1 -1
  103. package/src/components/ui/select/SelectItemContent.vue +1 -1
  104. package/src/components/ui/select/SelectTrigger.vue +5 -1
  105. package/src/components/ui/sidebar/Sidebar.vue +1 -1
  106. package/src/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  107. package/src/components/ui/sidebar/SidebarGroupLabel.vue +1 -1
  108. package/src/components/ui/sidebar/SidebarInset.vue +1 -1
  109. package/src/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  110. package/src/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  111. package/src/components/ui/sidebar/SidebarMenuSkeleton.vue +2 -2
  112. package/src/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  113. package/src/components/ui/sidebar/SidebarProvider.vue +17 -6
  114. package/src/components/ui/sidebar/index.ts +2 -2
  115. package/src/components/ui/sidebar/utils.ts +1 -1
  116. package/src/components/ui/slider/Slider.vue +5 -4
  117. package/src/components/ui/spinner/Spinner.vue +3 -2
  118. package/src/components/ui/stepper/StepperDescription.vue +5 -3
  119. package/src/components/ui/stepper/StepperIndicator.vue +2 -2
  120. package/src/components/ui/stepper/StepperTitle.vue +5 -3
  121. package/src/components/ui/switch/Switch.vue +6 -2
  122. package/src/components/ui/tabs/TabsList.vue +1 -1
  123. package/src/components/ui/tabs/TabsTrigger.vue +1 -1
  124. package/src/components/ui/textarea/Textarea.vue +5 -1
  125. package/src/components/ui/time-picker/TimePicker.vue +2 -2
  126. package/src/components/ui/toggle-group/ToggleGroup.vue +1 -1
  127. package/src/components/ui/tooltip/TooltipContent.vue +1 -1
  128. package/src/i18n/de.json +19 -7
  129. package/src/i18n/en.json +19 -7
  130. package/src/i18n/es.json +19 -7
  131. package/src/i18n/fr.json +19 -7
  132. package/src/i18n/it.json +19 -7
  133. package/src/i18n/ja.json +19 -7
  134. package/src/i18n/pt.json +19 -7
  135. package/src/i18n/ru.json +19 -7
  136. package/src/i18n/zh.json +19 -7
  137. package/src/lib/color-tokens.ts +1 -1
  138. package/src/lib/i18n.ts +32 -4
  139. package/src/lib/utils.ts +21 -1
  140. package/src/runtime/ui-vintage-i18n-plugin.ts +11 -1
  141. package/src/styles.css +52 -16
  142. package/skills/ui-vintage/SKILL.md +0 -42
@@ -18,6 +18,7 @@ interface TocHeading {
18
18
  id: string;
19
19
  text: string;
20
20
  level: number;
21
+ number: string;
21
22
  }
22
23
 
23
24
  // Props
@@ -68,7 +69,8 @@ const headings = computed(() => {
68
69
  const currentHeading = {
69
70
  id: link.id ?? '',
70
71
  text: link.text ?? '',
71
- level: link.depth ?? 0
72
+ level: link.depth ?? 0,
73
+ number: ''
72
74
  };
73
75
 
74
76
  // Recursively flatten the children links
@@ -77,13 +79,23 @@ const headings = computed(() => {
77
79
  });
78
80
  };
79
81
 
80
- // Return the flattened list of headings
81
- return flattenLinks(tocLinks).filter(heading => heading.id && heading.text && heading.level >= 2 && heading.level <= 3);
82
+ // Keep h2 and h3 headings and number the main sections
83
+ let sectionNumber = 0;
84
+ return flattenLinks(tocLinks)
85
+ .filter(heading => heading.id && heading.text && heading.level >= 2 && heading.level <= 3)
86
+ .map(heading => ({
87
+ ...heading,
88
+ number: heading.level === 2 ? String(++sectionNumber).padStart(2, '0') : ''
89
+ }));
82
90
  });
83
91
 
92
+ // Number of main sections shown in the header
93
+ const sectionCount = computed(() => headings.value.filter(heading => heading.level === 2).length);
94
+
84
95
  // Title and description for the table of contents
85
96
  const tocTitle = computed(() => t('uiVintage.blog.tableOfContents'));
86
97
  const tocDescription = computed(() => t('uiVintage.blog.tableOfContentsDescription'));
98
+ const tocSections = computed(() => t('uiVintage.blog.tableOfContentsSections', { count: sectionCount.value }, sectionCount.value));
87
99
 
88
100
  // Prefer reduced motion for heading scroll
89
101
  const prefersReducedMotion = () => {
@@ -265,11 +277,14 @@ const scrollToHeading = (id: string, options: { closeMobile?: boolean } = {}) =>
265
277
  history.replaceState(null, '', `#${id}`);
266
278
  }
267
279
  };
268
- restoreMobileTriggerFocus = false;
269
-
270
280
  if (options.closeMobile && isMobileOpen.value) {
281
+ // The heading takes focus after the scroll, so skip the trigger focus restore for this close only
282
+ restoreMobileTriggerFocus = false;
271
283
  mobileTriggerRef.value?.focus();
272
284
  isMobileOpen.value = false;
285
+ nextTick(() => {
286
+ restoreMobileTriggerFocus = true;
287
+ });
273
288
  window.clearTimeout(mobileScrollTimeout);
274
289
  mobileScrollTimeout = window.setTimeout(runScroll, MOBILE_CLOSE_SCROLL_DELAY_MS);
275
290
  return;
@@ -286,21 +301,19 @@ const openMobileToc = () => {
286
301
  // Get classes for heading button
287
302
  const getHeadingButtonClasses = (level: number, isActive: boolean) => {
288
303
  // Base classes for all titles
289
- const baseClasses = 'block w-full text-left px-2.5 py-2 md:px-3 md:py-2 rounded border-l-2 border-transparent transition-all duration-200 hover:bg-accent cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 motion-reduce:transition-none';
290
-
291
- // Add classes based on heading level
292
- let levelClasses = '';
293
- if (level === 2) {
294
- levelClasses = 'text-foreground font-semibold text-xs md:text-sm';
295
- } else {
296
- levelClasses = 'text-muted-foreground pl-6! text-xs md:text-sm';
297
- }
304
+ const baseClasses = 'flex w-full cursor-pointer gap-3 rounded-r py-1.5 pr-2 text-left text-xs outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/45 md:text-sm';
305
+
306
+ // Main sections carry a number, subsections align with their text
307
+ const levelClasses = level === 2 ? 'pl-4 font-medium' : 'pl-12 md:text-xs';
298
308
 
299
- // Add classes if the heading is active
300
- const activeClasses = isActive ? 'bg-accent border-l-primary text-foreground' : '';
309
+ // Add classes based on the active state
310
+ let stateClasses = level === 2 ? 'text-foreground/80' : 'text-muted-foreground';
311
+ if (isActive) {
312
+ stateClasses = 'font-semibold text-foreground';
313
+ }
301
314
 
302
315
  // Return combined classes
303
- return [baseClasses, levelClasses, activeClasses];
316
+ return [baseClasses, levelClasses, stateClasses];
304
317
  };
305
318
 
306
319
  // Watch for changes in the headings and update the visible heading IDs and active heading ID accordingly
@@ -343,10 +356,22 @@ onUnmounted(() => {
343
356
  <!-- In-flow accordion; replaced by the mobile control after it scrolls away -->
344
357
  <div ref="inlineRef" class="mb-6" :aria-hidden="isDocked" :inert="isDocked">
345
358
  <Accordion :title="tocTitle" :icon="Bookmark01Icon">
346
- <nav :aria-label="tocTitle" class="space-y-1">
347
- <button v-for="heading in headings" :key="`inline-${heading.id}`" type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id)">
348
- {{ heading.text }}
349
- </button>
359
+ <!-- Section count -->
360
+ <template #trailing>
361
+ <span class="shrink-0 text-xs text-muted-foreground">{{ tocSections }}</span>
362
+ </template>
363
+
364
+ <nav :aria-label="tocTitle">
365
+ <ol>
366
+ <li v-for="heading in headings" :key="`inline-${heading.id}`" class="relative border-l border-border">
367
+ <!-- Active marker on the track -->
368
+ <span aria-hidden="true" :class="['absolute inset-y-1 -left-px w-0.5 origin-center bg-primary transition-transform duration-300 ease-spring', heading.id === activeHeadingId ? 'scale-y-100' : 'scale-y-0']" />
369
+ <button type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id)">
370
+ <span v-if="heading.number" :class="['w-5 shrink-0 text-[11px] leading-5 tabular-nums transition-colors', heading.id === activeHeadingId ? 'text-primary' : 'text-muted-foreground']">{{ heading.number }}</span>
371
+ <span>{{ heading.text }}</span>
372
+ </button>
373
+ </li>
374
+ </ol>
350
375
  </nav>
351
376
  </Accordion>
352
377
  </div>
@@ -354,20 +379,20 @@ onUnmounted(() => {
354
379
  <Teleport to="body">
355
380
  <!-- Mobile persistent control -->
356
381
  <Transition name="toc-tab">
357
- <button v-if="!isDesktop && isDocked" ref="mobileTriggerRef" type="button" class="fixed top-4/5 left-0 z-20 flex h-16 w-8 -translate-y-1/2 items-center justify-center rounded-r border border-l-0 border-(--border-light) bg-(--bg-card-light) text-(--text-secondary-light) transition-[background-color,color,transform] duration-200 hover:bg-(--bg-selected-light) hover:text-(--text-primary-light) focus-visible:z-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 dark:border-(--border-dark) dark:bg-(--bg-card-dark) dark:text-(--text-secondary-dark) dark:hover:bg-(--bg-selected-dark) dark:hover:text-(--text-primary-dark) motion-reduce:transition-none" :aria-label="t('uiVintage.blog.openTableOfContents')" :aria-expanded="isMobileOpen" aria-haspopup="dialog" aria-controls="toc-mobile-overlay" @click="openMobileToc">
358
- <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4" aria-hidden="true" />
382
+ <button v-if="!isDesktop && isDocked" ref="mobileTriggerRef" type="button" class="group/toc-tab fixed top-4/5 left-0 z-20 flex h-16 w-7 -translate-y-1/2 items-center justify-center rounded-r border border-l-0 border-border bg-card text-primary shadow-elevated-sm transition-[width] duration-300 ease-out-expo hover:w-8 focus-visible:z-30 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45" :aria-label="t('uiVintage.blog.openTableOfContents')" :aria-expanded="isMobileOpen" aria-haspopup="dialog" aria-controls="toc-mobile-overlay" @click="openMobileToc">
383
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-3.5 transition-[translate] duration-300 ease-spring group-hover/toc-tab:translate-x-0.5" aria-hidden="true" />
359
384
  </button>
360
385
  </Transition>
361
386
 
362
387
  <!-- Mobile full-screen overlay -->
363
388
  <Sheet :open="isMobileOpen" @update:open="handleMobileOpenChange">
364
- <SheetContent id="toc-mobile-overlay" side="left" class="flex h-full w-full max-w-none flex-col gap-0 border-0 bg-(--bg-card-light) p-0 dark:bg-(--bg-card-dark) sm:max-w-none">
365
- <SheetHeader class="flex-row items-center justify-between gap-4 border-b border-(--border-light) p-5 dark:border-(--border-dark)">
389
+ <SheetContent id="toc-mobile-overlay" side="left" class="flex h-full w-full max-w-none flex-col gap-0 border-0 bg-card p-0 data-[state=open]:[animation-duration:320ms] data-[state=open]:[animation-timing-function:var(--ease-out-expo)] sm:max-w-none">
390
+ <SheetHeader class="flex-row items-center justify-between gap-4 border-b border-border p-5">
366
391
  <!-- Mobile table of contents header -->
367
392
  <div class="flex min-w-0 items-center gap-3">
368
393
  <!-- Mobile table of contents icon -->
369
- <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded border border-(--border-light) bg-(--bg-card-light) dark:border-(--border-dark) dark:bg-(--bg-card-dark)">
370
- <HugeiconsIcon :icon="Bookmark01Icon" class="size-4 text-muted-foreground" />
394
+ <div class="flex size-7 shrink-0 items-center justify-center rounded border border-primary/30 text-primary">
395
+ <HugeiconsIcon :icon="Bookmark01Icon" class="size-3.5" />
371
396
  </div>
372
397
 
373
398
  <!-- Mobile table of contents title and description -->
@@ -378,6 +403,9 @@ onUnmounted(() => {
378
403
  <SheetDescription class="sr-only">
379
404
  {{ tocDescription }}
380
405
  </SheetDescription>
406
+ <p aria-hidden="true" class="text-xs text-muted-foreground">
407
+ {{ tocSections }}
408
+ </p>
381
409
  </div>
382
410
  </div>
383
411
 
@@ -391,11 +419,16 @@ onUnmounted(() => {
391
419
 
392
420
  <!-- Mobile table of contents navigation -->
393
421
  <nav :aria-label="tocTitle" class="flex-1 overflow-y-auto p-5">
394
- <div class="space-y-1">
395
- <button v-for="heading in headings" :key="`mobile-${heading.id}`" type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id, { closeMobile: true })">
396
- {{ heading.text }}
397
- </button>
398
- </div>
422
+ <ol>
423
+ <li v-for="heading in headings" :key="`mobile-${heading.id}`" class="relative border-l border-border">
424
+ <!-- Active marker on the track -->
425
+ <span aria-hidden="true" :class="['absolute inset-y-1 -left-px w-0.5 bg-primary', heading.id === activeHeadingId ? 'opacity-100' : 'opacity-0']" />
426
+ <button type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id, { closeMobile: true })">
427
+ <span v-if="heading.number" :class="['w-5 shrink-0 text-[11px] leading-5 tabular-nums', heading.id === activeHeadingId ? 'text-primary' : 'text-muted-foreground']">{{ heading.number }}</span>
428
+ <span>{{ heading.text }}</span>
429
+ </button>
430
+ </li>
431
+ </ol>
399
432
  </nav>
400
433
  </SheetContent>
401
434
  </Sheet>
@@ -404,14 +437,18 @@ onUnmounted(() => {
404
437
  </template>
405
438
 
406
439
  <style scoped>
407
- .toc-tab-enter-active,
440
+ .toc-tab-enter-active {
441
+ transition: opacity 0.3s var(--ease-out-expo), transform 0.32s var(--ease-out-expo);
442
+ }
443
+
408
444
  .toc-tab-leave-active {
409
- transition: opacity 0.25s ease;
445
+ transition: opacity 0.15s var(--ease-snappy), transform 0.15s var(--ease-snappy);
410
446
  }
411
447
 
412
448
  .toc-tab-enter-from,
413
449
  .toc-tab-leave-to {
414
450
  opacity: 0;
451
+ transform: translateX(-100%);
415
452
  }
416
453
 
417
454
  @media (prefers-reduced-motion: reduce) {
@@ -2,6 +2,7 @@ export { default as AllPostsSection } from './AllPostsSection.vue';
2
2
  export { default as BlogContentRenderer } from './BlogContentRenderer.vue';
3
3
  export { default as BlogFAQSection } from './BlogFAQSection.vue';
4
4
  export { default as BlogInfoSection } from './BlogInfoSection.vue';
5
+ export { default as BlogPostHeader } from './BlogPostHeader.vue';
5
6
  export { default as BlogHeaderSection } from './BlogHeaderSection.vue';
6
7
  export { default as BlogSectionTitle } from './BlogSectionTitle.vue';
7
8
  export { default as BlogTagsSection } from './BlogTagsSection.vue';
@@ -52,8 +52,8 @@ const handleLoadMore = () => {
52
52
  <slot name="card" v-for="item in props.items" :item="item" :key="item.id" />
53
53
  </div>
54
54
 
55
- <!-- Load more button -->
56
- <div v-if="props.hasMore || (props.loadMoreBusy && props.items.length > 0)" class="flex justify-center mt-6">
55
+ <!-- Load more button (only under a grid, never under the loading or empty state) -->
56
+ <div v-if="!props.busy && props.items.length > 0 && (props.hasMore || props.loadMoreBusy)" class="flex justify-center mt-6">
57
57
  <LoadMoreButton :busy="props.loadMoreBusy"
58
58
  @load-more="handleLoadMore" />
59
59
  </div>
@@ -11,6 +11,11 @@ const pendingAction = ref<'confirm' | 'cancel' | null>(null);
11
11
 
12
12
  // Run an action while showing a spinner on the button that triggered it
13
13
  const runAction = async (action: 'confirm' | 'cancel') => {
14
+ // Ignore clicks while another action is still running
15
+ if (pendingAction.value) {
16
+ return;
17
+ }
18
+
14
19
  pendingAction.value = action;
15
20
  try {
16
21
  await (action === 'confirm' ? confirmActiveDialog() : cancelActiveConfirmDialog());
@@ -120,10 +120,10 @@ const clearCurrentConfirmDialog = () => {
120
120
  openNextConfirmDialog();
121
121
  };
122
122
 
123
- // Close the current dialog after the leave transition
124
- const scheduleCloseCurrentConfirmDialog = () => {
125
- // Guard against missing or already closed dialogs
126
- if (!confirmDialogState.current || !confirmDialogState.isOpen) return;
123
+ // Close the current dialog after the leave transition (only if it is still the given request)
124
+ const scheduleCloseCurrentConfirmDialog = (request: ConfirmDialogRequest) => {
125
+ // Guard against missing, replaced, or already closed dialogs
126
+ if (confirmDialogState.current !== request || !confirmDialogState.isOpen) return;
127
127
  confirmDialogState.isOpen = false;
128
128
 
129
129
  // Clear any existing close timer to avoid racing conditions
@@ -138,40 +138,40 @@ const scheduleCloseCurrentConfirmDialog = () => {
138
138
  }, DIALOG_CLOSE_DURATION_MS);
139
139
  };
140
140
 
141
- // Resolve the dialog as cancelled
141
+ // Resolve the dialog as cancelled (ignored while a confirm or cancel handler is still running)
142
142
  export const closeConfirmDialog = () => {
143
143
  const current = confirmDialogState.current;
144
- if (!current || !confirmDialogState.isOpen) return;
144
+ if (!current || !confirmDialogState.isOpen || confirmDialogState.busy) return;
145
145
  current.resolve(false);
146
- scheduleCloseCurrentConfirmDialog();
146
+ scheduleCloseCurrentConfirmDialog(current);
147
147
  };
148
148
 
149
- // Run the cancel handler for the active dialog
150
- export const cancelActiveConfirmDialog = async () => {
149
+ // Run a dialog handler, then resolve and close the dialog it belongs to
150
+ const runConfirmDialogHandler = async (handler: (() => void | Promise<void>) | null, result: boolean) => {
151
151
  const current = confirmDialogState.current;
152
- if (!current || confirmDialogState.busy) return;
152
+ if (!current || !confirmDialogState.isOpen || confirmDialogState.busy) return;
153
153
  confirmDialogState.busy = true;
154
154
 
155
155
  try {
156
- await current.options.onCancel?.();
156
+ await handler?.();
157
+ current.resolve(result);
158
+ } catch (error) {
159
+ // Settle the caller's promise even when the handler throws, then surface the error
157
160
  current.resolve(false);
161
+ throw error;
158
162
  } finally {
159
- scheduleCloseCurrentConfirmDialog();
163
+ scheduleCloseCurrentConfirmDialog(current);
160
164
  }
161
165
  };
162
166
 
167
+ // Run the cancel handler for the active dialog
168
+ export const cancelActiveConfirmDialog = async () => {
169
+ await runConfirmDialogHandler(confirmDialogState.current?.options.onCancel ?? null, false);
170
+ };
171
+
163
172
  // Run the confirm handler for the active dialog
164
173
  export const confirmActiveDialog = async () => {
165
- const current = confirmDialogState.current;
166
- if (!current || confirmDialogState.busy) return;
167
- confirmDialogState.busy = true;
168
-
169
- try {
170
- await current.options.onConfirm?.();
171
- current.resolve(true);
172
- } finally {
173
- scheduleCloseCurrentConfirmDialog();
174
- }
174
+ await runConfirmDialogHandler(confirmDialogState.current?.options.onConfirm ?? null, true);
175
175
  };
176
176
 
177
177
  // Queue and show a confirm dialog
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { Cancel01Icon, CheckmarkCircle02Icon, CircleSmallIcon } from '@hugeicons/core-free-icons';
2
+ import { Cancel01Icon, Tick02Icon } from '@hugeicons/core-free-icons';
3
3
  import { HugeiconsIcon } from '@hugeicons/vue';
4
- import { Card, CardContent } from '../ui/card';
4
+ import { Card } from '../ui/card';
5
5
 
6
6
  // Props
7
7
  const props = withDefaults(defineProps<{
@@ -12,52 +12,40 @@ const props = withDefaults(defineProps<{
12
12
  variant: 'checkmark'
13
13
  });
14
14
 
15
- // Icon container classes
16
- const getIconContainerClasses = () => {
17
- return 'flex items-center justify-center w-8 h-8 bg-card border border-border rounded mr-4 shrink-0';
15
+ // Marker classes
16
+ const getMarkerClasses = () => {
17
+ const toneClasses = {
18
+ checkmark: 'rounded bg-success/10 text-success',
19
+ cross: 'rounded bg-destructive/10 text-destructive',
20
+ numbered: 'text-[11px] font-semibold tabular-nums text-primary',
21
+ circle: ''
22
+ };
23
+ return ['mt-px flex size-5 shrink-0 items-center justify-center', toneClasses[props.variant]];
18
24
  };
19
25
 
20
- // Icon color classes
21
- const getIconClasses = () => {
22
- return 'text-muted-foreground text-xs';
26
+ // Format the item number with a leading zero
27
+ const formatNumber = (index: number) => {
28
+ return String(index + 1).padStart(2, '0');
23
29
  };
24
30
  </script>
25
31
 
26
32
  <template>
27
33
  <div class="not-prose my-6 sm:my-8">
28
- <Card>
29
- <CardContent class="gap-0">
30
- <ul class="text-xs md:text-sm p-0! space-y-6!">
31
- <li v-for="(item, index) in props.items" :key="index" class="flex items-center">
32
- <!-- Circle -->
33
- <div v-if="props.variant === 'circle'" :class="getIconContainerClasses()">
34
- <HugeiconsIcon :icon="CircleSmallIcon" :class="getIconClasses()" />
35
- </div>
36
-
37
- <!-- Checkmark -->
38
- <div v-else-if="props.variant === 'checkmark'" :class="getIconContainerClasses()">
39
- <HugeiconsIcon :icon="CheckmarkCircle02Icon" :class="getIconClasses()" />
40
- </div>
41
-
42
- <!-- Numbered -->
43
- <div v-else-if="props.variant === 'numbered'" :class="getIconContainerClasses()">
44
- <span :class="[getIconClasses(), 'font-semibold']">{{ index + 1 }}</span>
45
- </div>
46
-
47
- <!-- Cross -->
48
- <div v-else-if="props.variant === 'cross'" :class="getIconContainerClasses()">
49
- <HugeiconsIcon :icon="Cancel01Icon" :class="getIconClasses()" />
50
- </div>
51
-
52
- <!-- Content -->
53
- <div class="flex-1">
54
- <div class="text-foreground leading-relaxed">
55
- {{ item }}
56
- </div>
57
- </div>
58
- </li>
59
- </ul>
60
- </CardContent>
34
+ <Card data-aos="blur-up" class="gap-0! py-0! overflow-hidden">
35
+ <ul class="divide-y divide-border">
36
+ <li v-for="(item, index) in props.items" :key="index" data-aos="blur-up" :data-aos-delay="100 + index * 80" class="flex items-start gap-3 px-4 py-3.5 text-xs leading-relaxed text-foreground sm:px-5 md:text-sm">
37
+ <!-- Marker -->
38
+ <span aria-hidden="true" :class="getMarkerClasses()">
39
+ <HugeiconsIcon v-if="props.variant === 'checkmark'" :icon="Tick02Icon" :stroke-width="2.5" class="size-3" />
40
+ <HugeiconsIcon v-else-if="props.variant === 'cross'" :icon="Cancel01Icon" :stroke-width="2.5" class="size-3" />
41
+ <template v-else-if="props.variant === 'numbered'">{{ formatNumber(index) }}</template>
42
+ <span v-else class="size-1.5 rounded-full bg-primary" />
43
+ </span>
44
+
45
+ <!-- Content -->
46
+ <span class="flex-1">{{ item }}</span>
47
+ </li>
48
+ </ul>
61
49
  </Card>
62
50
  </div>
63
- </template>
51
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { Card, CardContent } from '../ui/card';
2
+ import { Card } from '../ui/card';
3
3
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
4
4
 
5
5
  // Props
@@ -15,46 +15,42 @@ const props = withDefaults(defineProps<{
15
15
 
16
16
  // Header cell classes
17
17
  const getHeaderClasses = (colIndex: number) => {
18
- const baseClasses = 'text-left font-semibold text-foreground';
19
- const highlightClass = props.highlightCol === colIndex ? 'bg-accent' : '';
18
+ const baseClasses = 'h-10 px-4 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground sm:px-5';
19
+ const highlightClass = props.highlightCol === colIndex ? 'text-primary shadow-[inset_0_-2px_0_var(--primary)]' : '';
20
20
  return [baseClasses, highlightClass];
21
21
  };
22
22
 
23
23
  // Body cell classes
24
24
  const getCellClasses = (colIndex: number) => {
25
- const baseClasses = 'align-top text-foreground leading-relaxed whitespace-normal';
26
- const firstColClass = colIndex === 0 ? 'font-semibold text-muted-foreground' : '';
27
- const highlightClass = props.highlightCol === colIndex ? 'bg-accent' : '';
25
+ const baseClasses = 'min-w-32 px-4 py-3 align-top leading-relaxed whitespace-normal sm:px-5';
26
+ const firstColClass = colIndex === 0 ? 'font-medium text-foreground' : 'text-muted-foreground';
27
+ const highlightClass = props.highlightCol === colIndex ? 'bg-primary/5 text-foreground' : '';
28
28
  return [baseClasses, firstColClass, highlightClass];
29
29
  };
30
30
  </script>
31
31
 
32
32
  <template>
33
33
  <div class="not-prose my-6 sm:my-8">
34
- <Card>
35
- <CardContent>
36
- <div class="w-full overflow-x-auto">
37
- <Table class="w-full text-sm text-left">
38
- <!-- Table header -->
39
- <TableHeader>
40
- <TableRow class="bg-transparent text-sm text-muted-foreground hover:bg-transparent">
41
- <TableHead v-for="(header, colIndex) in props.headers" :key="colIndex" :class="getHeaderClasses(colIndex)">
42
- {{ header }}
43
- </TableHead>
44
- </TableRow>
45
- </TableHeader>
34
+ <Card data-aos="blur-up" class="gap-0! py-0! overflow-hidden">
35
+ <Table class="w-full text-left text-sm">
36
+ <!-- Table header -->
37
+ <TableHeader>
38
+ <TableRow class="bg-surface hover:bg-surface">
39
+ <TableHead v-for="(header, colIndex) in props.headers" :key="colIndex" :class="getHeaderClasses(colIndex)">
40
+ {{ header }}
41
+ </TableHead>
42
+ </TableRow>
43
+ </TableHeader>
46
44
 
47
- <!-- Table body -->
48
- <TableBody>
49
- <TableRow v-for="(row, rowIndex) in props.rows" :key="rowIndex" class="text-muted-foreground hover:bg-transparent">
50
- <TableCell v-for="(cell, colIndex) in row" :key="colIndex" :class="getCellClasses(colIndex)">
51
- {{ cell }}
52
- </TableCell>
53
- </TableRow>
54
- </TableBody>
55
- </Table>
56
- </div>
57
- </CardContent>
45
+ <!-- Table body -->
46
+ <TableBody>
47
+ <TableRow v-for="(row, rowIndex) in props.rows" :key="rowIndex" class="last:border-b-0 hover:bg-surface/60">
48
+ <TableCell v-for="(cell, colIndex) in row" :key="colIndex" :class="getCellClasses(colIndex)">
49
+ {{ cell }}
50
+ </TableCell>
51
+ </TableRow>
52
+ </TableBody>
53
+ </Table>
58
54
  </Card>
59
55
  </div>
60
- </template>
56
+ </template>