@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,143 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { UiIcon as UiIconName } from '../../shared/ui-icons'
|
|
3
|
+
import type { ViewState } from '../../shared/view-state'
|
|
4
|
+
import type { WidgetFailure } from '../../shared/widget-failure'
|
|
5
|
+
import { computed, onMounted, ref } from 'vue'
|
|
6
|
+
import { UiButton, UiEmptyState, UiIcon, UiSkeleton } from '#components'
|
|
7
|
+
import { toWidgetFailure, WIDGET_FAILURE_COPY } from '../../shared/widget-failure'
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
state,
|
|
11
|
+
status = 'idle',
|
|
12
|
+
error,
|
|
13
|
+
empty,
|
|
14
|
+
skeletonLines = 5,
|
|
15
|
+
skeletonType = 'text',
|
|
16
|
+
emptyIcon = 'inbox',
|
|
17
|
+
emptyTitle = 'No data',
|
|
18
|
+
emptyMessage = 'There is no data to display',
|
|
19
|
+
showLoadingBeforeHydration = false,
|
|
20
|
+
retainContentOnError = false,
|
|
21
|
+
} = defineProps<{
|
|
22
|
+
/**
|
|
23
|
+
* The read behind the region, built with `viewState` or `listViewState`.
|
|
24
|
+
* When set, it decides the branch and `status`, `error` and `empty` are
|
|
25
|
+
* ignored. Only an `empty` state shows the empty copy, so a failed read can
|
|
26
|
+
* never say "No data".
|
|
27
|
+
*/
|
|
28
|
+
state?: ViewState<unknown>
|
|
29
|
+
/** The read's lifecycle. Ignored when `state` is set. */
|
|
30
|
+
status?: 'idle' | 'pending' | 'success' | 'error'
|
|
31
|
+
/**
|
|
32
|
+
* The caught value, for classification only. Typed `unknown` on purpose: the
|
|
33
|
+
* template can read no string off it, so a wire message has no route to the
|
|
34
|
+
* screen. Pass the raw query error — wrapping it in `new Error(humanCopy)`
|
|
35
|
+
* throws away the status code this needs and buys nothing.
|
|
36
|
+
*/
|
|
37
|
+
error?: unknown
|
|
38
|
+
empty?: boolean
|
|
39
|
+
skeletonLines?: number
|
|
40
|
+
skeletonType?: 'text' | 'bar' | 'circle'
|
|
41
|
+
emptyIcon?: UiIconName
|
|
42
|
+
emptyTitle?: string
|
|
43
|
+
emptyMessage?: string
|
|
44
|
+
/**
|
|
45
|
+
* Render a deterministic loading slot during SSR and hydration. Use only for
|
|
46
|
+
* explicitly client-only reads whose payload cannot resolve before hydration.
|
|
47
|
+
*/
|
|
48
|
+
showLoadingBeforeHydration?: boolean
|
|
49
|
+
/** The caller has usable content owned by the current query scope. */
|
|
50
|
+
retainContentOnError?: boolean
|
|
51
|
+
}>()
|
|
52
|
+
|
|
53
|
+
const emit = defineEmits<{
|
|
54
|
+
retry: []
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
defineSlots<{
|
|
58
|
+
/**
|
|
59
|
+
* `data` is the answer of a `state` in its `data` branch. It is `unknown`
|
|
60
|
+
* here; read the typed answer from the state you passed.
|
|
61
|
+
*/
|
|
62
|
+
default?: (props: { data: unknown }) => unknown
|
|
63
|
+
loading?: () => unknown
|
|
64
|
+
empty?: () => unknown
|
|
65
|
+
error?: (props: { failure: WidgetFailure, retry: () => void }) => unknown
|
|
66
|
+
}>()
|
|
67
|
+
|
|
68
|
+
// Suppress loading skeletons until after hydration so server and client
|
|
69
|
+
// render the same branch. Lazy fetches transfer data via the SSR payload,
|
|
70
|
+
// making status jump from pending→success between render and hydrate.
|
|
71
|
+
const hydrated = ref(false)
|
|
72
|
+
onMounted(() => {
|
|
73
|
+
hydrated.value = true
|
|
74
|
+
})
|
|
75
|
+
// A `state` needs no hydration gate: a read with no answer yet is `loading`
|
|
76
|
+
// on the server and on the client, so both render the same branch.
|
|
77
|
+
const isLoading = computed(() => state
|
|
78
|
+
? state._tag === 'loading'
|
|
79
|
+
: (showLoadingBeforeHydration || hydrated.value) && (status === 'pending' || status === 'idle'))
|
|
80
|
+
// `status` alone decides the branch. It used to also require a truthy `error`,
|
|
81
|
+
// which meant a caller that mapped its own failure to `status="error"` without
|
|
82
|
+
// threading an error object fell past this branch AND past the empty branch,
|
|
83
|
+
// into the default slot: a heading over an empty box. Half the call sites did
|
|
84
|
+
// exactly that, so the error object is no longer a gate.
|
|
85
|
+
const isError = computed(() => state ? state._tag === 'error' : status === 'error')
|
|
86
|
+
const isEmpty = computed(() => state ? state._tag === 'empty' : status === 'success' && empty)
|
|
87
|
+
const failure = computed(() => toWidgetFailure(state?._tag === 'error' ? state.error : error))
|
|
88
|
+
const data = computed(() => state?._tag === 'data' ? state.data : undefined)
|
|
89
|
+
// A refresh failed while an earlier answer is still on screen. The content
|
|
90
|
+
// stays and a notice says the refresh failed.
|
|
91
|
+
const refreshFailure = computed(() => state?._tag === 'data' && state.refreshError ? WIDGET_FAILURE_COPY[toWidgetFailure(state.refreshError)._tag] : null)
|
|
92
|
+
const failureCopy = computed(() => WIDGET_FAILURE_COPY[failure.value._tag])
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<div data-ui="UiWidgetState" :aria-busy="isLoading">
|
|
97
|
+
<slot v-if="isLoading" name="loading">
|
|
98
|
+
<span class="sr-only" role="status" aria-live="polite">Loading…</span>
|
|
99
|
+
<UiSkeleton v-if="skeletonType === 'text'" :lines="skeletonLines" />
|
|
100
|
+
<div v-else-if="skeletonType === 'bar'" class="flex items-end gap-1 h-40">
|
|
101
|
+
<UiSkeleton v-for="i in skeletonLines" :key="i" type="bar" :index="i" />
|
|
102
|
+
</div>
|
|
103
|
+
<UiSkeleton v-else type="circle" :base="skeletonLines" />
|
|
104
|
+
</slot>
|
|
105
|
+
<slot v-else-if="isError" name="error" :failure="failure" :retry="() => emit('retry')">
|
|
106
|
+
<div class="flex flex-col items-center justify-center py-8 px-4 text-center" role="alert">
|
|
107
|
+
<div class="size-10 rounded-xl bg-error/10 flex items-center justify-center mb-3">
|
|
108
|
+
<UiIcon name="caution" class="size-5 text-error" aria-hidden="true" />
|
|
109
|
+
</div>
|
|
110
|
+
<p class="text-sm font-medium text-default mb-1">
|
|
111
|
+
{{ failureCopy.title }}
|
|
112
|
+
</p>
|
|
113
|
+
<p class="text-xs text-muted mb-3 max-w-xs text-balance">
|
|
114
|
+
{{ failureCopy.body }}
|
|
115
|
+
</p>
|
|
116
|
+
<UiButton v-if="failureCopy.retryable" size="xs" purpose="secondary" icon="refresh" @click="emit('retry')">
|
|
117
|
+
Retry
|
|
118
|
+
</UiButton>
|
|
119
|
+
</div>
|
|
120
|
+
</slot>
|
|
121
|
+
<slot v-else-if="isEmpty" name="empty">
|
|
122
|
+
<UiEmptyState :icon="emptyIcon" :title="emptyTitle" :description="emptyMessage" compact />
|
|
123
|
+
</slot>
|
|
124
|
+
<template v-else>
|
|
125
|
+
<div
|
|
126
|
+
v-if="refreshFailure"
|
|
127
|
+
role="alert"
|
|
128
|
+
data-ui="UiWidgetState-refresh-error"
|
|
129
|
+
class="mb-3 flex items-start gap-2.5 rounded-lg border border-default px-3 py-2"
|
|
130
|
+
>
|
|
131
|
+
<UiIcon name="caution" class="mt-0.5 size-4 shrink-0 text-error" aria-hidden="true" />
|
|
132
|
+
<p class="min-w-0 flex-1 text-sm text-muted">
|
|
133
|
+
<span class="font-medium text-default">{{ refreshFailure.title }}.</span> {{ refreshFailure.body }}
|
|
134
|
+
</p>
|
|
135
|
+
<UiButton v-if="refreshFailure.retryable" purpose="secondary" size="xs" icon="refresh" @click="emit('retry')">
|
|
136
|
+
Retry
|
|
137
|
+
</UiButton>
|
|
138
|
+
</div>
|
|
139
|
+
<slot :data="data" />
|
|
140
|
+
</template>
|
|
141
|
+
<slot v-if="isError && retainContentOnError" :data="data" />
|
|
142
|
+
</div>
|
|
143
|
+
</template>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { InjectionKey } from 'vue'
|
|
2
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
3
|
+
import { inject } from 'vue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Controls for UiAppShell's mobile nav drawer, provided by the shell to slot
|
|
7
|
+
* content rendered inside it. Absent a provider this no-ops, so a consumer
|
|
8
|
+
* stays renderable outside the shell (sandbox pages, tests).
|
|
9
|
+
*/
|
|
10
|
+
export interface UiNavDrawer {
|
|
11
|
+
/**
|
|
12
|
+
* Keep the drawer open across the navigation to `target`. Arms exactly one
|
|
13
|
+
* route change: landing on `target` keeps the drawer open, any other route
|
|
14
|
+
* change closes it as usual. For in-drawer pane switches (the sidebar mode
|
|
15
|
+
* tabs) — not for links, which navigate and close.
|
|
16
|
+
*/
|
|
17
|
+
keepOpenFor: (target: RouteLocationRaw) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const uiNavDrawerKey: InjectionKey<UiNavDrawer> = Symbol('ui-nav-drawer')
|
|
21
|
+
|
|
22
|
+
const noop: UiNavDrawer = { keepOpenFor: () => {} }
|
|
23
|
+
|
|
24
|
+
export function useNavDrawer(): UiNavDrawer {
|
|
25
|
+
return inject(uiNavDrawerKey, noop)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pure core of the drawer's close-on-navigation rule. The shell consults the
|
|
30
|
+
* decider on EVERY route change (its watcher keys on fullPath) so the arm is
|
|
31
|
+
* consumed the first time the route moves, whatever moves it. The drawer then
|
|
32
|
+
* closes only when the route path changed and the change was not the exact
|
|
33
|
+
* navigation an in-drawer pane switch armed.
|
|
34
|
+
*/
|
|
35
|
+
export function navDrawerCloseDecider() {
|
|
36
|
+
let armedFullPath: string | null = null
|
|
37
|
+
return {
|
|
38
|
+
arm(fullPath: string) {
|
|
39
|
+
armedFullPath = fullPath
|
|
40
|
+
},
|
|
41
|
+
/** Returns whether the drawer should close for this route change. */
|
|
42
|
+
shouldCloseOnRouteChange(fullPath: string): boolean {
|
|
43
|
+
const keep = armedFullPath === fullPath
|
|
44
|
+
armedFullPath = null
|
|
45
|
+
return !keep
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
3
|
+
import type { RelatedFixRow } from './UiRelatedFixes.vue'
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
import { RailPanel, UiButton, UiChip, UiIcon, UiRelatedFixes, UiSyncDot } from '#components'
|
|
6
|
+
|
|
7
|
+
export interface ActionRailSuggestion {
|
|
8
|
+
id: string
|
|
9
|
+
action: string
|
|
10
|
+
label: string
|
|
11
|
+
sourceLabel?: string
|
|
12
|
+
icon?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
rows,
|
|
17
|
+
suggestions,
|
|
18
|
+
to,
|
|
19
|
+
loaded = false,
|
|
20
|
+
failed = false,
|
|
21
|
+
title = 'Do next',
|
|
22
|
+
contextLabel,
|
|
23
|
+
icon = 'sparkle',
|
|
24
|
+
moreCount = 0,
|
|
25
|
+
emptyLabel,
|
|
26
|
+
loadingLabel = 'Checking next actions...',
|
|
27
|
+
errorLabel = 'Could not load next actions.',
|
|
28
|
+
hideWhenEmpty = true,
|
|
29
|
+
} = defineProps<{
|
|
30
|
+
rows: RelatedFixRow[]
|
|
31
|
+
suggestions: ActionRailSuggestion[]
|
|
32
|
+
to: RouteLocationRaw
|
|
33
|
+
loaded?: boolean
|
|
34
|
+
failed?: boolean
|
|
35
|
+
title?: string
|
|
36
|
+
contextLabel?: string
|
|
37
|
+
icon?: string
|
|
38
|
+
moreCount?: number
|
|
39
|
+
emptyLabel?: string
|
|
40
|
+
loadingLabel?: string
|
|
41
|
+
errorLabel?: string
|
|
42
|
+
hideWhenEmpty?: boolean
|
|
43
|
+
}>()
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{
|
|
46
|
+
select: [row: RelatedFixRow]
|
|
47
|
+
retry: []
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
const status = computed(() => failed ? 'error' : loaded ? 'success' : 'pending')
|
|
51
|
+
const empty = computed(() => !rows.length && !suggestions.length)
|
|
52
|
+
const showViewAll = computed(() => rows.length > 0 || moreCount > 0)
|
|
53
|
+
const visibleSuggestionCount = computed(() => Math.min(suggestions.length, 4))
|
|
54
|
+
const visibleCount = computed(() => rows.length + visibleSuggestionCount.value)
|
|
55
|
+
const totalCount = computed(() => rows.length + suggestions.length + moreCount)
|
|
56
|
+
const countLabel = computed(() => {
|
|
57
|
+
if (!totalCount.value)
|
|
58
|
+
return null
|
|
59
|
+
return totalCount.value > visibleCount.value ? `${visibleCount.value}+` : visibleCount.value
|
|
60
|
+
})
|
|
61
|
+
const showGroupLabels = computed(() => rows.length > 0 && suggestions.length > 0)
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<RailPanel
|
|
66
|
+
:title="title"
|
|
67
|
+
:count="countLabel"
|
|
68
|
+
:icon="icon"
|
|
69
|
+
:status="status"
|
|
70
|
+
:empty="empty"
|
|
71
|
+
:empty-label="emptyLabel"
|
|
72
|
+
:loading-label="loadingLabel"
|
|
73
|
+
:error-label="errorLabel"
|
|
74
|
+
:hide-when-empty="hideWhenEmpty"
|
|
75
|
+
>
|
|
76
|
+
<template #meta>
|
|
77
|
+
<UiChip
|
|
78
|
+
v-if="contextLabel"
|
|
79
|
+
purpose="tag"
|
|
80
|
+
size="xs"
|
|
81
|
+
icon="target"
|
|
82
|
+
>
|
|
83
|
+
{{ contextLabel }}
|
|
84
|
+
</UiChip>
|
|
85
|
+
<UiButton
|
|
86
|
+
v-if="showViewAll"
|
|
87
|
+
label="View all"
|
|
88
|
+
size="xs"
|
|
89
|
+
purpose="quiet"
|
|
90
|
+
trailing-icon="next"
|
|
91
|
+
:to="to"
|
|
92
|
+
/>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<template #error>
|
|
96
|
+
<div class="px-4 py-6 text-sm text-muted">
|
|
97
|
+
<p>{{ errorLabel }}</p>
|
|
98
|
+
<UiButton label="Retry" icon="refresh" size="xs" purpose="quiet" class="mt-2" @click="emit('retry')" />
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
101
|
+
|
|
102
|
+
<div class="space-y-2 p-2">
|
|
103
|
+
<div v-if="rows.length" class="space-y-1">
|
|
104
|
+
<div v-if="showGroupLabels" class="flex items-center gap-2 px-1 pt-0.5">
|
|
105
|
+
<UiChip purpose="count" size="xs" tabular>
|
|
106
|
+
{{ rows.length }}
|
|
107
|
+
</UiChip>
|
|
108
|
+
<span class="text-mini font-medium uppercase text-muted">Queued work</span>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<UiRelatedFixes
|
|
112
|
+
:rows="rows"
|
|
113
|
+
:to="to"
|
|
114
|
+
loaded
|
|
115
|
+
:more-count="moreCount"
|
|
116
|
+
:show-header="false"
|
|
117
|
+
@select="emit('select', $event)"
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div v-if="suggestions.length" class="space-y-1">
|
|
122
|
+
<div v-if="showGroupLabels" class="flex items-center justify-between gap-2 px-1 pt-1">
|
|
123
|
+
<UiSyncDot status="syncing" label="Fresh signals" size="1.5" />
|
|
124
|
+
<UiChip purpose="count" size="xs" tabular>
|
|
125
|
+
{{ suggestions.length }}
|
|
126
|
+
</UiChip>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<ul class="divide-y divide-default overflow-hidden rounded-lg border border-default bg-default/60">
|
|
130
|
+
<li
|
|
131
|
+
v-for="suggestion in suggestions.slice(0, 4)"
|
|
132
|
+
:key="suggestion.id"
|
|
133
|
+
class="flex items-start gap-2.5 px-3 py-2.5"
|
|
134
|
+
>
|
|
135
|
+
<span class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md border border-default bg-elevated text-dimmed">
|
|
136
|
+
<UiIcon :name="suggestion.icon ?? 'sparkle'" class="size-3.5" />
|
|
137
|
+
</span>
|
|
138
|
+
<span class="min-w-0 flex-1 space-y-1">
|
|
139
|
+
<span class="block text-sm text-default">
|
|
140
|
+
<span class="font-medium text-highlighted">{{ suggestion.action }}</span>
|
|
141
|
+
<span class="text-muted"> · {{ suggestion.label }}</span>
|
|
142
|
+
</span>
|
|
143
|
+
<UiChip purpose="tag" size="xs" icon="activity">
|
|
144
|
+
{{ suggestion.sourceLabel ?? 'Recent activity' }}
|
|
145
|
+
</UiChip>
|
|
146
|
+
</span>
|
|
147
|
+
</li>
|
|
148
|
+
</ul>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</RailPanel>
|
|
152
|
+
</template>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends string = string">
|
|
2
|
+
import type { UiTabItem, UiTabLink } from '../element/UiTabs.vue'
|
|
3
|
+
import UiTabs from '../element/UiTabs.vue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A visible, counted set of related facets. `UiTabs` keeps ownership of each
|
|
7
|
+
* segmented run's selection, keyboard, focus, and horizontal overflow behavior;
|
|
8
|
+
* this primitive owns the repeated group-label layout and count presentation.
|
|
9
|
+
*/
|
|
10
|
+
export interface FacetStripItem<T extends string = string> extends UiTabItem<T> {
|
|
11
|
+
count: string | number
|
|
12
|
+
magnitude?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FacetStripGroup<T extends string = string> {
|
|
16
|
+
label: string
|
|
17
|
+
items: FacetStripItem<T>[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { groups, ariaLabel = 'Facets', wrapOnMobile = false } = defineProps<{
|
|
21
|
+
groups: FacetStripGroup<T>[]
|
|
22
|
+
ariaLabel?: string
|
|
23
|
+
wrapOnMobile?: boolean
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const model = defineModel<T>({ required: true })
|
|
27
|
+
|
|
28
|
+
function itemCount(link: UiTabLink | UiTabItem<T>): string | number | undefined {
|
|
29
|
+
return 'value' in link ? (link as FacetStripItem<T>).count : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function itemMagnitude(link: UiTabLink | UiTabItem<T>): number | undefined {
|
|
33
|
+
if (!('value' in link))
|
|
34
|
+
return undefined
|
|
35
|
+
const magnitude = (link as FacetStripItem<T>).magnitude
|
|
36
|
+
return typeof magnitude === 'number' && Number.isFinite(magnitude)
|
|
37
|
+
? Math.min(100, Math.max(0, magnitude))
|
|
38
|
+
: undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function magnitudeStyle(link: UiTabLink | UiTabItem<T>): Record<string, string> | undefined {
|
|
42
|
+
const magnitude = itemMagnitude(link)
|
|
43
|
+
if (magnitude == null)
|
|
44
|
+
return undefined
|
|
45
|
+
return { '--facet-magnitude': `${magnitude}%` }
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div
|
|
51
|
+
data-ui="FacetStrip"
|
|
52
|
+
role="group"
|
|
53
|
+
:aria-label="ariaLabel"
|
|
54
|
+
class="grid gap-2"
|
|
55
|
+
>
|
|
56
|
+
<div
|
|
57
|
+
v-for="group in groups"
|
|
58
|
+
:key="group.label"
|
|
59
|
+
class="grid min-w-0 gap-1 sm:grid-cols-[4.5rem_minmax(0,1fr)] sm:items-center sm:gap-2"
|
|
60
|
+
>
|
|
61
|
+
<span class="text-sm font-strong text-muted">{{ group.label }}</span>
|
|
62
|
+
<div class="min-w-0">
|
|
63
|
+
<UiTabs
|
|
64
|
+
v-model="model"
|
|
65
|
+
class="w-full!"
|
|
66
|
+
:class="wrapOnMobile ? 'max-sm:flex-wrap! max-sm:overflow-visible!' : ''"
|
|
67
|
+
kind="subnav"
|
|
68
|
+
:items="group.items"
|
|
69
|
+
:aria-label="`${group.label}, ${ariaLabel}`"
|
|
70
|
+
>
|
|
71
|
+
<template #trailing="{ link }">
|
|
72
|
+
<span class="relative isolate overflow-hidden rounded-sm px-1 numerals-display tabular-nums">
|
|
73
|
+
<span
|
|
74
|
+
v-if="(itemMagnitude(link) ?? 0) > 0"
|
|
75
|
+
class="facet-strip__magnitude absolute inset-y-0 left-0 -z-10 rounded-sm bg-accented"
|
|
76
|
+
:data-facet-magnitude="itemMagnitude(link)"
|
|
77
|
+
:style="magnitudeStyle(link)"
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
/>
|
|
80
|
+
<span>{{ itemCount(link) }}</span>
|
|
81
|
+
</span>
|
|
82
|
+
</template>
|
|
83
|
+
</UiTabs>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<style scoped>
|
|
90
|
+
.facet-strip__magnitude {
|
|
91
|
+
width: var(--facet-magnitude);
|
|
92
|
+
}
|
|
93
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
/**
|
|
4
|
+
* Bar-filled metric cell: a relative container with an absolute-positioned bar
|
|
5
|
+
* fill behind slotted content.
|
|
6
|
+
*
|
|
7
|
+
* Keep `barClass` opacity at /10 or above — a /5 fill is invisible against the
|
|
8
|
+
* dashboard's near-black surface, and a bar you can't see isn't a bar.
|
|
9
|
+
*/
|
|
10
|
+
const { percent, barClass = 'bg-muted/40' } = defineProps<{
|
|
11
|
+
/** Bar fill width as 0-100 */
|
|
12
|
+
percent: number
|
|
13
|
+
/** Tailwind bg class for the bar fill (e.g. from barColorMap or cwvBarColorMap) */
|
|
14
|
+
barClass?: string
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const clampedPercent = computed(() => Math.min(100, Math.max(0, Number.isFinite(percent) ? percent : 0)))
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="relative flex items-center gap-3 px-2 py-1">
|
|
22
|
+
<!-- The fill is presentation only. It stays static across filter changes so
|
|
23
|
+
magnitude does not appear to morph between unrelated populations. -->
|
|
24
|
+
<div
|
|
25
|
+
v-if="clampedPercent > 0"
|
|
26
|
+
class="absolute inset-y-0 left-0 w-full overflow-hidden rounded-md"
|
|
27
|
+
>
|
|
28
|
+
<div
|
|
29
|
+
data-bar-fill
|
|
30
|
+
class="h-full w-full origin-left"
|
|
31
|
+
:class="barClass"
|
|
32
|
+
:style="{ transform: `scaleX(${clampedPercent / 100})` }"
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
<slot />
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|