@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,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized semantic color system for the Pro dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Three concerns:
|
|
5
|
+
* 1. **Status colors** — success/error/warning/info/neutral for health, connection, validation states
|
|
6
|
+
* 2. **Threshold colors** — good/needs-attention/poor for CWV, indexing %, etc.
|
|
7
|
+
* 3. **Trend colors** — positive (green) / negative (red) / neutral for change values
|
|
8
|
+
*
|
|
9
|
+
* Metric visualization colors (clicks=blue, impressions=purple, etc.) are intentionally
|
|
10
|
+
* NOT included here — those are data-viz concerns, not semantic.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type SemanticStatus = 'success' | 'error' | 'warning' | 'info' | 'neutral'
|
|
14
|
+
export type HealthStatus = 'healthy' | 'attention' | 'issues' | 'unknown'
|
|
15
|
+
|
|
16
|
+
export interface SemanticColorSet {
|
|
17
|
+
text: string
|
|
18
|
+
bg: string
|
|
19
|
+
dot: string
|
|
20
|
+
border: string
|
|
21
|
+
hex: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Full class sets for each semantic status */
|
|
25
|
+
export const semanticColors: Record<SemanticStatus, SemanticColorSet> = {
|
|
26
|
+
success: { text: 'text-success', bg: 'bg-success/10', dot: 'bg-success', border: 'border-success/20', hex: '#22c55e' },
|
|
27
|
+
error: { text: 'text-error', bg: 'bg-error/10', dot: 'bg-error', border: 'border-error/20', hex: '#ef4444' },
|
|
28
|
+
warning: { text: 'text-warning', bg: 'bg-warning/10', dot: 'bg-warning', border: 'border-warning/20', hex: '#eab308' },
|
|
29
|
+
info: { text: 'text-info', bg: 'bg-info/10', dot: 'bg-info', border: 'border-info/20', hex: '#3b82f6' },
|
|
30
|
+
// hex defaults to a violet-tinted grey (hue ~292) matching the de-chromatized
|
|
31
|
+
// neutral ramp, not stock cold blue-slate, so chart neutrals sit with the
|
|
32
|
+
// surface greys instead of reading cool against them. A site that extends the
|
|
33
|
+
// layer sets `--ui-viz-neutral`. It is a CSS colour: mix it with `color-mix()`,
|
|
34
|
+
// never parse it as hex. Light mode reads a darker `--_viz-neutral`, so
|
|
35
|
+
// neutral marks clear 3:1 on white (css/global.css).
|
|
36
|
+
neutral: { text: 'text-muted', bg: 'bg-accented', dot: 'bg-[var(--ui-border)]', border: 'border-default', hex: 'var(--ui-viz-neutral, var(--_viz-neutral, #9e9aa6))' },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Map dashboard health status to semantic status */
|
|
40
|
+
export function healthToSemantic(health: HealthStatus | null): SemanticStatus {
|
|
41
|
+
switch (health) {
|
|
42
|
+
case 'healthy': return 'success'
|
|
43
|
+
case 'attention': return 'warning'
|
|
44
|
+
case 'issues': return 'error'
|
|
45
|
+
default: return 'neutral'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Map a numeric value against good/poor thresholds to semantic status */
|
|
50
|
+
export function thresholdToSemantic(value: number, good: number, poor: number): SemanticStatus {
|
|
51
|
+
if (value <= good)
|
|
52
|
+
return 'success'
|
|
53
|
+
if (value <= poor)
|
|
54
|
+
return 'warning'
|
|
55
|
+
return 'error'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Map a trend direction to semantic status — positive=success, negative=error, zero=neutral */
|
|
59
|
+
export function trendToSemantic(value: number): SemanticStatus {
|
|
60
|
+
if (value > 0)
|
|
61
|
+
return 'success'
|
|
62
|
+
if (value < 0)
|
|
63
|
+
return 'error'
|
|
64
|
+
return 'neutral'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shorthand: get the full color set for a health status */
|
|
68
|
+
export function healthColors(health: HealthStatus | null): SemanticColorSet {
|
|
69
|
+
return semanticColors[healthToSemantic(health)]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Shorthand: get the full color set for a threshold value */
|
|
73
|
+
export function thresholdColors(value: number, good: number, poor: number): SemanticColorSet {
|
|
74
|
+
return semanticColors[thresholdToSemantic(value, good, poor)]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Shorthand: get hex color for a threshold value (for chart rendering) */
|
|
78
|
+
export function thresholdHex(value: number, good: number, poor: number): string {
|
|
79
|
+
return thresholdColors(value, good, poor).hex
|
|
80
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useState } from 'nuxt/app'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useActivityOverlay — the layer-agnostic trigger for the activity detail
|
|
5
|
+
* overlay (2026-08). Every "Recent Activity" row, on every surface, opens the
|
|
6
|
+
* SAME detail surface through this channel.
|
|
7
|
+
*
|
|
8
|
+
* The defect it exists to kill: an activity row knows exactly which entity it
|
|
9
|
+
* is about (a referring domain, a mention URL, a scanned page), and its
|
|
10
|
+
* destination threw that identity away and dropped the reader on a list. A row
|
|
11
|
+
* about `t128n.dev` landed on the whole backlink profile; a mention row had no
|
|
12
|
+
* destination at all. One overlay, one contract, so the class dies rather than
|
|
13
|
+
* one instance of it.
|
|
14
|
+
*
|
|
15
|
+
* Why it lives in design-system, not in a pro-* layer: ADR-0042 forbids feature
|
|
16
|
+
* layers importing each other's runtime, and the writers span pro-timeline,
|
|
17
|
+
* pro-dataforseo and `apps/pro`. So the trigger is a generic `useState` channel
|
|
18
|
+
* here (which every layer may import) and `apps/pro` owns the host
|
|
19
|
+
* (`ProActivityOverlayHost`) that reads this state and renders the real change
|
|
20
|
+
* brief. Same seam shape as `useIssueOverlay`; the entry travels opaquely
|
|
21
|
+
* (`unknown`) so design-system never learns the ChangeEntry shape.
|
|
22
|
+
*
|
|
23
|
+
* A row must only write here when it holds real evidence. An empty overlay is
|
|
24
|
+
* worse than a row that says nothing.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface ActivityOverlayRequest {
|
|
28
|
+
/** The activity entry (opaque here; the host casts it to `ChangeEntry`). */
|
|
29
|
+
entry: unknown
|
|
30
|
+
/** The site the activity belongs to — the brief is site-scoped. */
|
|
31
|
+
siteId: string
|
|
32
|
+
/**
|
|
33
|
+
* The owning site record, when the caller already holds one. Opaque for the
|
|
34
|
+
* same reason `entry` is. Omitted callers fall back to the session lookup.
|
|
35
|
+
*/
|
|
36
|
+
site?: unknown
|
|
37
|
+
/** When the owning site was last assessed — the brief's "as of" anchor. */
|
|
38
|
+
assessedAt?: string | null
|
|
39
|
+
/** Bumped per open so the host re-triggers even for the same entry. */
|
|
40
|
+
nonce: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The shared overlay channel — read by the host, written by `open`. */
|
|
44
|
+
export function useActivityOverlayState() {
|
|
45
|
+
return useState<ActivityOverlayRequest | null>('pro:activity-overlay', () => null)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useActivityOverlay() {
|
|
49
|
+
const state = useActivityOverlayState()
|
|
50
|
+
return {
|
|
51
|
+
open(req: { entry: unknown, siteId: string, site?: unknown, assessedAt?: string | null }) {
|
|
52
|
+
state.value = { ...req, nonce: (state.value?.nonce ?? 0) + 1 }
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { InjectionKey } from 'vue'
|
|
2
|
+
import { inject, provide } from 'vue'
|
|
3
|
+
|
|
4
|
+
// Shell drawer control shared down the tree. UiAppShell provides it; a
|
|
5
|
+
// descendant page header (saas `ProPageHeader`) injects it to render the
|
|
6
|
+
// hamburger inline with the page title below `lg` (shell `inlineMobileNav`).
|
|
7
|
+
// Null outside a shell: consumers hide their hamburger when it is absent,
|
|
8
|
+
// so a header rendered standalone keeps working.
|
|
9
|
+
export interface UiAppShellNav {
|
|
10
|
+
openNav: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const key: InjectionKey<UiAppShellNav> = Symbol('ui-app-shell-nav')
|
|
14
|
+
|
|
15
|
+
export function provideUiAppShellNav(nav: UiAppShellNav) {
|
|
16
|
+
provide(key, nav)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useUiAppShellNav(): UiAppShellNav | null {
|
|
20
|
+
return inject(key, null)
|
|
21
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter, Ref } from 'vue'
|
|
2
|
+
import { useResizeObserver } from '@vueuse/core'
|
|
3
|
+
import { computed, onUnmounted, ref, toValue } from 'vue'
|
|
4
|
+
|
|
5
|
+
// Provider-agnostic drag-to-select-range interaction for time-series charts.
|
|
6
|
+
// Extracted from ProCardGsc's pointer-drag logic (the brush half — hover/tooltip
|
|
7
|
+
// springs stay with the consuming card) so the Web Analytics chart reuses the
|
|
8
|
+
// same scrubbing UX without copying ~120 lines. The consumer binds `wrapRef` to
|
|
9
|
+
// the chart wrapper, draws the selection rect from `selectionLeft`/`selectionWidth`,
|
|
10
|
+
// and gets a committed `{ startIdx, endIdx, startDate, endDate }` via `onCommit`.
|
|
11
|
+
|
|
12
|
+
export interface ChartBrushRange {
|
|
13
|
+
startIdx: number
|
|
14
|
+
endIdx: number
|
|
15
|
+
startDate: string
|
|
16
|
+
endDate: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseChartBrushOptions {
|
|
20
|
+
/** Reactive ISO date strings, one per data point — drives idx ↔ date lookups. */
|
|
21
|
+
dates: MaybeRefOrGetter<string[]>
|
|
22
|
+
/** Called when a drag completes spanning more than one point. */
|
|
23
|
+
onCommit: (range: ChartBrushRange) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseChartBrush {
|
|
27
|
+
wrapRef: Ref<HTMLElement | null>
|
|
28
|
+
chartWidth: Ref<number>
|
|
29
|
+
isDragging: Ref<boolean>
|
|
30
|
+
dragRange: Ref<ChartBrushRange | null>
|
|
31
|
+
selectionLeft: Ref<number>
|
|
32
|
+
selectionWidth: Ref<number>
|
|
33
|
+
onPointerDown: (e: PointerEvent) => void
|
|
34
|
+
refreshChartRect: () => void
|
|
35
|
+
cancel: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DRAG_THRESHOLD_PX = 5
|
|
39
|
+
|
|
40
|
+
// Suppress page text-selection for the duration of a brush drag — a fast drag
|
|
41
|
+
// otherwise selects surrounding copy (the "disable text selection during drag"
|
|
42
|
+
// rule). Restored when the drag ends/cancels.
|
|
43
|
+
function setSelectSuppressed(on: boolean) {
|
|
44
|
+
if (typeof document === 'undefined')
|
|
45
|
+
return
|
|
46
|
+
const s = document.documentElement.style
|
|
47
|
+
s.setProperty('user-select', on ? 'none' : '')
|
|
48
|
+
s.setProperty('-webkit-user-select', on ? 'none' : '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useChartBrush(opts: UseChartBrushOptions): UseChartBrush {
|
|
52
|
+
const wrapRef = ref<HTMLElement | null>(null)
|
|
53
|
+
// Cached geometry — avoids synchronous layout reads on every pointermove.
|
|
54
|
+
const chartWidth = ref(0)
|
|
55
|
+
let chartRectLeft = 0
|
|
56
|
+
|
|
57
|
+
const isDragging = ref(false)
|
|
58
|
+
const dragRange = ref<ChartBrushRange | null>(null)
|
|
59
|
+
|
|
60
|
+
useResizeObserver(wrapRef, ([entry]) => {
|
|
61
|
+
if (!entry)
|
|
62
|
+
return
|
|
63
|
+
// Width comes straight off contentRect (no layout read). `chartRectLeft` is
|
|
64
|
+
// refreshed via getBoundingClientRect() on pointerdown (refreshChartRect),
|
|
65
|
+
// so reading it here too would just be a redundant synchronous reflow.
|
|
66
|
+
chartWidth.value = entry.contentRect.width
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
function refreshChartRect() {
|
|
70
|
+
const el = wrapRef.value
|
|
71
|
+
if (!el)
|
|
72
|
+
return
|
|
73
|
+
const rect = el.getBoundingClientRect()
|
|
74
|
+
chartRectLeft = rect.left
|
|
75
|
+
chartWidth.value = rect.width
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pixelToIdx(px: number): number {
|
|
79
|
+
const len = toValue(opts.dates).length
|
|
80
|
+
const width = chartWidth.value
|
|
81
|
+
if (len < 2 || width <= 0)
|
|
82
|
+
return 0
|
|
83
|
+
return Math.max(0, Math.min(len - 1, Math.round((px / width) * (len - 1))))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function idxToPixel(idx: number): number {
|
|
87
|
+
const len = toValue(opts.dates).length
|
|
88
|
+
const width = chartWidth.value
|
|
89
|
+
if (len < 2 || width <= 0)
|
|
90
|
+
return 0
|
|
91
|
+
return (idx / (len - 1)) * width
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function idxToDate(idx: number): string {
|
|
95
|
+
const rows = toValue(opts.dates)
|
|
96
|
+
const clamped = Math.max(0, Math.min(rows.length - 1, idx))
|
|
97
|
+
return rows[clamped] ?? ''
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const selectionLeft = computed(() => dragRange.value ? idxToPixel(dragRange.value.startIdx) : 0)
|
|
101
|
+
const selectionWidth = computed(() => {
|
|
102
|
+
if (!dragRange.value)
|
|
103
|
+
return 0
|
|
104
|
+
return Math.max(0, idxToPixel(dragRange.value.endIdx) - idxToPixel(dragRange.value.startIdx))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
let dragStartX = 0
|
|
108
|
+
let dragStartIdx = 0
|
|
109
|
+
let removeWindowListeners: (() => void) | null = null
|
|
110
|
+
|
|
111
|
+
function cancel() {
|
|
112
|
+
removeWindowListeners?.()
|
|
113
|
+
removeWindowListeners = null
|
|
114
|
+
isDragging.value = false
|
|
115
|
+
dragRange.value = null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onPointerDown(e: PointerEvent) {
|
|
119
|
+
if (e.button !== 0)
|
|
120
|
+
return
|
|
121
|
+
if (!wrapRef.value)
|
|
122
|
+
return
|
|
123
|
+
// A second touch or pointer must replace the active session. Otherwise its
|
|
124
|
+
// cleanup closure overwrites the first one and leaves global listeners alive.
|
|
125
|
+
cancel()
|
|
126
|
+
// Single BCR read on drag start — covers scroll/offset drift since last observer tick.
|
|
127
|
+
refreshChartRect()
|
|
128
|
+
dragStartX = e.clientX - chartRectLeft
|
|
129
|
+
dragStartIdx = pixelToIdx(dragStartX)
|
|
130
|
+
const startY = e.clientY
|
|
131
|
+
const pointerId = e.pointerId
|
|
132
|
+
|
|
133
|
+
// rAF-coalesce moves: a fast drag fires pointermove many times per frame,
|
|
134
|
+
// but we only need one reactive `dragRange` write (and one overlay render)
|
|
135
|
+
// per frame. Listener is passive — we never preventDefault on move.
|
|
136
|
+
let moveRaf: number | null = null
|
|
137
|
+
let pendingX = 0
|
|
138
|
+
const onMove = (ev: PointerEvent) => {
|
|
139
|
+
if (ev.pointerId !== pointerId)
|
|
140
|
+
return
|
|
141
|
+
pendingX = ev.clientX - chartRectLeft
|
|
142
|
+
// Vertical touch intent belongs to page scrolling, even with horizontal drift.
|
|
143
|
+
if (e.pointerType === 'touch' && !isDragging.value && Math.abs(ev.clientY - startY) >= DRAG_THRESHOLD_PX
|
|
144
|
+
&& Math.abs(ev.clientY - startY) >= Math.abs(pendingX - dragStartX)) {
|
|
145
|
+
cancel()
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (moveRaf != null)
|
|
149
|
+
return
|
|
150
|
+
moveRaf = requestAnimationFrame(() => {
|
|
151
|
+
moveRaf = null
|
|
152
|
+
const currentX = pendingX
|
|
153
|
+
if (!isDragging.value) {
|
|
154
|
+
if (Math.abs(currentX - dragStartX) < DRAG_THRESHOLD_PX)
|
|
155
|
+
return
|
|
156
|
+
isDragging.value = true
|
|
157
|
+
setSelectSuppressed(true)
|
|
158
|
+
}
|
|
159
|
+
dragRange.value = rangeTo(currentX)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function rangeTo(x: number): ChartBrushRange {
|
|
164
|
+
const currentIdx = pixelToIdx(x)
|
|
165
|
+
const startIdx = Math.min(dragStartIdx, currentIdx)
|
|
166
|
+
const endIdx = Math.max(dragStartIdx, currentIdx)
|
|
167
|
+
return { startIdx, endIdx, startDate: idxToDate(startIdx), endDate: idxToDate(endIdx) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const onUp = (ev: PointerEvent) => {
|
|
171
|
+
if (ev.pointerId !== pointerId)
|
|
172
|
+
return
|
|
173
|
+
removeWindowListeners?.()
|
|
174
|
+
removeWindowListeners = null
|
|
175
|
+
// Read the range from the release point. The rAF that writes
|
|
176
|
+
// `dragRange` is cancelled above, so a flick that ends inside one frame
|
|
177
|
+
// had no range and never committed.
|
|
178
|
+
const upX = ev.clientX - chartRectLeft
|
|
179
|
+
const range = Math.abs(upX - dragStartX) >= DRAG_THRESHOLD_PX ? rangeTo(upX) : null
|
|
180
|
+
isDragging.value = false
|
|
181
|
+
dragRange.value = null
|
|
182
|
+
if (range && range.endIdx > range.startIdx)
|
|
183
|
+
opts.onCommit(range)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const onCancel = (ev: PointerEvent) => {
|
|
187
|
+
if (ev.pointerId === pointerId)
|
|
188
|
+
cancel()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
window.addEventListener('pointermove', onMove, { passive: true })
|
|
192
|
+
window.addEventListener('pointerup', onUp)
|
|
193
|
+
window.addEventListener('pointercancel', onCancel)
|
|
194
|
+
window.addEventListener('wheel', cancel, { passive: true })
|
|
195
|
+
window.addEventListener('scroll', cancel, { passive: true, capture: true })
|
|
196
|
+
removeWindowListeners = () => {
|
|
197
|
+
if (moveRaf != null)
|
|
198
|
+
cancelAnimationFrame(moveRaf)
|
|
199
|
+
setSelectSuppressed(false)
|
|
200
|
+
window.removeEventListener('pointermove', onMove)
|
|
201
|
+
window.removeEventListener('pointerup', onUp)
|
|
202
|
+
window.removeEventListener('pointercancel', onCancel)
|
|
203
|
+
window.removeEventListener('wheel', cancel)
|
|
204
|
+
window.removeEventListener('scroll', cancel, { capture: true })
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
onUnmounted(() => removeWindowListeners?.())
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
wrapRef,
|
|
212
|
+
chartWidth,
|
|
213
|
+
isDragging,
|
|
214
|
+
dragRange,
|
|
215
|
+
selectionLeft,
|
|
216
|
+
selectionWidth,
|
|
217
|
+
onPointerDown,
|
|
218
|
+
refreshChartRect,
|
|
219
|
+
cancel,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { MotionValue } from 'motion-v'
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
import { useMotionValue, useSpring } from 'motion-v'
|
|
4
|
+
import { onScopeDispose, ref, shallowRef } from 'vue'
|
|
5
|
+
|
|
6
|
+
// Hover/tooltip half of a time-series chart's pointer UX. Sibling to
|
|
7
|
+
// useChartBrush — both cards (Gsc, Analytics) had near-identical tooltip
|
|
8
|
+
// state + spring-tracked card positioning. Pass in the brush's `isDragging`
|
|
9
|
+
// to suppress the tooltip during a drag (the selection overlay owns the
|
|
10
|
+
// visual then).
|
|
11
|
+
//
|
|
12
|
+
// Positioning model: the tooltip anchors to the *cursor* (not the chart edge)
|
|
13
|
+
// via spring-tracked `cardX` + `cursorY`. The `placement` ref says whether
|
|
14
|
+
// the tooltip sits above or below the cursor; consumers translate the
|
|
15
|
+
// rendered element accordingly. Above-cursor is the default; flips to below
|
|
16
|
+
// when the cursor is too close to the chart's top edge to fit the tooltip.
|
|
17
|
+
|
|
18
|
+
export interface UseChartHoverOptions {
|
|
19
|
+
/** Chart wrapper element — used for BCR reads. */
|
|
20
|
+
wrapRef: Ref<HTMLElement | null>
|
|
21
|
+
/** Reactive chart width in px — usually from useChartBrush. */
|
|
22
|
+
chartWidth: Ref<number>
|
|
23
|
+
/** When true, onTooltip clears state instead of setting it. */
|
|
24
|
+
isDragging?: Ref<boolean>
|
|
25
|
+
/** Tooltip card width in px (used to keep the card on-screen). */
|
|
26
|
+
tooltipWidth?: number
|
|
27
|
+
/** Approximate tooltip height — used to decide when to flip above ↔ below. */
|
|
28
|
+
tooltipHeightEst?: number
|
|
29
|
+
/** Padding from the chart edges in px. */
|
|
30
|
+
edgePad?: number
|
|
31
|
+
/** Vertical gap between cursor and tooltip edge. */
|
|
32
|
+
cursorGap?: number
|
|
33
|
+
/** Spring config for the floating card (softer — glides). */
|
|
34
|
+
cardSpring?: { stiffness: number, damping: number, mass: number }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseChartHover<T, E = void> {
|
|
38
|
+
tooltipData: Ref<T | null>
|
|
39
|
+
tooltipPrev: Ref<T | null>
|
|
40
|
+
tooltipExtra: Ref<E | null>
|
|
41
|
+
/** Spring-tracked cursor x (centre of tooltip). */
|
|
42
|
+
cardSpring: MotionValue<number>
|
|
43
|
+
/** Spring-tracked cursor y (anchor point of tooltip). */
|
|
44
|
+
cursorYSpring: MotionValue<number>
|
|
45
|
+
/** Which side of the cursor the tooltip sits on. Reactive. */
|
|
46
|
+
placement: Ref<'above' | 'below'>
|
|
47
|
+
onTooltip: (data: T | null, prev: T | null, extra?: E) => void
|
|
48
|
+
onChartMove: (e: MouseEvent) => void
|
|
49
|
+
clear: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DEFAULT_SPRING = { stiffness: 380, damping: 38, mass: 0.5 }
|
|
53
|
+
|
|
54
|
+
export function useChartHover<T, E = void>(opts: UseChartHoverOptions): UseChartHover<T, E> {
|
|
55
|
+
const tooltipWidth = opts.tooltipWidth ?? 220
|
|
56
|
+
const tooltipHeightEst = opts.tooltipHeightEst ?? 56
|
|
57
|
+
const edgePad = opts.edgePad ?? 8
|
|
58
|
+
const cursorGap = opts.cursorGap ?? 14
|
|
59
|
+
// Approx axis row height; tooltip avoids sitting on top of axis tick labels.
|
|
60
|
+
const AXIS_ROW = 36
|
|
61
|
+
|
|
62
|
+
// shallowRef: these hold whole datum objects swapped wholesale on hover —
|
|
63
|
+
// deep reactivity would be wasted tracking on every pointer move.
|
|
64
|
+
const tooltipData = shallowRef<T | null>(null)
|
|
65
|
+
const tooltipPrev = shallowRef<T | null>(null)
|
|
66
|
+
const tooltipExtra = shallowRef<E | null>(null)
|
|
67
|
+
|
|
68
|
+
function clear() {
|
|
69
|
+
tooltipData.value = null
|
|
70
|
+
tooltipPrev.value = null
|
|
71
|
+
tooltipExtra.value = null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function onTooltip(data: T | null, prev: T | null, extra?: E) {
|
|
75
|
+
if (opts.isDragging?.value) {
|
|
76
|
+
clear()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
tooltipData.value = data
|
|
80
|
+
tooltipPrev.value = prev
|
|
81
|
+
tooltipExtra.value = (extra ?? null) as E | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cardX = useMotionValue(0)
|
|
85
|
+
const cursorY = useMotionValue(0)
|
|
86
|
+
// Honour prefers-reduced-motion: skip the glide spring and track the cursor
|
|
87
|
+
// position directly so the tooltip jumps instead of easing.
|
|
88
|
+
const prefersReduced = import.meta.client
|
|
89
|
+
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
|
|
90
|
+
const cardSpring = prefersReduced ? cardX : useSpring(cardX, opts.cardSpring ?? DEFAULT_SPRING)
|
|
91
|
+
const cursorYSpring = prefersReduced ? cursorY : useSpring(cursorY, opts.cardSpring ?? DEFAULT_SPRING)
|
|
92
|
+
// Default 'above' (tooltip floats above cursor). Flips to 'below' when the
|
|
93
|
+
// cursor is too close to the top edge to fit the tooltip without clipping.
|
|
94
|
+
const placement = ref<'above' | 'below'>('above')
|
|
95
|
+
|
|
96
|
+
// rAF-batch the mousemove handler so we do at most one motion update per frame.
|
|
97
|
+
let rafId: number | null = null
|
|
98
|
+
let pendingClientX = 0
|
|
99
|
+
let pendingClientY = 0
|
|
100
|
+
function onChartMove(e: MouseEvent) {
|
|
101
|
+
pendingClientX = e.clientX
|
|
102
|
+
pendingClientY = e.clientY
|
|
103
|
+
if (rafId != null)
|
|
104
|
+
return
|
|
105
|
+
rafId = requestAnimationFrame(() => {
|
|
106
|
+
rafId = null
|
|
107
|
+
const width = opts.chartWidth.value
|
|
108
|
+
if (width <= 0)
|
|
109
|
+
return
|
|
110
|
+
const rect = opts.wrapRef.value?.getBoundingClientRect()
|
|
111
|
+
const cursorXpx = pendingClientX - (rect?.left ?? 0)
|
|
112
|
+
const cursorYpx = pendingClientY - (rect?.top ?? 0)
|
|
113
|
+
// Clamp x so the tooltip's left/right edges stay within the chart.
|
|
114
|
+
const halfW = tooltipWidth / 2
|
|
115
|
+
const clampedX = Math.max(halfW + edgePad, Math.min(width - halfW - edgePad, cursorXpx))
|
|
116
|
+
cardX.set(clampedX)
|
|
117
|
+
cursorY.set(cursorYpx)
|
|
118
|
+
|
|
119
|
+
// Decide placement. Prefer above (tooltip floats over the data). Flip
|
|
120
|
+
// to below when too close to the top edge to fit the full tooltip.
|
|
121
|
+
// Also flip back to above when cursor would push the tooltip into the
|
|
122
|
+
// axis-tick row at the bottom.
|
|
123
|
+
const height = rect?.height ?? 0
|
|
124
|
+
const fitsAbove = cursorYpx - cursorGap - tooltipHeightEst >= edgePad
|
|
125
|
+
const fitsBelow = cursorYpx + cursorGap + tooltipHeightEst <= height - AXIS_ROW
|
|
126
|
+
if (placement.value === 'above' && !fitsAbove && fitsBelow)
|
|
127
|
+
placement.value = 'below'
|
|
128
|
+
else if (placement.value === 'below' && fitsAbove && !fitsBelow)
|
|
129
|
+
placement.value = 'above'
|
|
130
|
+
else if (placement.value === 'below' && fitsAbove)
|
|
131
|
+
placement.value = 'above'
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Cancel any frame still queued when the consuming component tears down, so
|
|
136
|
+
// the callback never reads a stale `wrapRef` element after unmount.
|
|
137
|
+
onScopeDispose(() => {
|
|
138
|
+
if (rafId != null)
|
|
139
|
+
cancelAnimationFrame(rafId)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
tooltipData,
|
|
144
|
+
tooltipPrev,
|
|
145
|
+
tooltipExtra,
|
|
146
|
+
cardSpring,
|
|
147
|
+
cursorYSpring,
|
|
148
|
+
placement,
|
|
149
|
+
onTooltip,
|
|
150
|
+
onChartMove,
|
|
151
|
+
clear,
|
|
152
|
+
}
|
|
153
|
+
}
|