@nuxtseo/design-system 0.0.0-stage → 0.1.1
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 +181 -2
- package/app/app.config.ts +592 -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 +233 -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 +127 -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 +68 -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 +93 -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 +15 -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/app-favicon.ts +64 -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 +69 -0
- package/build/external-links.ts +38 -0
- package/build/fonts.ts +39 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1896 -0
- package/nuxt.config.ts +143 -0
- package/package.json +93 -3
- package/public/fonts/README.md +33 -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/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,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { UiBrandMark, UiProLettering } from '#components'
|
|
3
|
+
|
|
4
|
+
const { variant = 'default' } = defineProps<{
|
|
5
|
+
variant?: 'default' | 'auth'
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const wordSize = { default: 18, auth: 24 } as const
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<span
|
|
13
|
+
data-ui="UiLogoPro"
|
|
14
|
+
:data-variant="variant"
|
|
15
|
+
class="inline-flex shrink-0 items-center whitespace-nowrap text-highlighted"
|
|
16
|
+
:class="variant === 'auth' ? 'gap-2.5' : 'gap-2'"
|
|
17
|
+
>
|
|
18
|
+
<UiBrandMark product="pro" :size="variant === 'auth' ? 32 : 28" />
|
|
19
|
+
<span
|
|
20
|
+
class="font-display tracking-[-0.03em]"
|
|
21
|
+
:style="{ fontSize: `${wordSize[variant]}px` }"
|
|
22
|
+
><span class="font-medium">Nuxt</span> <span class="font-bold">SEO</span></span>
|
|
23
|
+
<UiProLettering :size="wordSize[variant]" />
|
|
24
|
+
</span>
|
|
25
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Official Umami brand mark, brand colour #13B0F5 baked into the asset (single-colour
|
|
3
|
+
// mark — there is no multicolour version). Inline SVG so the colour survives, vs an
|
|
4
|
+
// icon set that would monotone it to currentColor. Sizing/aria fall through from the
|
|
5
|
+
// parent (e.g. class="size-6").
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<svg viewBox="0 0 24 24" fill="#13B0F5" xmlns="http://www.w3.org/2000/svg">
|
|
10
|
+
<path d="M2.203 8.611H.857a.845.845 0 0 0-.841.841v.858a13.31 13.31 0 0 0-.016.6c0 6.627 5.373 12 12 12 6.527 0 11.837-5.212 11.996-11.701 0-.025.004-.05.004-.075V9.452a.845.845 0 0 0-.841-.841h-1.346c-1.159-4.329-5.112-7.521-9.805-7.521-4.692 0-8.645 3.192-9.805 7.521Zm18.444 0H3.37c1.127-3.702 4.57-6.399 8.638-6.399 4.069 0 7.512 2.697 8.639 6.399Z" />
|
|
11
|
+
</svg>
|
|
12
|
+
</template>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { UiIcon as UiIconName } from '../../shared/ui-icons'
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
import { UiIcon } from '#components'
|
|
5
|
+
/**
|
|
6
|
+
* Nav icon container used in sidebar navigation and status rows.
|
|
7
|
+
* Default is primary-tinted. Semantic variants (success/warning/error/info)
|
|
8
|
+
* use low-alpha skewed tints of the semantic color tokens.
|
|
9
|
+
*
|
|
10
|
+
* The `default` variant is alive: idle it stays restrained (dimmed glyph,
|
|
11
|
+
* faint tint), then leans into the brand color as a pop on hover (reacts to a
|
|
12
|
+
* `group/navitem` ancestor) and on `active`. The glyph springs up a touch and
|
|
13
|
+
* the box lifts, so the icon reads as the focal point of its row rather than a
|
|
14
|
+
* flat chip. Semantic variants are static status tints and don't react.
|
|
15
|
+
*
|
|
16
|
+
* Renders through `UiIcon`, so it speaks the curated icon vocabulary
|
|
17
|
+
* (semantic names like `chart`, `lock`) while still accepting raw `i-*` ids.
|
|
18
|
+
*
|
|
19
|
+
* `neutral` is the monochrome identity treatment: a flat neutral box + muted
|
|
20
|
+
* glyph, no brand tint and no hover pop. Use it when the icon is resting
|
|
21
|
+
* identity (e.g. a trigger-kind glyph) and the colour budget belongs to the
|
|
22
|
+
* row's status (a dot/badge), not the icon. Differentiate kinds by glyph shape,
|
|
23
|
+
* never by colour.
|
|
24
|
+
*/
|
|
25
|
+
type Variant = 'default' | 'neutral' | 'success' | 'warning' | 'error' | 'info' | 'experimental'
|
|
26
|
+
|
|
27
|
+
const { icon, locked = false, loading = false, active = false, variant = 'default', size = 'md' } = defineProps<{
|
|
28
|
+
icon: UiIconName
|
|
29
|
+
locked?: boolean
|
|
30
|
+
/** Swap the glyph for a spinner — e.g. a nav item whose data is syncing. */
|
|
31
|
+
loading?: boolean
|
|
32
|
+
/** Marks the icon's row as active — deepens the tint, lifts the glyph to primary, adds a soft halo. */
|
|
33
|
+
active?: boolean
|
|
34
|
+
variant?: Variant
|
|
35
|
+
/** `md` (size-6 — nav/status rows) · `sm` (size-5) · `xs` (size-4 — dense list rows). */
|
|
36
|
+
size?: 'xs' | 'sm' | 'md'
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
const boxSize = size === 'xs' ? 'size-4' : size === 'sm' ? 'size-5' : 'size-6'
|
|
40
|
+
const glyphSize = size === 'xs' ? 'size-2.5' : size === 'sm' ? 'size-3' : 'size-3.5'
|
|
41
|
+
|
|
42
|
+
const variantBoxClasses: Record<Variant, string> = {
|
|
43
|
+
neutral: 'border border-default bg-elevated',
|
|
44
|
+
success: 'border bg-success/5 border-success/20',
|
|
45
|
+
warning: 'border bg-warning/5 border-warning/20',
|
|
46
|
+
error: 'border bg-error/5 border-error/20',
|
|
47
|
+
info: 'border bg-info/5 border-info/20',
|
|
48
|
+
experimental: 'bg-[var(--ui-bg-elevated)]/60',
|
|
49
|
+
} as Record<Variant, string>
|
|
50
|
+
|
|
51
|
+
const variantIconClasses: Record<Variant, string> = {
|
|
52
|
+
neutral: 'text-muted',
|
|
53
|
+
success: 'text-success/80',
|
|
54
|
+
warning: 'text-warning/80',
|
|
55
|
+
error: 'text-error/80',
|
|
56
|
+
info: 'text-info/80',
|
|
57
|
+
experimental: 'text-dimmed',
|
|
58
|
+
} as Record<Variant, string>
|
|
59
|
+
|
|
60
|
+
// The default variant is state-driven; precedence is active > hover > idle so
|
|
61
|
+
// nothing fights over `!important`. Hover is CSS-only (group/navitem ancestor).
|
|
62
|
+
const boxClass = computed(() => {
|
|
63
|
+
if (variant !== 'default')
|
|
64
|
+
return variantBoxClasses[variant]
|
|
65
|
+
if (active)
|
|
66
|
+
return 'border bg-gradient-to-b from-primary-300/30 to-primary-300/12 border-primary/35 ring-2 ring-primary/15 dark:from-primary-900/40 dark:to-primary-900/15'
|
|
67
|
+
return 'border bg-gradient-to-b from-primary-300/15 to-primary-300/5 border-primary-300/15 dark:from-primary-900/20 dark:to-primary-900/5 dark:border-primary-950/15 group-hover/navitem:-translate-y-px group-hover/navitem:from-primary-300/25 group-hover/navitem:to-primary-300/10 group-hover/navitem:border-primary-300/30 dark:group-hover/navitem:from-primary-900/30 dark:group-hover/navitem:to-primary-900/10'
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const iconClass = computed(() => {
|
|
71
|
+
if (variant !== 'default')
|
|
72
|
+
return variantIconClasses[variant]
|
|
73
|
+
if (active)
|
|
74
|
+
return 'text-primary scale-110'
|
|
75
|
+
return 'text-dimmed group-hover/navitem:text-primary group-hover/navitem:scale-115'
|
|
76
|
+
})
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<!--
|
|
81
|
+
`<span>`, not `<div>`. Callers place this glyph tile INLINE — riding next
|
|
82
|
+
to a category label inside a `<p>` (`UiIssueRow`'s "full" type line) or
|
|
83
|
+
beside text in a nav row. A `<div>` is flow content and cannot legally sit
|
|
84
|
+
inside `<p>`'s phrasing-content model: the browser's HTML parser force-
|
|
85
|
+
closes the `<p>` (and everything open inside it) the instant it meets the
|
|
86
|
+
div start tag, silently hoisting this tile OUT to become a sibling of the
|
|
87
|
+
paragraph instead of its child. That reshapes the server-rendered DOM into
|
|
88
|
+
something Vue's client vdom never expected — a REAL SSR/hydration
|
|
89
|
+
mismatch (not a dev-only artefact; it's an HTML parsing rule, identical in
|
|
90
|
+
production), cascading into 6-7 "Hydration node/children mismatch"
|
|
91
|
+
warnings per row, on every `UiIssueRow` (site board + portfolio queue).
|
|
92
|
+
`inline-flex` keeps the exact same internal flex layout; as a flex ITEM
|
|
93
|
+
inside a parent flexbox, `span`/`div` behave identically either way, so
|
|
94
|
+
this is a pure content-model fix with no visual change.
|
|
95
|
+
-->
|
|
96
|
+
<span
|
|
97
|
+
class="inline-flex items-center justify-center rounded-md transition-[transform,background-color,border-color,box-shadow] duration-200 ease-[var(--ease-spring)]"
|
|
98
|
+
:class="[boxSize, boxClass]"
|
|
99
|
+
>
|
|
100
|
+
<UiIcon
|
|
101
|
+
:name="loading ? 'loading' : locked ? 'lock' : icon"
|
|
102
|
+
class="shrink-0 transition-[transform,color] duration-200 ease-[var(--ease-spring)]"
|
|
103
|
+
:class="[glyphSize, iconClass, { 'animate-spin': loading }]"
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
/>
|
|
106
|
+
</span>
|
|
107
|
+
</template>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends UiNavLink">
|
|
2
|
+
import type { VNodeChild } from 'vue'
|
|
3
|
+
import type { UiNavLink } from '../../shared/nav'
|
|
4
|
+
import { useRoute } from 'nuxt/app'
|
|
5
|
+
import { NuxtLink, UiIcon, UiNavIcon, UiTooltip } from '#components'
|
|
6
|
+
|
|
7
|
+
const { links, activeMode = 'exact', label, variant = 'default', tone = 'muted' } = defineProps<{
|
|
8
|
+
links: T[]
|
|
9
|
+
/** Sidebar rows use plain icons. Release labels belong in the page header. */
|
|
10
|
+
variant?: 'default' | 'sidebar'
|
|
11
|
+
tone?: 'default' | 'muted'
|
|
12
|
+
activeMode?: 'exact' | 'prefix'
|
|
13
|
+
/**
|
|
14
|
+
* Accessible name for this `<nav>` landmark. Pass a distinct value when more
|
|
15
|
+
* than one nav list renders on a page (e.g. grouped sidebars) so the landmarks
|
|
16
|
+
* stay unique for screen readers.
|
|
17
|
+
*/
|
|
18
|
+
label?: string
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
defineSlots<{
|
|
22
|
+
/** Custom leading visual per item (e.g. a favicon). Replaces `UiNavIcon`. */
|
|
23
|
+
icon?: (props: { link: T, active: boolean }) => VNodeChild
|
|
24
|
+
/** Trailing per-row action (e.g. a hover menu). Rendered as a sibling of the
|
|
25
|
+
* link, not nested inside the anchor, so interactive triggers stay valid. */
|
|
26
|
+
action?: (props: { link: T }) => VNodeChild
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const route = useRoute()
|
|
30
|
+
|
|
31
|
+
function isActive(link: T) {
|
|
32
|
+
if (link.active)
|
|
33
|
+
return link.active(route.path)
|
|
34
|
+
if (activeMode === 'prefix')
|
|
35
|
+
return route.path === link.to || route.path.startsWith(`${link.to}/`)
|
|
36
|
+
return route.path === link.to
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<nav class="space-y-0.5" :aria-label="label">
|
|
42
|
+
<div
|
|
43
|
+
v-for="link in links"
|
|
44
|
+
:key="link.to"
|
|
45
|
+
class="relative group/navitem"
|
|
46
|
+
>
|
|
47
|
+
<NuxtLink
|
|
48
|
+
:to="link.disabled ? undefined : link.to"
|
|
49
|
+
:prefetch="link.disabled ? undefined : link.prefetch"
|
|
50
|
+
:aria-disabled="link.disabled || undefined"
|
|
51
|
+
:aria-current="isActive(link) && !link.disabled ? 'page' : undefined"
|
|
52
|
+
:tabindex="link.disabled ? -1 : undefined"
|
|
53
|
+
class="flex items-center gap-1.5 px-1 py-1 min-h-11 lg:min-h-0 rounded text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
54
|
+
:class="[
|
|
55
|
+
link.disabled
|
|
56
|
+
? 'text-dimmed cursor-not-allowed'
|
|
57
|
+
: isActive(link)
|
|
58
|
+
? 'bg-elevated text-highlighted'
|
|
59
|
+
: link.textTone === 'highlighted'
|
|
60
|
+
? 'text-highlighted hover:text-highlighted hover:bg-elevated'
|
|
61
|
+
: tone === 'default'
|
|
62
|
+
? 'text-default hover:text-highlighted hover:bg-elevated'
|
|
63
|
+
: 'text-muted hover:text-default hover:bg-elevated',
|
|
64
|
+
variant === 'sidebar' && !link.disabled && !isActive(link) && (tone === 'default' || link.textTone === 'highlighted')
|
|
65
|
+
? 'dark:text-toned dark:hover:text-highlighted'
|
|
66
|
+
: undefined,
|
|
67
|
+
]"
|
|
68
|
+
:style="isActive(link) && !link.disabled ? { boxShadow: 'var(--elevation-raised)', backgroundImage: 'var(--surface-raised)' } : undefined"
|
|
69
|
+
:title="link.title"
|
|
70
|
+
>
|
|
71
|
+
<slot v-if="$slots.icon" name="icon" :link="link" :active="isActive(link)" />
|
|
72
|
+
<UiIcon
|
|
73
|
+
v-else-if="link.icon && variant === 'sidebar'"
|
|
74
|
+
:name="link.icon"
|
|
75
|
+
class="size-4 shrink-0"
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
/>
|
|
78
|
+
<UiNavIcon
|
|
79
|
+
v-else-if="link.icon"
|
|
80
|
+
:icon="link.icon"
|
|
81
|
+
:active="isActive(link)"
|
|
82
|
+
/>
|
|
83
|
+
<span class="min-w-0 truncate">{{ link.label }}</span>
|
|
84
|
+
<span
|
|
85
|
+
v-if="link.badge"
|
|
86
|
+
class="ml-auto text-mini font-medium px-1.5 py-0.5 rounded-md"
|
|
87
|
+
:class="link.badgeColor === 'warning'
|
|
88
|
+
? 'text-warning bg-warning/10'
|
|
89
|
+
: link.badgeColor === 'neutral'
|
|
90
|
+
? 'text-muted bg-elevated ring-1 ring-default'
|
|
91
|
+
: 'text-primary bg-primary/10'"
|
|
92
|
+
>
|
|
93
|
+
{{ link.badge }}
|
|
94
|
+
</span>
|
|
95
|
+
<!--
|
|
96
|
+
A row whose feature is not connected yet owes the user a chore, not an
|
|
97
|
+
alert. It renders as an unfilled outline chip carrying the verb, so it
|
|
98
|
+
names the action without a tooltip and stays quieter than the filled
|
|
99
|
+
`badge` above (a real worklist count) sitting in the same slot.
|
|
100
|
+
-->
|
|
101
|
+
<UiTooltip
|
|
102
|
+
v-else-if="link.setup"
|
|
103
|
+
:text="link.setup.tooltip"
|
|
104
|
+
>
|
|
105
|
+
<span
|
|
106
|
+
class="ml-auto shrink-0 text-mini font-medium px-1.5 py-px rounded border border-dashed transition-colors"
|
|
107
|
+
:class="link.setup.tone === 'warning'
|
|
108
|
+
? 'text-warning border-warning/30 group-hover/navitem:border-warning/50'
|
|
109
|
+
: 'text-muted border-default group-hover/navitem:text-default group-hover/navitem:border-accented'"
|
|
110
|
+
>
|
|
111
|
+
<!--
|
|
112
|
+
The verb alone is ambiguous out of row context, so the accessible
|
|
113
|
+
name carries the same full state the tooltip shows. `text-dimmed`
|
|
114
|
+
is banned here: this chip is an affordance, not tertiary metadata,
|
|
115
|
+
and it fails AA in dark mode.
|
|
116
|
+
-->
|
|
117
|
+
<span aria-hidden="true">{{ link.setup.verb }}</span>
|
|
118
|
+
<span class="sr-only">{{ link.setup.tooltip }}</span>
|
|
119
|
+
</span>
|
|
120
|
+
</UiTooltip>
|
|
121
|
+
<UiTooltip
|
|
122
|
+
v-else-if="link.pending"
|
|
123
|
+
:text="link.pendingTooltip || 'Waiting for data'"
|
|
124
|
+
>
|
|
125
|
+
<span
|
|
126
|
+
class="ml-auto flex items-center"
|
|
127
|
+
role="img"
|
|
128
|
+
:aria-label="link.pendingTooltip || 'Waiting for data'"
|
|
129
|
+
>
|
|
130
|
+
<span class="size-1.5 rounded-full bg-current text-warning motion-safe:animate-pulse" aria-hidden="true" />
|
|
131
|
+
</span>
|
|
132
|
+
</UiTooltip>
|
|
133
|
+
<!--
|
|
134
|
+
Stability is a word, not a glyph: a flask beside three rows told the
|
|
135
|
+
reader nothing without hovering each one. Same dashed outline chip
|
|
136
|
+
as `setup` above, in the muted tone, so the sidebar carries one chip
|
|
137
|
+
grammar (a verb to act on, or a tier to know about).
|
|
138
|
+
-->
|
|
139
|
+
<UiTooltip
|
|
140
|
+
v-else-if="link.stability && variant !== 'sidebar'"
|
|
141
|
+
:text="link.stability.tooltip"
|
|
142
|
+
>
|
|
143
|
+
<span class="ml-auto shrink-0 text-mini font-medium px-1.5 py-px rounded border border-dashed border-default text-muted transition-colors group-hover/navitem:text-default group-hover/navitem:border-accented">
|
|
144
|
+
{{ link.stability.tooltip }}
|
|
145
|
+
</span>
|
|
146
|
+
</UiTooltip>
|
|
147
|
+
</NuxtLink>
|
|
148
|
+
<div
|
|
149
|
+
v-if="$slots.action"
|
|
150
|
+
class="absolute right-1 top-1/2 -translate-y-1/2"
|
|
151
|
+
>
|
|
152
|
+
<slot name="action" :link="link" />
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</nav>
|
|
156
|
+
</template>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { OnboardingAnswersCopy, OnboardingAnswersDraft } from './onboarding-answers'
|
|
3
|
+
import type { OnboardingIntent } from './onboarding-intents'
|
|
4
|
+
import { computed, useId } from 'vue'
|
|
5
|
+
import { UiIcon, UTextarea } from '#components'
|
|
6
|
+
import {
|
|
7
|
+
ONBOARDING_ANSWERS_COPY,
|
|
8
|
+
ONBOARDING_OTHER_MAX_LENGTH,
|
|
9
|
+
ONBOARDING_ROLES,
|
|
10
|
+
ONBOARDING_SEO_EXPERIENCE,
|
|
11
|
+
ONBOARDING_SITE_COUNTS,
|
|
12
|
+
} from './onboarding-answers'
|
|
13
|
+
import { ONBOARDING_INTENTS } from './onboarding-intents'
|
|
14
|
+
|
|
15
|
+
// The onboarding "About you" questions on one screen: three single-choice chip
|
|
16
|
+
// rows, the multi-choice goals, and an optional free-text box. Every question
|
|
17
|
+
// is optional, and a second tap on a picked chip clears it. Presentation only:
|
|
18
|
+
// the live teams/create wizard and the brand-kit showcase both v-model the
|
|
19
|
+
// draft and decide when to save it.
|
|
20
|
+
//
|
|
21
|
+
// The goals list and the copy name the product, so a site that extends this
|
|
22
|
+
// layer passes its own `intents` and any `copy` keys it rewords. The option
|
|
23
|
+
// ids are shared and stored, so they never change.
|
|
24
|
+
const {
|
|
25
|
+
intents = ONBOARDING_INTENTS,
|
|
26
|
+
copy: copyOverrides = {},
|
|
27
|
+
} = defineProps<{
|
|
28
|
+
intents?: readonly OnboardingIntent[]
|
|
29
|
+
copy?: Partial<OnboardingAnswersCopy>
|
|
30
|
+
}>()
|
|
31
|
+
const copy = computed<OnboardingAnswersCopy>(() => ({ ...ONBOARDING_ANSWERS_COPY, ...copyOverrides }))
|
|
32
|
+
const answers = defineModel<OnboardingAnswersDraft>({ required: true })
|
|
33
|
+
|
|
34
|
+
const id = useId()
|
|
35
|
+
const otherId = `${id}-other`
|
|
36
|
+
|
|
37
|
+
const singleQuestions = computed(() => [
|
|
38
|
+
{ key: 'role', question: copy.value.roleQuestion, options: ONBOARDING_ROLES },
|
|
39
|
+
{ key: 'siteCount', question: copy.value.siteCountQuestion, options: ONBOARDING_SITE_COUNTS },
|
|
40
|
+
{ key: 'seoExperience', question: copy.value.seoExperienceQuestion, options: ONBOARDING_SEO_EXPERIENCE },
|
|
41
|
+
] as const)
|
|
42
|
+
|
|
43
|
+
type SingleKey = 'role' | 'siteCount' | 'seoExperience'
|
|
44
|
+
|
|
45
|
+
function pick(key: SingleKey, value: string) {
|
|
46
|
+
answers.value = { ...answers.value, [key]: answers.value[key] === value ? undefined : value }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toggleIntent(intent: string) {
|
|
50
|
+
const intents = answers.value.intents.includes(intent)
|
|
51
|
+
? answers.value.intents.filter(existing => existing !== intent)
|
|
52
|
+
: [...answers.value.intents, intent]
|
|
53
|
+
answers.value = { ...answers.value, intents }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setOther(other: string) {
|
|
57
|
+
answers.value = { ...answers.value, other }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const chipClass = 'inline-flex min-h-11 sm:min-h-9 cursor-pointer items-center gap-1.5 rounded-full border px-3.5 text-sm font-medium outline-none transition-[background-color,border-color,color] focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-default active:translate-y-px'
|
|
61
|
+
const selectedClass = 'border-primary bg-primary/10 text-highlighted ring-1 ring-primary/40'
|
|
62
|
+
const restClass = 'border-accented bg-default/80 text-default hover:border-primary/60 hover:bg-elevated'
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<template>
|
|
66
|
+
<div class="space-y-6">
|
|
67
|
+
<fieldset v-for="item in singleQuestions" :key="item.key" class="space-y-2.5">
|
|
68
|
+
<legend class="text-sm font-medium text-highlighted">
|
|
69
|
+
{{ item.question }}
|
|
70
|
+
</legend>
|
|
71
|
+
<div class="flex flex-wrap gap-2">
|
|
72
|
+
<button
|
|
73
|
+
v-for="option in item.options"
|
|
74
|
+
:key="option.id"
|
|
75
|
+
type="button"
|
|
76
|
+
:aria-pressed="answers[item.key] === option.id"
|
|
77
|
+
:class="[chipClass, answers[item.key] === option.id ? selectedClass : restClass]"
|
|
78
|
+
@click="pick(item.key, option.id)"
|
|
79
|
+
>
|
|
80
|
+
{{ option.label }}
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</fieldset>
|
|
84
|
+
|
|
85
|
+
<fieldset class="space-y-2.5">
|
|
86
|
+
<legend class="text-sm font-medium text-highlighted">
|
|
87
|
+
{{ copy.intentsQuestion }}
|
|
88
|
+
</legend>
|
|
89
|
+
<p class="text-xs text-muted">
|
|
90
|
+
{{ copy.intentsHint }}
|
|
91
|
+
</p>
|
|
92
|
+
<div class="flex flex-wrap gap-2">
|
|
93
|
+
<button
|
|
94
|
+
v-for="intent in intents"
|
|
95
|
+
:key="intent.id"
|
|
96
|
+
type="button"
|
|
97
|
+
:aria-pressed="answers.intents.includes(intent.id)"
|
|
98
|
+
:class="[chipClass, answers.intents.includes(intent.id) ? selectedClass : restClass]"
|
|
99
|
+
@click="toggleIntent(intent.id)"
|
|
100
|
+
>
|
|
101
|
+
<UiIcon
|
|
102
|
+
:name="answers.intents.includes(intent.id) ? 'check' : intent.icon"
|
|
103
|
+
class="size-4 shrink-0"
|
|
104
|
+
:class="answers.intents.includes(intent.id) ? 'text-primary' : 'text-muted'"
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
/>
|
|
107
|
+
{{ intent.label }}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="space-y-1.5 pt-1">
|
|
111
|
+
<label :for="otherId" class="text-xs text-muted">
|
|
112
|
+
{{ copy.otherLabel }}
|
|
113
|
+
</label>
|
|
114
|
+
<UTextarea
|
|
115
|
+
:id="otherId"
|
|
116
|
+
:model-value="answers.other"
|
|
117
|
+
:maxlength="ONBOARDING_OTHER_MAX_LENGTH"
|
|
118
|
+
:rows="2"
|
|
119
|
+
autoresize
|
|
120
|
+
:placeholder="copy.otherPlaceholder"
|
|
121
|
+
class="w-full"
|
|
122
|
+
@update:model-value="setOther(String($event ?? ''))"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
</fieldset>
|
|
126
|
+
</div>
|
|
127
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { UiChip, UiTooltip } from '#components'
|
|
3
|
+
/**
|
|
4
|
+
* UiOverflowCount — the "+N" pill for "primary values shown + N hidden peers"
|
|
5
|
+
* fields (e.g. a domain header with 5 more aliases). Neutral count chip, no
|
|
6
|
+
* glyph (overflow primitives stay icon-light); the optional tooltip lists the
|
|
7
|
+
* hidden values. Renders nothing when count <= 0.
|
|
8
|
+
*
|
|
9
|
+
* NOT for "N more rows below" list tails (those are a disclosure or a nav link).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { count, items, size = 'xs' } = defineProps<{
|
|
13
|
+
count: number
|
|
14
|
+
/** The hidden values, listed in a tooltip. */
|
|
15
|
+
items?: string[]
|
|
16
|
+
size?: 'xs' | 'sm'
|
|
17
|
+
}>()
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<template v-if="count > 0">
|
|
22
|
+
<UiTooltip v-if="items?.length" :text="items.join(', ')">
|
|
23
|
+
<UiChip purpose="count" :size="size" tabular>
|
|
24
|
+
+{{ count }}
|
|
25
|
+
</UiChip>
|
|
26
|
+
</UiTooltip>
|
|
27
|
+
<UiChip v-else purpose="count" :size="size" tabular>
|
|
28
|
+
+{{ count }}
|
|
29
|
+
</UiChip>
|
|
30
|
+
</template>
|
|
31
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { size = 'sm' } = defineProps<{
|
|
3
|
+
size?: 'sm' | 'md'
|
|
4
|
+
}>()
|
|
5
|
+
|
|
6
|
+
const sizeClasses = {
|
|
7
|
+
sm: 'px-1.5 py-0.5 text-[9px] tracking-[0.16em]',
|
|
8
|
+
md: 'px-1.5 py-1 text-[10px] tracking-[0.16em]',
|
|
9
|
+
} as const
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<span
|
|
14
|
+
class="inline-flex shrink-0 items-center justify-center rounded-md bg-pro-500/10 font-bold uppercase leading-none text-pro-600 ring-1 ring-inset ring-pro-500/20 dark:text-pro-400"
|
|
15
|
+
:class="sizeClasses[size]"
|
|
16
|
+
>
|
|
17
|
+
Pro
|
|
18
|
+
</span>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { clamp } from '../../utils/number'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* UiProgressCircle
|
|
7
|
+
*
|
|
8
|
+
* Shows a circular progres
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
percent?: number | string
|
|
12
|
+
size?: number | string
|
|
13
|
+
strokeSize?: number | string
|
|
14
|
+
lighter?: boolean
|
|
15
|
+
/** Accessible name for the progressbar (e.g. "Indexing progress"). */
|
|
16
|
+
label?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { percent = 0, size = 36, strokeSize = 4, lighter, label } = defineProps<Props>()
|
|
20
|
+
|
|
21
|
+
const style = computed(() => ({
|
|
22
|
+
width: `${size}px`,
|
|
23
|
+
height: `${size}px`,
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
const svg = computed(() => ({
|
|
27
|
+
'viewBox': `0 0 ${size} ${size}`,
|
|
28
|
+
'stroke-width': strokeSize,
|
|
29
|
+
'fill': 'none',
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const circle = computed(() => {
|
|
33
|
+
const r = Number(size) / 2
|
|
34
|
+
return {
|
|
35
|
+
cx: r,
|
|
36
|
+
cy: r,
|
|
37
|
+
r: Math.max(1, r - Number(strokeSize) / 2),
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const offset = computed(() => clamp(100 - Number(percent), 0, 100))
|
|
42
|
+
|
|
43
|
+
const bg = computed(() => {
|
|
44
|
+
return lighter
|
|
45
|
+
? 'stroke-[var(--ui-bg-accented)]'
|
|
46
|
+
: 'stroke-[var(--ui-border)]'
|
|
47
|
+
})
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<div
|
|
52
|
+
data-ui="UiProgressCircle"
|
|
53
|
+
:style="style"
|
|
54
|
+
class="relative shrink-0"
|
|
55
|
+
role="progressbar"
|
|
56
|
+
:aria-label="label || `${Number(percent)}% complete`"
|
|
57
|
+
:aria-valuenow="Number(percent)"
|
|
58
|
+
:aria-valuemin="0"
|
|
59
|
+
:aria-valuemax="100"
|
|
60
|
+
:aria-valuetext="`${Number(percent)}%`"
|
|
61
|
+
>
|
|
62
|
+
<svg
|
|
63
|
+
v-bind="svg"
|
|
64
|
+
:class="bg"
|
|
65
|
+
class="absolute inset-0 w-full h-full"
|
|
66
|
+
>
|
|
67
|
+
<circle v-bind="circle" />
|
|
68
|
+
</svg>
|
|
69
|
+
<svg
|
|
70
|
+
v-bind="svg"
|
|
71
|
+
:stroke-dashoffset="offset"
|
|
72
|
+
:stroke-dasharray="100"
|
|
73
|
+
stroke-linecap="butt"
|
|
74
|
+
class="absolute inset-0 w-full h-full -rotate-90 stroke-primary-300"
|
|
75
|
+
>
|
|
76
|
+
<circle v-bind="circle" pathLength="100" />
|
|
77
|
+
</svg>
|
|
78
|
+
</div>
|
|
79
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { UiTooltip, UProgress } from '#components'
|
|
4
|
+
// A %-of-total viz bar — brand-colored by default. Constrained to the
|
|
5
|
+
// SemanticStatus set + `primary` (the design-system palette); the older
|
|
6
|
+
// `secondary`/`tertiary`/`pro` Nuxt UI aliases were never used by a caller.
|
|
7
|
+
type ProgressColor = 'primary' | 'success' | 'warning' | 'error' | 'info' | 'neutral'
|
|
8
|
+
|
|
9
|
+
const { total, value, tooltip, color = 'primary' } = defineProps<{
|
|
10
|
+
total?: string | number
|
|
11
|
+
value: string | number
|
|
12
|
+
tooltip?: string
|
|
13
|
+
color?: ProgressColor
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const percentage = computed(() => {
|
|
17
|
+
return (Number(value) / Number(total || 100)) * 100
|
|
18
|
+
})
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<UiTooltip :text="tooltip || `${percentage.toFixed(1)}% of clicks`" trigger-as="child">
|
|
23
|
+
<div class="min-w-0 w-full">
|
|
24
|
+
<slot />
|
|
25
|
+
<UProgress
|
|
26
|
+
:model-value="percentage || 0"
|
|
27
|
+
:color="color"
|
|
28
|
+
:aria-label="tooltip || `${percentage.toFixed(1)}% of clicks`"
|
|
29
|
+
class="opacity-90"
|
|
30
|
+
size="xs"
|
|
31
|
+
v-bind="$attrs"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
</UiTooltip>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue'
|
|
3
|
+
import { DISPLAY_LOCALE, formatTimeAgo, toTimestampDate } from '../../composables/formatting'
|
|
4
|
+
|
|
5
|
+
// Hydration-safe relative time. A bare relative string (e.g. "5m ago") is
|
|
6
|
+
// derived from Date.now(), so it differs between the server render and client
|
|
7
|
+
// hydration and trips a mismatch. This renders the *absolute* date during SSR
|
|
8
|
+
// and the first client paint (identical on both), then swaps to the live
|
|
9
|
+
// relative string after mount. The absolute date is always available in `title`.
|
|
10
|
+
const { date, fallback = '—', format = 'narrow' } = defineProps<{
|
|
11
|
+
/** Unix seconds or milliseconds (number), an ISO/date string, or a Date. */
|
|
12
|
+
date: number | string | Date | null | undefined
|
|
13
|
+
/** Shown when `date` is null/invalid. */
|
|
14
|
+
fallback?: string
|
|
15
|
+
/** `narrow` ("in 3d") for dense cells; `long` ("in 3 days") where the value stands alone. */
|
|
16
|
+
format?: 'narrow' | 'long'
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
// The server's timezone and the browser's can differ. Render in UTC through
|
|
20
|
+
// hydration, then switch the title to the viewer's timezone after mount
|
|
21
|
+
// alongside the relative display text. The locale stays pinned throughout.
|
|
22
|
+
const hydrationAbsoluteFormat = new Intl.DateTimeFormat(DISPLAY_LOCALE, {
|
|
23
|
+
dateStyle: 'medium',
|
|
24
|
+
timeStyle: 'short',
|
|
25
|
+
timeZone: 'UTC',
|
|
26
|
+
})
|
|
27
|
+
const localAbsoluteFormat = new Intl.DateTimeFormat(DISPLAY_LOCALE, { dateStyle: 'medium', timeStyle: 'short' })
|
|
28
|
+
|
|
29
|
+
const parsed = computed(() => {
|
|
30
|
+
if (date == null)
|
|
31
|
+
return null
|
|
32
|
+
const d = toTimestampDate(date)
|
|
33
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const mounted = ref(false)
|
|
37
|
+
onMounted(() => {
|
|
38
|
+
mounted.value = true
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const absolute = computed(() => {
|
|
42
|
+
if (!parsed.value)
|
|
43
|
+
return ''
|
|
44
|
+
return (mounted.value ? localAbsoluteFormat : hydrationAbsoluteFormat).format(parsed.value)
|
|
45
|
+
})
|
|
46
|
+
const display = computed(() => {
|
|
47
|
+
if (!parsed.value)
|
|
48
|
+
return fallback
|
|
49
|
+
// `formatTimeAgo` (and thus Date.now()) is only read after mount — never on
|
|
50
|
+
// the server — so SSR and first-paint HTML stay identical.
|
|
51
|
+
return mounted.value ? (formatTimeAgo(date, format) ?? absolute.value) : absolute.value
|
|
52
|
+
})
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<time v-if="parsed" :datetime="parsed.toISOString()" :title="absolute">{{ display }}</time>
|
|
57
|
+
<span v-else>{{ fallback }}</span>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
</script>
|
|
3
|
+
|
|
4
|
+
<template>
|
|
5
|
+
<div class="relative overflow-y-auto">
|
|
6
|
+
<div
|
|
7
|
+
aria-hidden="true"
|
|
8
|
+
class="sticky top-0 z-[5] h-5 -mb-5 pointer-events-none bg-gradient-to-b from-default to-transparent [overflow-anchor:none]"
|
|
9
|
+
/>
|
|
10
|
+
<slot />
|
|
11
|
+
<div
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
class="sticky bottom-0 z-[5] h-5 -mt-5 pointer-events-none bg-gradient-to-t from-default to-transparent [overflow-anchor:none]"
|
|
14
|
+
/>
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|