@nuxtseo/design-system 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -2
- package/app/app.config.ts +596 -0
- package/app/component-attributes.d.ts +8 -0
- package/app/components/README.md +38 -0
- package/app/components/brand/UiBrandMark.vue +63 -0
- package/app/components/brand/UiModuleIcon.vue +45 -0
- package/app/components/brand/UiProLettering.vue +26 -0
- package/app/components/brand/UiRangeTerrain.vue +311 -0
- package/app/components/brand/range-glyphs.ts +18 -0
- package/app/components/container/BrowserTab.vue +145 -0
- package/app/components/container/IntegrationRow.vue +120 -0
- package/app/components/container/KnowDoRails.vue +40 -0
- package/app/components/container/RailPanel.vue +93 -0
- package/app/components/container/UiAlert.vue +120 -0
- package/app/components/container/UiAppShell.vue +165 -0
- package/app/components/container/UiCard.vue +204 -0
- package/app/components/container/UiDisclosure.vue +43 -0
- package/app/components/container/UiEmptyState.vue +102 -0
- package/app/components/container/UiErrorState.vue +61 -0
- package/app/components/container/UiLoadingState.vue +20 -0
- package/app/components/container/UiPageHeader.vue +95 -0
- package/app/components/container/UiPopover.vue +102 -0
- package/app/components/container/UiSampleDataOverlay.vue +47 -0
- package/app/components/container/UiSectionHeader.vue +83 -0
- package/app/components/container/UiSummaryStrip.vue +40 -0
- package/app/components/container/UiToast.vue +230 -0
- package/app/components/container/UiToaster.vue +271 -0
- package/app/components/container/UiWidgetState.vue +143 -0
- package/app/components/container/ui-nav-drawer.ts +48 -0
- package/app/components/data/ActionRail.vue +152 -0
- package/app/components/data/FacetStrip.vue +93 -0
- package/app/components/data/UiBarCell.vue +37 -0
- package/app/components/data/UiBarChart.vue +281 -0
- package/app/components/data/UiChartAnnotationControls.vue +74 -0
- package/app/components/data/UiChartAnnotations.vue +163 -0
- package/app/components/data/UiChartBrushOverlay.vue +96 -0
- package/app/components/data/UiChartDataReveal.vue +81 -0
- package/app/components/data/UiChartFrame.vue +123 -0
- package/app/components/data/UiChartTooltip.vue +81 -0
- package/app/components/data/UiCoverageNote.vue +90 -0
- package/app/components/data/UiCwvSparkline.vue +188 -0
- package/app/components/data/UiDataList.vue +276 -0
- package/app/components/data/UiDataTableSection.vue +232 -0
- package/app/components/data/UiDistributionBars.vue +164 -0
- package/app/components/data/UiDonutChart.vue +132 -0
- package/app/components/data/UiDoorwayColumn.vue +108 -0
- package/app/components/data/UiDoorwayGrid.vue +58 -0
- package/app/components/data/UiDoorwayRow.vue +83 -0
- package/app/components/data/UiEntitySummary.vue +261 -0
- package/app/components/data/UiFactsGrid.vue +107 -0
- package/app/components/data/UiImpactMeter.vue +33 -0
- package/app/components/data/UiIssueRow.vue +149 -0
- package/app/components/data/UiLineChart.vue +431 -0
- package/app/components/data/UiMetricBarPopover.vue +121 -0
- package/app/components/data/UiMetricLabel.vue +30 -0
- package/app/components/data/UiMetricStat.vue +73 -0
- package/app/components/data/UiMetricToggle.vue +156 -0
- package/app/components/data/UiMetricsRow.vue +64 -0
- package/app/components/data/UiPositionMetric.vue +53 -0
- package/app/components/data/UiRelatedFixes.vue +113 -0
- package/app/components/data/UiRichText.vue +325 -0
- package/app/components/data/UiScatterPlot.vue +144 -0
- package/app/components/data/UiSeverityMarker.vue +30 -0
- package/app/components/data/UiSparkColumns.vue +160 -0
- package/app/components/data/UiSparkline.vue +681 -0
- package/app/components/data/UiStat.vue +445 -0
- package/app/components/data/UiStats.vue +76 -0
- package/app/components/data/UiTable.vue +517 -0
- package/app/components/data/UiTableExport.vue +40 -0
- package/app/components/data/UiTableFrame.vue +149 -0
- package/app/components/data/UiTablePagination.vue +37 -0
- package/app/components/data/UiTableShell.vue +48 -0
- package/app/components/data/UiTableTd.vue +39 -0
- package/app/components/data/UiTableTh.vue +61 -0
- package/app/components/data/UiTopEntityStackChart.vue +393 -0
- package/app/components/data/cells/UiTableDash.vue +3 -0
- package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
- package/app/components/data/cells/UiTableMetricCell.vue +72 -0
- package/app/components/data/cells/UiTablePathCell.vue +76 -0
- package/app/components/data/cells/UiTableScoreTile.vue +49 -0
- package/app/components/data/cells/UiTableTrendCell.vue +41 -0
- package/app/components/element/UiAtmosphere.vue +64 -0
- package/app/components/element/UiAuthHeading.vue +24 -0
- package/app/components/element/UiAuthProviders.vue +46 -0
- package/app/components/element/UiBrandIcon.vue +39 -0
- package/app/components/element/UiButton.vue +549 -0
- package/app/components/element/UiCelebration.vue +65 -0
- package/app/components/element/UiChannelChips.vue +62 -0
- package/app/components/element/UiChatQuickLink.vue +319 -0
- package/app/components/element/UiChip.vue +101 -0
- package/app/components/element/UiConfirmDialog.vue +84 -0
- package/app/components/element/UiCopyButton.vue +90 -0
- package/app/components/element/UiDitherAvatar.vue +177 -0
- package/app/components/element/UiDotLabel.vue +19 -0
- package/app/components/element/UiEjectMenu.vue +181 -0
- package/app/components/element/UiEjectOverlay.vue +29 -0
- package/app/components/element/UiFavicon.vue +235 -0
- package/app/components/element/UiFaviconStack.vue +84 -0
- package/app/components/element/UiFilterMenu.vue +324 -0
- package/app/components/element/UiFocusedSurface.vue +129 -0
- package/app/components/element/UiHealthDot.vue +32 -0
- package/app/components/element/UiHelpLabel.vue +28 -0
- package/app/components/element/UiIcon.vue +22 -0
- package/app/components/element/UiInput.vue +35 -0
- package/app/components/element/UiLogoPlausible.vue +12 -0
- package/app/components/element/UiLogoPro.vue +25 -0
- package/app/components/element/UiLogoUmami.vue +12 -0
- package/app/components/element/UiNavIcon.vue +107 -0
- package/app/components/element/UiNavList.vue +156 -0
- package/app/components/element/UiOnboardingAnswers.vue +125 -0
- package/app/components/element/UiOverflowCount.vue +31 -0
- package/app/components/element/UiProBadge.vue +19 -0
- package/app/components/element/UiProgressCircle.vue +79 -0
- package/app/components/element/UiProgressPercent.vue +35 -0
- package/app/components/element/UiRelativeTime.vue +58 -0
- package/app/components/element/UiScrollFade.vue +16 -0
- package/app/components/element/UiSelect.vue +55 -0
- package/app/components/element/UiSeverityDot.vue +27 -0
- package/app/components/element/UiSiteFavicon.vue +24 -0
- package/app/components/element/UiSkeleton.vue +148 -0
- package/app/components/element/UiSlotText.vue +65 -0
- package/app/components/element/UiSourceLogos.vue +113 -0
- package/app/components/element/UiSourceRail.vue +71 -0
- package/app/components/element/UiStaticTerminal.vue +107 -0
- package/app/components/element/UiStatusBadge.vue +62 -0
- package/app/components/element/UiSyncDot.vue +46 -0
- package/app/components/element/UiTabs.vue +245 -0
- package/app/components/element/UiTogglePill.vue +86 -0
- package/app/components/element/UiTooltip.vue +390 -0
- package/app/components/element/UiTooltipGrid.vue +457 -0
- package/app/components/element/UiTrend.vue +184 -0
- package/app/components/element/UiTrialBenefits.vue +71 -0
- package/app/components/element/UiUrlLabel.vue +75 -0
- package/app/components/element/UiValenceDot.vue +37 -0
- package/app/components/element/UiWizardModal.vue +284 -0
- package/app/components/element/UiWizardNav.vue +59 -0
- package/app/components/element/UiWizardProgress.vue +78 -0
- package/app/components/element/UiWizardShell.vue +43 -0
- package/app/components/element/eject-menu.ts +359 -0
- package/app/components/element/eject-tracker.ts +34 -0
- package/app/components/element/onboarding-answers.ts +68 -0
- package/app/components/element/onboarding-intents.ts +19 -0
- package/app/composables/dataVizColors.ts +350 -0
- package/app/composables/formatting.ts +252 -0
- package/app/composables/semanticColors.ts +80 -0
- package/app/composables/useActivityOverlay.ts +55 -0
- package/app/composables/useAppShellNav.ts +21 -0
- package/app/composables/useChartBrush.ts +221 -0
- package/app/composables/useChartHover.ts +153 -0
- package/app/composables/useChartTickPlan.ts +186 -0
- package/app/composables/useCloseOnNavigation.ts +7 -0
- package/app/composables/useConfirm.ts +38 -0
- package/app/composables/useEdgeFade.ts +37 -0
- package/app/composables/useEject.ts +69 -0
- package/app/composables/useEntranceMotion.ts +22 -0
- package/app/composables/useFaviconBacking.ts +164 -0
- package/app/composables/useGridCols.ts +39 -0
- package/app/composables/useIssueOverlay.ts +43 -0
- package/app/composables/useOnboardingService.ts +106 -0
- package/app/composables/useOpenedOnce.ts +19 -0
- package/app/composables/useScrollSpy.ts +178 -0
- package/app/composables/useSquircleFallback.ts +21 -0
- package/app/composables/useWizardRoute.ts +123 -0
- package/app/plugins/favicon.ts +29 -0
- package/app/plugins/lazy-motion.ts +22 -0
- package/app/plugins/selection-highlight.client.ts +176 -0
- package/app/shared/cli.ts +10 -0
- package/app/shared/error-state.ts +150 -0
- package/app/shared/icons.ts +1 -0
- package/app/shared/motion.ts +1 -0
- package/app/shared/nav.ts +38 -0
- package/app/shared/navigation.ts +5 -0
- package/app/shared/static-terminal.ts +14 -0
- package/app/shared/table-export.ts +36 -0
- package/app/shared/table.ts +105 -0
- package/app/shared/ui-icons.ts +1 -0
- package/app/shared/view-state.ts +85 -0
- package/app/shared/widget-failure.ts +126 -0
- package/app/utils/chartAnnotations.ts +326 -0
- package/app/utils/countries.ts +337 -0
- package/app/utils/export-download.ts +100 -0
- package/app/utils/favicon-fallback.ts +12 -0
- package/app/utils/icon-color.ts +105 -0
- package/app/utils/number.ts +74 -0
- package/app/utils/render-link.ts +14 -0
- package/app/utils/slot-text.ts +18 -0
- package/app/utils/squircle.ts +154 -0
- package/app/utils/topEntityStack.ts +197 -0
- package/app/utils/vue.ts +57 -0
- package/build/design-system.ts +47 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1892 -0
- package/nuxt.config.ts +132 -0
- package/package.json +86 -3
- package/public/brand-icon-dark.png +0 -0
- package/public/brand-icon-light.png +0 -0
- package/public/favicon-dark.png +0 -0
- package/public/favicon-light.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/fonts/README.md +31 -0
- package/public/fonts/fira-code-OFL.txt +93 -0
- package/public/fonts/fira-code-var-latin.woff2 +0 -0
- package/public/fonts/hubot-sans-OFL.txt +93 -0
- package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
- package/public/icon-dark.png +0 -0
- package/public/icon.png +0 -0
- package/public/touch-icon-dark.png +0 -0
- package/public/touch-icon.png +0 -0
- package/server/api/favicon.get.ts +122 -0
- package/server/utils/favicon-source.ts +51 -0
- package/shared/contracts/feedback.ts +42 -0
- package/shared/contracts/index.ts +1 -0
- package/shared/delivery-channels.ts +53 -0
- package/shared/icons/index.ts +143 -0
- package/shared/icons/registry.ts +347 -0
- package/shared/icons/ui-slots.ts +58 -0
- package/shared/markdown.ts +44 -0
- package/shared/motion.ts +120 -0
- package/shared/rangi.ts +60 -0
- package/shared/rich-text.ts +34 -0
- package/shared/ui-icons.ts +6 -0
- package/shared/utils/urls.ts +64 -0
- package/types/index.ts +1 -0
- package/types/ui.ts +339 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SemanticStatus } from '../../composables/semanticColors'
|
|
3
|
+
import type { UiIcon as UiIconName } from '../../shared/ui-icons'
|
|
4
|
+
import type { EjectChat, EjectLink, EjectMcp } from '../element/eject-menu'
|
|
5
|
+
import { computed, useSlots } from 'vue'
|
|
6
|
+
import { UiChip, UiEjectMenu, UiIcon, UiNavIcon, UiTooltip, ULink } from '#components'
|
|
7
|
+
// Within-a-page section title: icon + heading, optional status badge, info
|
|
8
|
+
// tooltip, description, and a trailing actions area (defaults to a "View all"
|
|
9
|
+
// link when `to` is set), plus optional eject targets.
|
|
10
|
+
const {
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
icon,
|
|
14
|
+
badge,
|
|
15
|
+
badgeStatus = 'neutral',
|
|
16
|
+
tooltip,
|
|
17
|
+
to,
|
|
18
|
+
actionLabel = 'View all',
|
|
19
|
+
chat,
|
|
20
|
+
mcp,
|
|
21
|
+
schema,
|
|
22
|
+
} = defineProps<{
|
|
23
|
+
title: string
|
|
24
|
+
description?: string
|
|
25
|
+
icon?: UiIconName
|
|
26
|
+
/** Status count/label chip beside the title. */
|
|
27
|
+
badge?: string | number
|
|
28
|
+
badgeStatus?: SemanticStatus
|
|
29
|
+
/** Info tooltip beside the title. */
|
|
30
|
+
tooltip?: string
|
|
31
|
+
/** Trailing "View all"-style link target. Overridden by the #actions slot. */
|
|
32
|
+
to?: string
|
|
33
|
+
actionLabel?: string
|
|
34
|
+
/** Optional eject targets rendered beside the caller's actions. */
|
|
35
|
+
chat?: EjectChat
|
|
36
|
+
mcp?: EjectMcp
|
|
37
|
+
schema?: EjectLink
|
|
38
|
+
}>()
|
|
39
|
+
|
|
40
|
+
const slots = useSlots()
|
|
41
|
+
const hasEject = computed(() => !!(chat || mcp || schema))
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<div class="flex items-center justify-between gap-3 mb-3">
|
|
46
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
47
|
+
<!-- A section that reads through one Source passes its mark here, in
|
|
48
|
+
place of the decorative icon. -->
|
|
49
|
+
<slot name="mark">
|
|
50
|
+
<UiNavIcon v-if="icon" :icon="icon" />
|
|
51
|
+
</slot>
|
|
52
|
+
<div class="min-w-0">
|
|
53
|
+
<h2 class="text-heading text-default flex items-center gap-2">
|
|
54
|
+
<slot name="title">
|
|
55
|
+
{{ title }}
|
|
56
|
+
</slot>
|
|
57
|
+
<UiChip v-if="badge != null" purpose="status" :status="badgeStatus" tabular>
|
|
58
|
+
{{ badge }}
|
|
59
|
+
</UiChip>
|
|
60
|
+
<UiTooltip v-if="tooltip" :text="tooltip" trigger-as="button">
|
|
61
|
+
<UiIcon name="note" class="size-3 text-dimmed" aria-hidden="true" />
|
|
62
|
+
</UiTooltip>
|
|
63
|
+
<slot name="after-title" />
|
|
64
|
+
</h2>
|
|
65
|
+
<p v-if="description" class="text-mini text-muted mt-0.5 leading-snug">
|
|
66
|
+
{{ description }}
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="flex items-center gap-2 shrink-0">
|
|
71
|
+
<slot v-if="slots.actions" name="actions" />
|
|
72
|
+
<ULink
|
|
73
|
+
v-else-if="to && !hasEject"
|
|
74
|
+
:to="to"
|
|
75
|
+
class="text-xs text-muted hover:text-default transition-colors inline-flex items-center gap-1"
|
|
76
|
+
>
|
|
77
|
+
{{ actionLabel }}
|
|
78
|
+
<UiIcon name="next" class="size-3" aria-hidden="true" />
|
|
79
|
+
</ULink>
|
|
80
|
+
<UiEjectMenu v-if="hasEject" :chat="chat" :mcp="mcp" :schema="schema" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SemanticStatus } from '../../composables/semanticColors'
|
|
3
|
+
import { NuxtLink, UiIcon } from '#components'
|
|
4
|
+
import { semanticColors } from '../../composables/semanticColors'
|
|
5
|
+
|
|
6
|
+
// One-line diagnosis rollup — the "collapse a card to a sentence" overflow
|
|
7
|
+
// move (DESIGN.md Composition rules). A dot carries the status, the sentence
|
|
8
|
+
// carries the diagnosis, `to` carries the drill-in. Deliberately one signal
|
|
9
|
+
// per line: no icon container, no eyebrow, no second metadata row. If the
|
|
10
|
+
// content needs more than a sentence + counts, it has outgrown the strip and
|
|
11
|
+
// wants a card or its own page.
|
|
12
|
+
|
|
13
|
+
const { status = 'neutral', title, to, linkLabel = 'View all' } = defineProps<{
|
|
14
|
+
status?: SemanticStatus
|
|
15
|
+
/** The diagnosis sentence — lead with the noun and the number ("7 broken internal links"). */
|
|
16
|
+
title: string
|
|
17
|
+
/** Drill-in destination. When set the whole row is a NuxtLink with a trailing chevron (canonical clickable-row pattern). */
|
|
18
|
+
to?: string
|
|
19
|
+
linkLabel?: string
|
|
20
|
+
}>()
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<component
|
|
25
|
+
:is="to ? NuxtLink : 'div'"
|
|
26
|
+
:to="to || undefined"
|
|
27
|
+
class="group flex items-center gap-2.5 rounded-lg border border-default bg-[var(--ui-bg-elevated)]/5 px-3 py-2 min-h-11 sm:min-h-0"
|
|
28
|
+
:class="to && 'hover:bg-[var(--ui-bg-elevated)]/50 transition-colors focus-visible:ring-2 focus-visible:ring-primary outline-none'"
|
|
29
|
+
>
|
|
30
|
+
<span class="size-2 rounded-full shrink-0" :class="semanticColors[status].dot" aria-hidden="true" />
|
|
31
|
+
<slot name="leading" />
|
|
32
|
+
<span class="text-sm text-default truncate">{{ title }}</span>
|
|
33
|
+
<span class="flex-1" />
|
|
34
|
+
<slot name="meta" />
|
|
35
|
+
<template v-if="to">
|
|
36
|
+
<span class="text-xs text-muted group-hover:text-default transition-colors shrink-0">{{ linkLabel }}</span>
|
|
37
|
+
<UiIcon name="chevron-right" class="size-3.5 text-dimmed group-hover:text-default transition-colors shrink-0" aria-hidden="true" />
|
|
38
|
+
</template>
|
|
39
|
+
</component>
|
|
40
|
+
</template>
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Component } from 'vue'
|
|
3
|
+
import type { SemanticStatus } from '../../composables/semanticColors'
|
|
4
|
+
import { useElementSize } from '@vueuse/core'
|
|
5
|
+
import { computed, useTemplateRef, watch } from 'vue'
|
|
6
|
+
import { UiButton, UiIcon } from '#components'
|
|
7
|
+
import { semanticColors } from '../../composables/semanticColors'
|
|
8
|
+
|
|
9
|
+
// A single toast card. Pure presentation: owns our chrome (neutral surface;
|
|
10
|
+
// semantic accent rides the icon + 1px top bar + corner bloom only, per the
|
|
11
|
+
// color-budget rule — mirrors UiAlert) plus the auto-dismiss progress bar.
|
|
12
|
+
// All positioning / stacking / motion / timers / drag live in UiToaster, which
|
|
13
|
+
// renders this inside a `<motion.li>`. Rendered exclusively from the shared
|
|
14
|
+
// useToast() queue, so every existing `toast.add(...)` call site keeps working.
|
|
15
|
+
|
|
16
|
+
export interface ToastAction {
|
|
17
|
+
label: string
|
|
18
|
+
icon?: string
|
|
19
|
+
to?: string
|
|
20
|
+
onClick?: (e: MouseEvent) => void
|
|
21
|
+
/** Visual emphasis. `primary` = filled, `neutral` = quiet. */
|
|
22
|
+
color?: 'primary' | 'neutral'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UiToastProps {
|
|
26
|
+
/** Heading text (string or a render component). */
|
|
27
|
+
title?: string | Component
|
|
28
|
+
/** Body text (string or a render component). */
|
|
29
|
+
description?: string | Component
|
|
30
|
+
/** Semantic status — drives accent + default icon. Wins over `color`. */
|
|
31
|
+
status?: SemanticStatus
|
|
32
|
+
/** Nuxt UI color compatibility for existing `toast.add({ color })` call sites. */
|
|
33
|
+
color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
|
|
34
|
+
/** Icon override. Defaults to the status icon. */
|
|
35
|
+
icon?: string
|
|
36
|
+
/** Inline action buttons. */
|
|
37
|
+
actions?: ToastAction[]
|
|
38
|
+
/**
|
|
39
|
+
* Bug-report handler. When set, failure toasts (error/warning) that carry no
|
|
40
|
+
* action of their own get a "Report bug" button. Wired by the app (the
|
|
41
|
+
* design system must not reach for the feedback drawer itself).
|
|
42
|
+
*/
|
|
43
|
+
reportBug?: (toast: { title?: string, description?: string }) => void
|
|
44
|
+
/** Show the close button. */
|
|
45
|
+
close?: boolean
|
|
46
|
+
/** Auto-dismiss progress 0..1 (remaining), or false to hide the bar. */
|
|
47
|
+
progress?: number | false
|
|
48
|
+
/** Collapsed back card — fade content so the deck reads as peeking cards. */
|
|
49
|
+
peek?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
title,
|
|
54
|
+
description,
|
|
55
|
+
status,
|
|
56
|
+
color,
|
|
57
|
+
icon,
|
|
58
|
+
actions,
|
|
59
|
+
reportBug,
|
|
60
|
+
close = true,
|
|
61
|
+
progress = false,
|
|
62
|
+
peek = false,
|
|
63
|
+
} = defineProps<UiToastProps>()
|
|
64
|
+
|
|
65
|
+
const emit = defineEmits<{
|
|
66
|
+
close: []
|
|
67
|
+
/** Reports the card's natural height to the toaster for fan-out offsets. */
|
|
68
|
+
measure: [number]
|
|
69
|
+
}>()
|
|
70
|
+
|
|
71
|
+
// Map legacy Nuxt UI `color` to our semantic vocabulary. `status` wins if set.
|
|
72
|
+
const COLOR_TO_STATUS: Record<NonNullable<UiToastProps['color']>, SemanticStatus> = {
|
|
73
|
+
success: 'success',
|
|
74
|
+
info: 'info',
|
|
75
|
+
warning: 'warning',
|
|
76
|
+
error: 'error',
|
|
77
|
+
neutral: 'neutral',
|
|
78
|
+
primary: 'info',
|
|
79
|
+
secondary: 'neutral',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const defaultIcons: Record<SemanticStatus, string> = {
|
|
83
|
+
error: 'caution',
|
|
84
|
+
warning: 'warning',
|
|
85
|
+
info: 'note',
|
|
86
|
+
success: 'success',
|
|
87
|
+
neutral: 'circle-dash',
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resolvedStatus = computed<SemanticStatus>(() => status ?? (color ? COLOR_TO_STATUS[color] : 'neutral'))
|
|
91
|
+
const colors = computed(() => semanticColors[resolvedStatus.value])
|
|
92
|
+
const resolvedIcon = computed(() => icon || defaultIcons[resolvedStatus.value])
|
|
93
|
+
|
|
94
|
+
// Anything the user sees fail should be one click from a bug report, same as
|
|
95
|
+
// the page-level error boundary. A toast that already carries its own action
|
|
96
|
+
// (Upgrade, Retry) keeps it — one CTA per toast, per the color/action budget.
|
|
97
|
+
const resolvedActions = computed<ToastAction[]>(() => {
|
|
98
|
+
if (actions?.length)
|
|
99
|
+
return actions
|
|
100
|
+
if (!reportBug || (resolvedStatus.value !== 'error' && resolvedStatus.value !== 'warning'))
|
|
101
|
+
return []
|
|
102
|
+
return [{
|
|
103
|
+
label: 'Report bug',
|
|
104
|
+
icon: 'bug',
|
|
105
|
+
color: 'neutral',
|
|
106
|
+
onClick: () => reportBug({
|
|
107
|
+
title: typeof title === 'string' ? title : undefined,
|
|
108
|
+
description: typeof description === 'string' ? description : undefined,
|
|
109
|
+
}),
|
|
110
|
+
}]
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Measure natural height reactively (ResizeObserver) so the toaster's fan-out
|
|
114
|
+
// offsets stay correct even after fonts load / content reflows. A one-shot
|
|
115
|
+
// onMounted getBoundingClientRect reads 0 inside the portal and collapses the
|
|
116
|
+
// fan — useElementSize avoids that.
|
|
117
|
+
const root = useTemplateRef<HTMLElement>('root')
|
|
118
|
+
const { height } = useElementSize(root)
|
|
119
|
+
watch(height, (h) => {
|
|
120
|
+
if (h)
|
|
121
|
+
emit('measure', h)
|
|
122
|
+
}, { immediate: true })
|
|
123
|
+
|
|
124
|
+
function onAction(action: ToastAction, e: MouseEvent) {
|
|
125
|
+
e.stopPropagation()
|
|
126
|
+
action.onClick?.(e)
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<template>
|
|
131
|
+
<div
|
|
132
|
+
ref="root"
|
|
133
|
+
class="group relative w-full overflow-hidden rounded-xl border border-default bg-default/90 ring-1 ring-black/5 backdrop-blur-md dark:ring-white/5"
|
|
134
|
+
style="box-shadow: var(--elevation-overlay)"
|
|
135
|
+
>
|
|
136
|
+
<!-- Gradient accent bar — top edge, crisp at left, fades right (mirrors UiAlert). -->
|
|
137
|
+
<div
|
|
138
|
+
class="pointer-events-none absolute inset-x-0 top-0 h-px opacity-40"
|
|
139
|
+
:class="colors.dot"
|
|
140
|
+
style="mask-image: linear-gradient(to right, black 0%, black 20%, transparent 80%); -webkit-mask-image: linear-gradient(to right, black 0%, black 20%, transparent 80%);"
|
|
141
|
+
aria-hidden="true"
|
|
142
|
+
/>
|
|
143
|
+
<!-- Corner bloom — faint radial light source. -->
|
|
144
|
+
<div
|
|
145
|
+
class="pointer-events-none absolute -right-8 -top-8 size-24 rounded-full opacity-10 blur-2xl"
|
|
146
|
+
:class="colors.dot"
|
|
147
|
+
aria-hidden="true"
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
<!-- Content — fades out on collapsed back cards so the deck reads as a tidy stack. -->
|
|
151
|
+
<div
|
|
152
|
+
class="flex items-start gap-3 p-3.5 pr-9 transition-opacity duration-200"
|
|
153
|
+
:class="peek ? 'opacity-0' : 'opacity-100'"
|
|
154
|
+
>
|
|
155
|
+
<UiIcon
|
|
156
|
+
v-if="resolvedIcon"
|
|
157
|
+
:name="resolvedIcon"
|
|
158
|
+
class="mt-0.5 size-4 shrink-0"
|
|
159
|
+
:class="colors.text"
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
<div class="min-w-0 flex-1">
|
|
164
|
+
<p
|
|
165
|
+
v-if="title"
|
|
166
|
+
class="text-sm font-medium leading-snug text-default"
|
|
167
|
+
>
|
|
168
|
+
<component :is="title" v-if="typeof title === 'object'" />
|
|
169
|
+
<template v-else>
|
|
170
|
+
{{ title }}
|
|
171
|
+
</template>
|
|
172
|
+
</p>
|
|
173
|
+
<div
|
|
174
|
+
v-if="description"
|
|
175
|
+
class="text-xs leading-snug text-muted"
|
|
176
|
+
:class="{ 'mt-0.5': title }"
|
|
177
|
+
>
|
|
178
|
+
<component :is="description" v-if="typeof description === 'object'" />
|
|
179
|
+
<template v-else>
|
|
180
|
+
{{ description }}
|
|
181
|
+
</template>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div
|
|
185
|
+
v-if="resolvedActions.length"
|
|
186
|
+
class="mt-2.5 flex flex-wrap gap-2"
|
|
187
|
+
>
|
|
188
|
+
<UiButton
|
|
189
|
+
v-for="(action, index) in resolvedActions"
|
|
190
|
+
:key="index"
|
|
191
|
+
size="xs"
|
|
192
|
+
:purpose="action.color === 'primary' ? 'cta' : 'secondary'"
|
|
193
|
+
:icon="action.icon"
|
|
194
|
+
:label="action.label"
|
|
195
|
+
:to="action.to"
|
|
196
|
+
class="min-h-11 sm:min-h-0"
|
|
197
|
+
@pointerdown.stop
|
|
198
|
+
@click="onAction(action, $event)"
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- Dismiss — a plain control (not the motion UiButton, whose width-measure +
|
|
205
|
+
overflow clip mis-render a tiny absolute icon). 24px hit target, icon
|
|
206
|
+
centered; it rests at text-dimmed and strengthens on hover/focus. No
|
|
207
|
+
rest opacity: at 60% the icon measured 2.7:1, under the 3:1 a control
|
|
208
|
+
needs (WCAG 1.4.11). The focus ring is primary for the same reason. `pointerdown.stop` so it never starts a card drag;
|
|
209
|
+
`click.stop` so it never triggers the card's onClick. -->
|
|
210
|
+
<button
|
|
211
|
+
v-if="close && !peek"
|
|
212
|
+
type="button"
|
|
213
|
+
class="absolute right-1.5 top-1.5 inline-flex size-6 items-center justify-center rounded-md text-dimmed transition hover:bg-elevated hover:text-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
214
|
+
aria-label="Dismiss"
|
|
215
|
+
@pointerdown.stop
|
|
216
|
+
@click.stop="emit('close')"
|
|
217
|
+
>
|
|
218
|
+
<UiIcon name="close" class="size-3.5" aria-hidden="true" />
|
|
219
|
+
</button>
|
|
220
|
+
|
|
221
|
+
<!-- Auto-dismiss progress — width = remaining fraction; hidden on back cards. -->
|
|
222
|
+
<div
|
|
223
|
+
v-if="progress !== false && !peek"
|
|
224
|
+
class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 origin-left opacity-50"
|
|
225
|
+
:class="colors.dot"
|
|
226
|
+
:style="{ width: `${Math.max(0, Math.min(1, progress)) * 100}%` }"
|
|
227
|
+
aria-hidden="true"
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
</template>
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { UiToastProps } from './UiToast.vue'
|
|
3
|
+
import { useMounted, useRafFn } from '@vueuse/core'
|
|
4
|
+
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
|
|
5
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
6
|
+
import { useToast } from '#imports'
|
|
7
|
+
import { springs } from '../../shared/motion'
|
|
8
|
+
import UiToast from './UiToast.vue'
|
|
9
|
+
|
|
10
|
+
// Sonner/Linear-style toaster. Resting state is a COLLAPSED peek-deck: only the
|
|
11
|
+
// newest card is fully readable, the rest scale down + peek a few px behind it.
|
|
12
|
+
// Hovering the deck fans it into a readable list and pauses every timer. motion-v
|
|
13
|
+
// drives the per-depth transforms (enter / make-room / exit / fan) off reactive
|
|
14
|
+
// targets, so adding/removing a toast re-stacks the deck automatically. We reuse
|
|
15
|
+
// Nuxt UI's shared useToast() queue, so every existing `toast.add(...)` keeps
|
|
16
|
+
// working unchanged. (Interaction model: brand-kit /toasts → "Fire 5 at once".)
|
|
17
|
+
|
|
18
|
+
export interface UiToasterProps {
|
|
19
|
+
position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
|
|
20
|
+
/** Max cards rendered in the deck (older ones collapse into a "+N" pill). */
|
|
21
|
+
max?: number
|
|
22
|
+
/** Default auto-dismiss in ms; a per-toast `duration` overrides. 0 = sticky. */
|
|
23
|
+
duration?: number
|
|
24
|
+
/** Passed to every card: adds "Report bug" to failure toasts. See UiToast. */
|
|
25
|
+
reportBug?: UiToastProps['reportBug']
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
position = 'bottom-right',
|
|
30
|
+
max = 3,
|
|
31
|
+
duration = 5000,
|
|
32
|
+
reportBug,
|
|
33
|
+
} = defineProps<UiToasterProps>()
|
|
34
|
+
|
|
35
|
+
const { toasts, remove } = useToast()
|
|
36
|
+
const mounted = useMounted()
|
|
37
|
+
const reduced = useReducedMotion()
|
|
38
|
+
|
|
39
|
+
// Deck geometry.
|
|
40
|
+
const PEEK = 14 // px each back card peeks above the front
|
|
41
|
+
const SCALE_STEP = 0.05 // scale shrink per depth
|
|
42
|
+
const GAP = 14 // px gap between cards when fanned
|
|
43
|
+
const FALLBACK_H = 72 // height used before a card has been measured
|
|
44
|
+
|
|
45
|
+
const isTop = computed(() => position.startsWith('top'))
|
|
46
|
+
const expanded = ref(false)
|
|
47
|
+
|
|
48
|
+
// Newest last in the queue; cap to `max` (keep the most recent). Within
|
|
49
|
+
// `visible`, index 0 = oldest, last = newest = deck front (depth 0).
|
|
50
|
+
const visible = computed(() => toasts.value.slice(-max))
|
|
51
|
+
const overflow = computed(() => Math.max(0, toasts.value.length - visible.value.length))
|
|
52
|
+
|
|
53
|
+
// Per-id measured natural heights, reported by each UiToast.
|
|
54
|
+
const heights = reactive<Record<string, number>>({})
|
|
55
|
+
function setHeight(id: string | number, h: number) {
|
|
56
|
+
heights[String(id)] = h
|
|
57
|
+
}
|
|
58
|
+
function heightOf(id: string | number | undefined): number {
|
|
59
|
+
return (id != null && heights[String(id)]) || FALLBACK_H
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Placement (transform target + chrome flags) per visible toast. motion-v tweens
|
|
63
|
+
// between successive targets, so a queue change animates the whole deck.
|
|
64
|
+
const placements = computed(() => {
|
|
65
|
+
const list = visible.value
|
|
66
|
+
const n = list.length
|
|
67
|
+
// depth 0 = front (newest = last). heightAtDepth indexes by depth.
|
|
68
|
+
const heightAtDepth = (depth: number) => heightOf(list[n - 1 - depth]?.id)
|
|
69
|
+
|
|
70
|
+
// Cumulative bottom offsets for the fanned (expanded) state.
|
|
71
|
+
const expandedOffset: number[] = []
|
|
72
|
+
let acc = 0
|
|
73
|
+
for (let d = 0; d < n; d++) {
|
|
74
|
+
expandedOffset[d] = acc
|
|
75
|
+
acc += heightAtDepth(d) + GAP
|
|
76
|
+
}
|
|
77
|
+
const frontH = heightAtDepth(0)
|
|
78
|
+
const dir = isTop.value ? 1 : -1 // grow downward for top-anchored, upward otherwise
|
|
79
|
+
|
|
80
|
+
return list.map((t, idx) => {
|
|
81
|
+
const depth = n - 1 - idx
|
|
82
|
+
const ownH = heightOf(t.id)
|
|
83
|
+
if (expanded.value) {
|
|
84
|
+
return {
|
|
85
|
+
depth,
|
|
86
|
+
peek: false,
|
|
87
|
+
z: 100 - depth,
|
|
88
|
+
target: {
|
|
89
|
+
y: dir * expandedOffset[depth]!,
|
|
90
|
+
scale: 1,
|
|
91
|
+
opacity: 1,
|
|
92
|
+
height: ownH,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
depth,
|
|
98
|
+
peek: depth > 0,
|
|
99
|
+
z: 100 - depth,
|
|
100
|
+
target: {
|
|
101
|
+
y: dir * depth * PEEK,
|
|
102
|
+
scale: Number((1 - depth * SCALE_STEP).toFixed(3)),
|
|
103
|
+
opacity: depth < max ? 1 : 0,
|
|
104
|
+
height: depth > 0 ? frontH : ownH,
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Region height — sized so the whole deck/fan is one continuous hover zone
|
|
111
|
+
// (prevents fan-out flicker as cards reposition out from under the pointer).
|
|
112
|
+
const regionHeight = computed(() => {
|
|
113
|
+
const n = visible.value.length
|
|
114
|
+
if (!n)
|
|
115
|
+
return 0
|
|
116
|
+
if (expanded.value) {
|
|
117
|
+
return visible.value.reduce((sum, t) => sum + heightOf(t.id) + GAP, 0)
|
|
118
|
+
}
|
|
119
|
+
const front = heightOf(visible.value[n - 1]!.id)
|
|
120
|
+
return front + (Math.min(n, max) - 1) * PEEK
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// ── Timers — one rAF loop, pauses entirely while the deck is fanned/hovered ──
|
|
124
|
+
const elapsed = reactive<Record<string, number>>({})
|
|
125
|
+
const progress = reactive<Record<string, number>>({})
|
|
126
|
+
const dragging = reactive<Record<string, boolean>>({})
|
|
127
|
+
|
|
128
|
+
function durationOf(t: (typeof toasts.value)[number]): number {
|
|
129
|
+
const d = (t as { duration?: number }).duration
|
|
130
|
+
return typeof d === 'number' ? d : duration
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
useRafFn(({ delta }) => {
|
|
134
|
+
if (expanded.value)
|
|
135
|
+
return // fanned → all timers paused
|
|
136
|
+
// Only the front (primary) toast counts down. Buried cards hold their full
|
|
137
|
+
// duration and start their own timer once promoted to the front — so a toast
|
|
138
|
+
// never expires while hidden behind the deck (timers stack, they don't race).
|
|
139
|
+
const list = visible.value
|
|
140
|
+
const front = list.at(-1)
|
|
141
|
+
if (!front)
|
|
142
|
+
return
|
|
143
|
+
const id = String(front.id)
|
|
144
|
+
const total = durationOf(front)
|
|
145
|
+
if (!total || total <= 0 || dragging[id])
|
|
146
|
+
return
|
|
147
|
+
elapsed[id] = (elapsed[id] || 0) + delta
|
|
148
|
+
progress[id] = Math.max(0, 1 - elapsed[id]! / total)
|
|
149
|
+
if (elapsed[id]! >= total)
|
|
150
|
+
remove(front.id)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Prune timer bookkeeping for toasts that have left the queue.
|
|
154
|
+
watch(visible, (list) => {
|
|
155
|
+
const live = new Set(list.map(t => String(t.id)))
|
|
156
|
+
for (const id of Object.keys(elapsed)) {
|
|
157
|
+
if (!live.has(id)) {
|
|
158
|
+
delete elapsed[id]
|
|
159
|
+
delete progress[id]
|
|
160
|
+
delete dragging[id]
|
|
161
|
+
delete heights[id]
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
function progressFor(t: (typeof toasts.value)[number]): number | false {
|
|
167
|
+
return durationOf(t) > 0 ? (progress[String(t.id)] ?? 1) : false
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Drag to dismiss ──
|
|
171
|
+
function canDrag(depth: number): boolean {
|
|
172
|
+
return expanded.value || depth === 0
|
|
173
|
+
}
|
|
174
|
+
function onDragStart(id: string | number) {
|
|
175
|
+
dragging[String(id)] = true
|
|
176
|
+
}
|
|
177
|
+
function onDragEnd(id: string | number, info: { offset: { x: number }, velocity: { x: number } }) {
|
|
178
|
+
dragging[String(id)] = false
|
|
179
|
+
if (info.offset.x > 100 || info.velocity.x > 600)
|
|
180
|
+
remove(id)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Whitelist the fields UiToast consumes so queue bookkeeping never hits the DOM.
|
|
184
|
+
function toastBind(t: (typeof toasts.value)[number]): Partial<UiToastProps> {
|
|
185
|
+
const x = t as unknown as Record<string, unknown>
|
|
186
|
+
return {
|
|
187
|
+
title: x.title as UiToastProps['title'],
|
|
188
|
+
description: x.description as UiToastProps['description'],
|
|
189
|
+
icon: x.icon as UiToastProps['icon'],
|
|
190
|
+
color: x.color as UiToastProps['color'],
|
|
191
|
+
status: x.status as UiToastProps['status'],
|
|
192
|
+
actions: x.actions as UiToastProps['actions'],
|
|
193
|
+
close: x.close as UiToastProps['close'],
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const stackTransition = computed(() =>
|
|
198
|
+
reduced.value
|
|
199
|
+
? { duration: 0 }
|
|
200
|
+
: { default: springs.snappy, opacity: { duration: 0.25 }, height: springs.smooth },
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const posClass = computed(() => ({
|
|
204
|
+
'top-left': 'top-4 left-4',
|
|
205
|
+
'top-center': 'top-4 left-1/2 -translate-x-1/2',
|
|
206
|
+
'top-right': 'top-4 right-4',
|
|
207
|
+
'bottom-left': 'bottom-4 left-4',
|
|
208
|
+
'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',
|
|
209
|
+
'bottom-right': 'bottom-4 right-4',
|
|
210
|
+
}[position]))
|
|
211
|
+
</script>
|
|
212
|
+
|
|
213
|
+
<template>
|
|
214
|
+
<Teleport v-if="mounted" to="body">
|
|
215
|
+
<!-- Region wrapper owns the hover zone + landmark role; keeping `role` off the
|
|
216
|
+
<ol> preserves its list semantics so the <li> aren't orphaned (a11y). -->
|
|
217
|
+
<div
|
|
218
|
+
class="pointer-events-auto fixed z-[100] w-[calc(100vw-2rem)] sm:w-96"
|
|
219
|
+
:class="posClass"
|
|
220
|
+
:style="{ height: `${regionHeight}px` }"
|
|
221
|
+
role="region"
|
|
222
|
+
aria-label="Notifications"
|
|
223
|
+
@mouseenter="expanded = true"
|
|
224
|
+
@mouseleave="expanded = false"
|
|
225
|
+
@focusin="expanded = true"
|
|
226
|
+
@focusout="(e: FocusEvent) => { if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) expanded = false }"
|
|
227
|
+
>
|
|
228
|
+
<ol class="absolute inset-0 m-0 list-none p-0" aria-live="polite">
|
|
229
|
+
<AnimatePresence :initial="false">
|
|
230
|
+
<motion.li
|
|
231
|
+
v-for="(t, idx) in visible"
|
|
232
|
+
:key="t.id"
|
|
233
|
+
class="absolute inset-x-0 overflow-hidden"
|
|
234
|
+
:class="isTop ? 'top-0' : 'bottom-0'"
|
|
235
|
+
style="transform-origin: bottom center; will-change: transform;"
|
|
236
|
+
:style="{ zIndex: placements[idx]!.z }"
|
|
237
|
+
:initial="reduced ? false : { opacity: 0, y: isTop ? -40 : 40, scale: 0.9, height: placements[idx]!.target.height }"
|
|
238
|
+
:animate="placements[idx]!.target"
|
|
239
|
+
:exit="reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, transition: { duration: 0.2 } }"
|
|
240
|
+
:transition="stackTransition"
|
|
241
|
+
:drag="canDrag(placements[idx]!.depth) ? 'x' : false"
|
|
242
|
+
:drag-snap-to-origin="true"
|
|
243
|
+
:drag-elastic="{ left: 0.15, right: 0.6 }"
|
|
244
|
+
:drag-constraints="{ left: 0, right: 0 }"
|
|
245
|
+
@drag-start="onDragStart(t.id)"
|
|
246
|
+
@drag-end="(_e: PointerEvent, info: { offset: { x: number }, velocity: { x: number } }) => onDragEnd(t.id, info)"
|
|
247
|
+
>
|
|
248
|
+
<UiToast
|
|
249
|
+
v-bind="toastBind(t)"
|
|
250
|
+
:report-bug="reportBug"
|
|
251
|
+
:peek="placements[idx]!.peek"
|
|
252
|
+
:progress="progressFor(t)"
|
|
253
|
+
@measure="(h: number) => setHeight(t.id, h)"
|
|
254
|
+
@close="remove(t.id)"
|
|
255
|
+
/>
|
|
256
|
+
</motion.li>
|
|
257
|
+
</AnimatePresence>
|
|
258
|
+
</ol>
|
|
259
|
+
|
|
260
|
+
<!-- "+N more" pill — sibling of the <ol> (never inside it). Sits just clear
|
|
261
|
+
of the deck: above it for bottom-anchored, below it for top-anchored. -->
|
|
262
|
+
<div
|
|
263
|
+
v-if="overflow > 0 && !expanded"
|
|
264
|
+
class="pointer-events-none absolute right-2 text-mini font-medium text-dimmed"
|
|
265
|
+
:class="isTop ? 'bottom-0 translate-y-full pt-1' : 'top-0 -translate-y-full pb-1'"
|
|
266
|
+
>
|
|
267
|
+
+{{ overflow }} more
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</Teleport>
|
|
271
|
+
</template>
|