@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,71 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { UiDitherAvatar, UiIcon, UiNavIcon } from '#components'
|
|
3
|
+
// The "What's inside" benefit list shown beside the auth panel and in the
|
|
4
|
+
// onboarding wizard. Single source of truth (design-system, so both the live
|
|
5
|
+
// pro-auth layout and the brand-kit showcase render it). Callers own the
|
|
6
|
+
// surrounding panel (wrapper + background); this renders only the content.
|
|
7
|
+
// `trialDays` is a prop so this carries no saas dependency. `footnote: false`
|
|
8
|
+
// drops the trial promise for surfaces where no trial is on offer — the
|
|
9
|
+
// waitlist screen, where "14-day free trial" beside "sign-ups are paused"
|
|
10
|
+
// promises something the product will refuse.
|
|
11
|
+
const { trialDays = 14, footnote = true } = defineProps<{ trialDays?: number, footnote?: boolean }>()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="flex flex-col text-left">
|
|
16
|
+
<div class="mb-7 flex items-center gap-3">
|
|
17
|
+
<UiDitherAvatar seed="nuxter-onboarding" :size="40" />
|
|
18
|
+
<div class="min-w-0">
|
|
19
|
+
<div class="text-label">
|
|
20
|
+
Ask more of your Site
|
|
21
|
+
</div>
|
|
22
|
+
<p class="mt-1 text-description">
|
|
23
|
+
Connect the data. Pro finds the work.
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<ul class="space-y-5">
|
|
29
|
+
<li class="flex items-start gap-2.5">
|
|
30
|
+
<UiNavIcon icon="i-simple-icons-googlesearchconsole" class="mt-1 shrink-0" />
|
|
31
|
+
<div class="min-w-0">
|
|
32
|
+
<div class="text-highlighted text-sm font-medium mb-0.5">
|
|
33
|
+
Organic Search, live
|
|
34
|
+
</div>
|
|
35
|
+
<p class="text-description">
|
|
36
|
+
Clicks, queries, pages and countries as soon as you connect Google.
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
</li>
|
|
40
|
+
|
|
41
|
+
<li class="flex items-start gap-2.5">
|
|
42
|
+
<UiNavIcon icon="arrow-right" class="mt-1 shrink-0" />
|
|
43
|
+
<div class="min-w-0">
|
|
44
|
+
<div class="text-highlighted text-sm font-medium mb-0.5">
|
|
45
|
+
Next Actions, ranked
|
|
46
|
+
</div>
|
|
47
|
+
<p class="text-description">
|
|
48
|
+
Issues and opportunities ordered by what matters first.
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
</li>
|
|
52
|
+
|
|
53
|
+
<li class="flex items-start gap-2.5">
|
|
54
|
+
<UiNavIcon icon="send" class="mt-1 shrink-0" />
|
|
55
|
+
<div class="min-w-0">
|
|
56
|
+
<div class="text-highlighted text-sm font-medium mb-0.5">
|
|
57
|
+
Weekly Report, ready
|
|
58
|
+
</div>
|
|
59
|
+
<p class="text-description">
|
|
60
|
+
What changed, what was verified, and what needs work next. Every Monday.
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
</li>
|
|
64
|
+
</ul>
|
|
65
|
+
|
|
66
|
+
<div v-if="footnote" class="mt-7 flex items-center gap-2 text-mini text-muted">
|
|
67
|
+
<UiIcon name="shield-check" class="size-3.5 text-dimmed shrink-0" />
|
|
68
|
+
<span>{{ trialDays }}-day free trial. Cancel anytime.</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { NuxtLink, UiFavicon, UiIcon } from '#components'
|
|
4
|
+
import { prettifyUrl, safeHttpHref, urlHostname } from '../../../shared/utils/urls'
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
url,
|
|
8
|
+
favicon = true,
|
|
9
|
+
faviconSize = 16,
|
|
10
|
+
external = false,
|
|
11
|
+
showPath = true,
|
|
12
|
+
label,
|
|
13
|
+
to,
|
|
14
|
+
} = defineProps<{
|
|
15
|
+
/** A full URL, bare hostname, or `sc-domain:` value. */
|
|
16
|
+
url: string
|
|
17
|
+
/** Render the favicon for the host. */
|
|
18
|
+
favicon?: boolean
|
|
19
|
+
faviconSize?: number
|
|
20
|
+
/** Render as an external link that opens the URL in a new tab. */
|
|
21
|
+
external?: boolean
|
|
22
|
+
/** Keep the path in the label; when false only the hostname is shown. */
|
|
23
|
+
showPath?: boolean
|
|
24
|
+
/** Override the auto-derived label text (e.g. an already-compacted path)
|
|
25
|
+
* while the favicon/href still resolve from `url`. */
|
|
26
|
+
label?: string
|
|
27
|
+
/** Internal deep link — renders the label as a `NuxtLink` (`:to`, never
|
|
28
|
+
* `@click`). Ignored when `external` is set (that already opens `url` in
|
|
29
|
+
* a new tab via a real anchor). */
|
|
30
|
+
to?: string
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
const host = computed(() => urlHostname(url))
|
|
34
|
+
const displayLabel = computed(() => label ?? (showPath ? prettifyUrl(url) : host.value))
|
|
35
|
+
// A URL with a scheme links only when the scheme is http(s), so
|
|
36
|
+
// `javascript://%0A…` renders as plain text. A bare host or `sc-domain:`
|
|
37
|
+
// value links to its https origin.
|
|
38
|
+
const href = computed(() => {
|
|
39
|
+
if (!external)
|
|
40
|
+
return undefined
|
|
41
|
+
const trimmed = url.trim()
|
|
42
|
+
if (/^[a-z][\w+.-]*:\/\//i.test(trimmed))
|
|
43
|
+
return safeHttpHref(trimmed)
|
|
44
|
+
return host.value ? safeHttpHref(`https://${host.value}`) : undefined
|
|
45
|
+
})
|
|
46
|
+
const isExternalLink = computed(() => !!href.value)
|
|
47
|
+
const isLink = computed(() => isExternalLink.value || (!external && !!to))
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<component
|
|
52
|
+
:is="isExternalLink ? 'a' : (!external && to ? NuxtLink : 'span')"
|
|
53
|
+
:href="href"
|
|
54
|
+
:to="!external && to ? to : undefined"
|
|
55
|
+
:target="isExternalLink ? '_blank' : undefined"
|
|
56
|
+
:rel="isExternalLink ? 'noopener' : undefined"
|
|
57
|
+
class="inline-flex min-w-0 max-w-full items-center gap-1.5"
|
|
58
|
+
:class="isLink ? 'group/url transition-colors hover:text-primary' : ''"
|
|
59
|
+
>
|
|
60
|
+
<UiFavicon
|
|
61
|
+
v-if="favicon && host"
|
|
62
|
+
:domain="host"
|
|
63
|
+
:size="faviconSize"
|
|
64
|
+
:alt="`${host} favicon`"
|
|
65
|
+
/>
|
|
66
|
+
<span translate="no" class="truncate text-sm">{{ displayLabel }}</span>
|
|
67
|
+
<span v-if="isExternalLink" class="sr-only">(opens in new tab)</span>
|
|
68
|
+
<UiIcon
|
|
69
|
+
v-if="isExternalLink"
|
|
70
|
+
name="external"
|
|
71
|
+
class="size-3 shrink-0 text-dimmed opacity-0 transition-opacity group-hover/url:opacity-100"
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
/>
|
|
74
|
+
</component>
|
|
75
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SemanticStatus } from '../../composables/semanticColors'
|
|
3
|
+
import { semanticColors } from '../../composables/semanticColors'
|
|
4
|
+
|
|
5
|
+
// Valence is the change-direction axis — the sibling of severity (UiSeverityDot)
|
|
6
|
+
// and health (UiHealthDot). A leading dot is the canonical treatment for
|
|
7
|
+
// direction in a repeated list (DESIGN.md colour budget); a coloured left-border
|
|
8
|
+
// stripe is the banned alternative. A flat / no-change reading is a non-event, so
|
|
9
|
+
// it stays neutral rather than spending colour.
|
|
10
|
+
const { direction = 'flat', size = 'sm', label } = defineProps<{
|
|
11
|
+
direction?: 'up' | 'down' | 'flat'
|
|
12
|
+
size?: 'xs' | 'sm' | 'md'
|
|
13
|
+
/**
|
|
14
|
+
* Accessible name for the dot. Pass when it stands alone (no adjacent text), so
|
|
15
|
+
* colour is not the only channel conveying valence. Omit when a visible label
|
|
16
|
+
* sits beside it — the dot stays decorative then.
|
|
17
|
+
*/
|
|
18
|
+
label?: string
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const DIRECTION_STATUS: Record<'up' | 'down' | 'flat', SemanticStatus> = {
|
|
22
|
+
up: 'success',
|
|
23
|
+
down: 'error',
|
|
24
|
+
flat: 'neutral',
|
|
25
|
+
}
|
|
26
|
+
const sizeClass = { xs: 'size-1', sm: 'size-1.5', md: 'size-2' } as const
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<span
|
|
31
|
+
class="rounded-full shrink-0"
|
|
32
|
+
:class="[sizeClass[size], semanticColors[DIRECTION_STATUS[direction]].dot]"
|
|
33
|
+
:role="label ? 'img' : undefined"
|
|
34
|
+
:aria-label="label || undefined"
|
|
35
|
+
:aria-hidden="label ? undefined : 'true'"
|
|
36
|
+
/>
|
|
37
|
+
</template>
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface UiWizardStep {
|
|
3
|
+
id: string
|
|
4
|
+
label: string
|
|
5
|
+
/** Primary button label. Defaults to "Continue", or "Done" on the last step. */
|
|
6
|
+
nextLabel?: string
|
|
7
|
+
nextIcon?: string
|
|
8
|
+
/** False disables Next. Defaults to true. */
|
|
9
|
+
valid?: boolean
|
|
10
|
+
/** False hides Next, for a step whose own action advances it. */
|
|
11
|
+
showNext?: boolean
|
|
12
|
+
/** False keeps a step link out of this step, for a step that needs an
|
|
13
|
+
* earlier save the host has not made yet. Defaults to true. */
|
|
14
|
+
reachable?: boolean
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { useMediaQuery } from '@vueuse/core'
|
|
20
|
+
import { computed, nextTick, onMounted, onScopeDispose, ref, useAttrs, useTemplateRef, watch } from 'vue'
|
|
21
|
+
import { UiWizardNav, UiWizardProgress, UModal } from '#components'
|
|
22
|
+
|
|
23
|
+
// A first-use setup with a form or more than one decision runs here (DESIGN.md,
|
|
24
|
+
// First-use setup flows). The empty state keeps one CTA that opens it.
|
|
25
|
+
//
|
|
26
|
+
// The host owns the step list, so a step list can branch on an answer: the
|
|
27
|
+
// active step is matched by id, and an id the list no longer holds falls back
|
|
28
|
+
// to the first step. Closing keeps the step and the step state, so a reopen
|
|
29
|
+
// resumes where the user left. Completing resets the step to the first step,
|
|
30
|
+
// so a reopen starts there. `useWizardRoute` backs `open` and `step` with the query,
|
|
31
|
+
// for deep links and OAuth returns.
|
|
32
|
+
//
|
|
33
|
+
// A step link cannot skip a step. The modal shows the linked step only if
|
|
34
|
+
// every step before it is valid, and the step itself is reachable. Otherwise it
|
|
35
|
+
// shows the first invalid step, or the step before the unreachable one. The
|
|
36
|
+
// link resolves once per open, when `ready` reads true, and the resolved step
|
|
37
|
+
// is written to the model on the client. A host whose step state loads async passes `ready`.
|
|
38
|
+
//
|
|
39
|
+
// `beforeNext` guards Next. While it is pending, Next shows loading and every
|
|
40
|
+
// further press is ignored. If it resolves false, the step stays and the host
|
|
41
|
+
// shows its error in the step slot. If it throws, the step stays and the error
|
|
42
|
+
// propagates to Vue's error handler: an unexpected failure must reach error
|
|
43
|
+
// reporting. Catch the expected failures and resolve false instead.
|
|
44
|
+
//
|
|
45
|
+
// Next is disabled for `settleMs` after the modal opens or the step changes.
|
|
46
|
+
// Next keeps its place, so without the pause a double click on Continue would
|
|
47
|
+
// also press the next step's final action. A test passes `settleMs: 0`, or
|
|
48
|
+
// fakes timers.
|
|
49
|
+
//
|
|
50
|
+
// `complete` fires after the `open` model reads false. A listener can then
|
|
51
|
+
// navigate: the close has already landed, so it cannot cancel the close.
|
|
52
|
+
defineOptions({ inheritAttrs: false })
|
|
53
|
+
|
|
54
|
+
const { title, description, steps, beforeNext, settleMs = 1000, ready = true } = defineProps<{
|
|
55
|
+
title: string
|
|
56
|
+
description?: string
|
|
57
|
+
steps: readonly UiWizardStep[]
|
|
58
|
+
beforeNext?: (id: string) => boolean | void | Promise<boolean | void>
|
|
59
|
+
/** Next is disabled for this many milliseconds after the modal opens or
|
|
60
|
+
* the step changes. 0 turns the pause off. Defaults to 1000. */
|
|
61
|
+
settleMs?: number
|
|
62
|
+
/** False while the host loads the state that decides which step a link may
|
|
63
|
+
* reach. The link resolves once this reads true. Defaults to true. */
|
|
64
|
+
ready?: boolean
|
|
65
|
+
}>()
|
|
66
|
+
|
|
67
|
+
const emit = defineEmits<{ complete: [] }>()
|
|
68
|
+
|
|
69
|
+
defineSlots<
|
|
70
|
+
Record<string, (props: { next: () => Promise<void>, back: () => void, settling: boolean }) => unknown>
|
|
71
|
+
& { footer?: (props: { next: () => Promise<void>, back: () => void, pending: boolean, settling: boolean }) => unknown }
|
|
72
|
+
>()
|
|
73
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
74
|
+
const step = defineModel<string>('step')
|
|
75
|
+
|
|
76
|
+
const attrs = useAttrs()
|
|
77
|
+
const fullscreen = useMediaQuery('(max-width: 639.98px)')
|
|
78
|
+
|
|
79
|
+
const linkedIndex = computed(() => Math.max(0, steps.findIndex(s => s.id === step.value)))
|
|
80
|
+
// The linked step, clamped to the furthest step the user can reach.
|
|
81
|
+
const reachableIndex = computed(() => {
|
|
82
|
+
for (let i = 1; i <= linkedIndex.value; i++) {
|
|
83
|
+
if (steps[i - 1]!.valid === false || steps[i]!.reachable === false)
|
|
84
|
+
return i - 1
|
|
85
|
+
}
|
|
86
|
+
return linkedIndex.value
|
|
87
|
+
})
|
|
88
|
+
// The clamp applies until the link resolves, once per open, when the host is
|
|
89
|
+
// ready. After that only Next, Back, and the rail move the step, so a step that
|
|
90
|
+
// turns valid while the user types never moves them. The resolved step renders
|
|
91
|
+
// from this state. The model, and so the query, takes it on the client after
|
|
92
|
+
// mount: a server-side write would answer the request with a redirect.
|
|
93
|
+
const resolved = ref<undefined | { from: string | undefined, to: string | undefined }>()
|
|
94
|
+
const activeIndex = computed(() => {
|
|
95
|
+
if (!resolved.value)
|
|
96
|
+
return reachableIndex.value
|
|
97
|
+
if (step.value === resolved.value.from)
|
|
98
|
+
return Math.max(0, steps.findIndex(s => s.id === resolved.value!.to))
|
|
99
|
+
return linkedIndex.value
|
|
100
|
+
})
|
|
101
|
+
const activeStep = computed(() => steps[activeIndex.value])
|
|
102
|
+
const isLast = computed(() => activeIndex.value === steps.length - 1)
|
|
103
|
+
const pending = ref(false)
|
|
104
|
+
// Counts opens, so a guard answer can tell whether its modal session ended.
|
|
105
|
+
let session = 0
|
|
106
|
+
watch(open, (value) => {
|
|
107
|
+
if (value)
|
|
108
|
+
session++
|
|
109
|
+
else
|
|
110
|
+
resolved.value = undefined
|
|
111
|
+
}, { immediate: true, flush: 'sync' })
|
|
112
|
+
|
|
113
|
+
let isMounted = false
|
|
114
|
+
function writeResolved(): void {
|
|
115
|
+
const link = resolved.value
|
|
116
|
+
if (!isMounted || link?.to === undefined || link.from === link.to || step.value !== link.from)
|
|
117
|
+
return
|
|
118
|
+
// Once the model holds the resolved step, the link is spent: a later move
|
|
119
|
+
// back to the linked id is a real step change, not the link again.
|
|
120
|
+
resolved.value = { from: link.to, to: link.to }
|
|
121
|
+
step.value = link.to
|
|
122
|
+
}
|
|
123
|
+
watch([open, () => ready], ([isOpen, isReady]) => {
|
|
124
|
+
if (!isOpen || !isReady || resolved.value)
|
|
125
|
+
return
|
|
126
|
+
const linked = steps[linkedIndex.value]?.id
|
|
127
|
+
const to = steps[reachableIndex.value]?.id
|
|
128
|
+
if (to === undefined)
|
|
129
|
+
return
|
|
130
|
+
// A link that names no step, or an unknown one, already shows the first
|
|
131
|
+
// step: it resolves without a write.
|
|
132
|
+
resolved.value = { from: step.value, to: linked === to ? step.value : to }
|
|
133
|
+
writeResolved()
|
|
134
|
+
}, { immediate: true })
|
|
135
|
+
onMounted(() => {
|
|
136
|
+
isMounted = true
|
|
137
|
+
writeResolved()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// True for `settleMs` after the modal opens or the step changes. Next is
|
|
141
|
+
// disabled meanwhile; Back is not.
|
|
142
|
+
const settling = ref(false)
|
|
143
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
|
144
|
+
watch([open, () => activeStep.value?.id], ([value]) => {
|
|
145
|
+
clearTimeout(settleTimer)
|
|
146
|
+
settling.value = Boolean(value) && settleMs > 0
|
|
147
|
+
if (settling.value)
|
|
148
|
+
settleTimer = setTimeout(() => { settling.value = false }, settleMs)
|
|
149
|
+
}, { immediate: true, flush: 'sync' })
|
|
150
|
+
onScopeDispose(() => clearTimeout(settleTimer))
|
|
151
|
+
|
|
152
|
+
const ui = computed(() => {
|
|
153
|
+
const own = (attrs.ui ?? {}) as Record<string, string | undefined>
|
|
154
|
+
return {
|
|
155
|
+
...own,
|
|
156
|
+
content: ['sm:max-w-xl', own.content].filter(Boolean).join(' '),
|
|
157
|
+
header: ['flex-wrap items-start gap-y-3', own.header].filter(Boolean).join(' '),
|
|
158
|
+
wrapper: ['min-w-0 flex-1 pe-8', own.wrapper].filter(Boolean).join(' '),
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
const passthrough = computed(() => {
|
|
162
|
+
const { ui: _ui, ...rest } = attrs
|
|
163
|
+
return rest
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
async function next(): Promise<void> {
|
|
167
|
+
const current = activeStep.value
|
|
168
|
+
if (pending.value || settling.value || !current)
|
|
169
|
+
return
|
|
170
|
+
if (beforeNext) {
|
|
171
|
+
const asked = session
|
|
172
|
+
pending.value = true
|
|
173
|
+
// No catch: a throw keeps the step and reaches Vue's error handler.
|
|
174
|
+
const passed = await Promise.resolve().then(() => beforeNext(current.id)).finally(() => {
|
|
175
|
+
pending.value = false
|
|
176
|
+
})
|
|
177
|
+
// The modal may have closed, reopened, or changed step while the guard
|
|
178
|
+
// ran. Its answer belongs to the session and step it was asked about.
|
|
179
|
+
if (passed === false || !open.value || session !== asked || activeStep.value?.id !== current.id)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (isLast.value) {
|
|
183
|
+
await complete()
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
step.value = steps[activeIndex.value + 1]!.id
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Closes, waits until the close lands, resets the step, then emits. Next stays
|
|
190
|
+
// pending meanwhile, so a second press cannot complete twice. If the close
|
|
191
|
+
// does not land in CLOSE_TIMEOUT_MS, a navigation guard or a failed Back kept
|
|
192
|
+
// the flow open: Next stops loading and the error reaches Vue's error handler.
|
|
193
|
+
const CLOSE_TIMEOUT_MS = 5000
|
|
194
|
+
async function complete(): Promise<void> {
|
|
195
|
+
pending.value = true
|
|
196
|
+
open.value = false
|
|
197
|
+
const closed = !open.value || await new Promise<boolean>((resolve) => {
|
|
198
|
+
const timer = setTimeout(() => {
|
|
199
|
+
stop()
|
|
200
|
+
resolve(false)
|
|
201
|
+
}, CLOSE_TIMEOUT_MS)
|
|
202
|
+
const stop = watch(open, (value) => {
|
|
203
|
+
if (value)
|
|
204
|
+
return
|
|
205
|
+
clearTimeout(timer)
|
|
206
|
+
stop()
|
|
207
|
+
resolve(true)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
pending.value = false
|
|
211
|
+
if (!closed)
|
|
212
|
+
throw new Error('The setup flow did not close, so it did not complete.')
|
|
213
|
+
step.value = steps[0]?.id
|
|
214
|
+
emit('complete')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function back(): void {
|
|
218
|
+
if (pending.value || activeIndex.value === 0)
|
|
219
|
+
return
|
|
220
|
+
step.value = steps[activeIndex.value - 1]!.id
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function select(id: string): void {
|
|
224
|
+
if (!pending.value)
|
|
225
|
+
step.value = id
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Move focus to the new step's heading, so a screen reader announces it and
|
|
229
|
+
// Tab continues inside the new step. The heading is visually hidden: the rail
|
|
230
|
+
// already shows the step label.
|
|
231
|
+
const heading = useTemplateRef<HTMLElement>('heading')
|
|
232
|
+
watch(() => activeStep.value?.id, async () => {
|
|
233
|
+
if (!open.value)
|
|
234
|
+
return
|
|
235
|
+
await nextTick()
|
|
236
|
+
heading.value?.focus()
|
|
237
|
+
})
|
|
238
|
+
</script>
|
|
239
|
+
|
|
240
|
+
<template>
|
|
241
|
+
<UModal
|
|
242
|
+
v-model:open="open"
|
|
243
|
+
:title="title"
|
|
244
|
+
:description="description"
|
|
245
|
+
:fullscreen="fullscreen"
|
|
246
|
+
:ui="ui"
|
|
247
|
+
v-bind="passthrough"
|
|
248
|
+
>
|
|
249
|
+
<template v-if="steps.length > 1" #actions>
|
|
250
|
+
<div class="basis-full">
|
|
251
|
+
<UiWizardProgress :steps="steps" :current-index="activeIndex" label="Steps" @select="select" />
|
|
252
|
+
</div>
|
|
253
|
+
</template>
|
|
254
|
+
|
|
255
|
+
<template #body>
|
|
256
|
+
<h3
|
|
257
|
+
v-if="steps.length > 1 && activeStep"
|
|
258
|
+
ref="heading"
|
|
259
|
+
tabindex="-1"
|
|
260
|
+
class="sr-only"
|
|
261
|
+
>
|
|
262
|
+
{{ activeStep.label }}
|
|
263
|
+
</h3>
|
|
264
|
+
<slot v-if="activeStep" :name="activeStep.id" :next="next" :back="back" :settling="settling" />
|
|
265
|
+
</template>
|
|
266
|
+
|
|
267
|
+
<template #footer>
|
|
268
|
+
<slot name="footer" :next="next" :back="back" :pending="pending" :settling="settling">
|
|
269
|
+
<UiWizardNav
|
|
270
|
+
class="w-full"
|
|
271
|
+
:divider="false"
|
|
272
|
+
:can-back="activeIndex > 0"
|
|
273
|
+
:next-label="activeStep?.nextLabel ?? (isLast ? 'Done' : 'Continue')"
|
|
274
|
+
:next-icon="activeStep?.nextIcon ?? (isLast ? 'check' : 'next')"
|
|
275
|
+
:show-next="activeStep?.showNext ?? true"
|
|
276
|
+
:next-disabled="activeStep?.valid === false || settling"
|
|
277
|
+
:next-loading="pending"
|
|
278
|
+
@back="back"
|
|
279
|
+
@next="next"
|
|
280
|
+
/>
|
|
281
|
+
</slot>
|
|
282
|
+
</template>
|
|
283
|
+
</UModal>
|
|
284
|
+
</template>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { UiButton } from '#components'
|
|
3
|
+
// Footer nav row for a wizard step: optional Back on the left, an optional Skip
|
|
4
|
+
// link + a primary advance button on the right. Captures the Back/Skip/Continue
|
|
5
|
+
// (and Finish) pattern shared across the onboarding steps.
|
|
6
|
+
const {
|
|
7
|
+
canBack = false,
|
|
8
|
+
skipLabel,
|
|
9
|
+
nextLabel = 'Continue',
|
|
10
|
+
nextIcon = 'next',
|
|
11
|
+
nextPurpose = 'cta',
|
|
12
|
+
showNext = true,
|
|
13
|
+
nextDisabled = false,
|
|
14
|
+
nextLoading = false,
|
|
15
|
+
divider = true,
|
|
16
|
+
} = defineProps<{
|
|
17
|
+
canBack?: boolean
|
|
18
|
+
/** When set, renders a Skip link emitting `skip`. */
|
|
19
|
+
skipLabel?: string
|
|
20
|
+
nextLabel?: string
|
|
21
|
+
nextIcon?: string
|
|
22
|
+
nextPurpose?: 'cta' | 'secondary'
|
|
23
|
+
/** Hide the primary advance button — e.g. a required step whose own CTA
|
|
24
|
+
* drives the advance (the plan/trial step). */
|
|
25
|
+
showNext?: boolean
|
|
26
|
+
nextDisabled?: boolean
|
|
27
|
+
nextLoading?: boolean
|
|
28
|
+
/** Draw the top rule. A host that already separates its footer, such as
|
|
29
|
+
* `UiWizardModal`, turns it off. */
|
|
30
|
+
divider?: boolean
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{ back: [], skip: [], next: [] }>()
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div class="flex items-center justify-between" :class="divider && 'pt-2 border-t border-default'">
|
|
38
|
+
<UiButton v-if="canBack" purpose="link" size="md" class="min-h-11" @click="emit('back')">
|
|
39
|
+
Back
|
|
40
|
+
</UiButton>
|
|
41
|
+
<span v-else />
|
|
42
|
+
<div class="flex items-center gap-3">
|
|
43
|
+
<UiButton v-if="skipLabel" purpose="link" size="md" class="min-h-11" @click="emit('skip')">
|
|
44
|
+
{{ skipLabel }}
|
|
45
|
+
</UiButton>
|
|
46
|
+
<UiButton
|
|
47
|
+
v-if="showNext"
|
|
48
|
+
:purpose="nextPurpose"
|
|
49
|
+
size="lg"
|
|
50
|
+
class="min-h-11"
|
|
51
|
+
:trailing-icon="nextIcon"
|
|
52
|
+
:label="nextLabel"
|
|
53
|
+
:disabled="nextDisabled"
|
|
54
|
+
:loading="nextLoading"
|
|
55
|
+
@click="emit('next')"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { UiIcon } from '#components'
|
|
3
|
+
// Numbered progress rail for the guided-onboarding wizard. Completed steps show
|
|
4
|
+
// a check, the current step is ringed, future steps recede.
|
|
5
|
+
//
|
|
6
|
+
// Future steps sit at `text-muted`, not `text-dimmed`: these are chrome labels,
|
|
7
|
+
// and dimmed measures 3.6–4.2:1 in dark on every surface (this layer's AGENTS.md,
|
|
8
|
+
// Tokens and contrast) — below AA at any size. The step you're on already carries the
|
|
9
|
+
// weight + ring, so it stays distinct without pushing the rest under AA.
|
|
10
|
+
//
|
|
11
|
+
// `complete` marks the current step done as well. The last step has no next
|
|
12
|
+
// step to move to, so without it the rail kept its number after the work ended.
|
|
13
|
+
const { label = 'Onboarding progress', complete = false } = defineProps<{
|
|
14
|
+
steps: readonly { id: string, label: string }[]
|
|
15
|
+
currentIndex: number
|
|
16
|
+
/** Accessible name of the progress nav. */
|
|
17
|
+
label?: string
|
|
18
|
+
/** The current step is done too, such as the last step once its work ends. */
|
|
19
|
+
complete?: boolean
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{ select: [id: string] }>()
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<nav :aria-label="label">
|
|
27
|
+
<p
|
|
28
|
+
class="sm:hidden text-sm font-medium text-highlighted"
|
|
29
|
+
aria-current="step"
|
|
30
|
+
>
|
|
31
|
+
Step {{ currentIndex + 1 }} of {{ steps.length }}:
|
|
32
|
+
{{ steps[currentIndex]?.label }}
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<ol class="mt-3 flex items-center gap-1.5 sm:mt-0 sm:gap-2">
|
|
36
|
+
<li
|
|
37
|
+
v-for="(s, i) in steps"
|
|
38
|
+
:key="s.id"
|
|
39
|
+
class="flex items-center gap-2"
|
|
40
|
+
:aria-current="i === currentIndex ? 'step' : undefined"
|
|
41
|
+
>
|
|
42
|
+
<button
|
|
43
|
+
v-if="i < currentIndex"
|
|
44
|
+
type="button"
|
|
45
|
+
class="group inline-flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-lg px-1 text-muted outline-none transition-colors hover:text-default focus-visible:ring-2 focus-visible:ring-primary sm:min-h-0"
|
|
46
|
+
:aria-label="`Return to ${s.label}`"
|
|
47
|
+
@click="emit('select', s.id)"
|
|
48
|
+
>
|
|
49
|
+
<span class="inline-flex size-5 items-center justify-center rounded-full bg-primary text-xs font-medium text-inverted transition-transform group-hover:scale-105">
|
|
50
|
+
<UiIcon name="check" class="size-3" aria-hidden="true" />
|
|
51
|
+
</span>
|
|
52
|
+
<span class="hidden text-sm sm:inline">{{ s.label }}</span>
|
|
53
|
+
</button>
|
|
54
|
+
<span v-else class="inline-flex min-h-11 items-center gap-2 px-1 sm:min-h-0">
|
|
55
|
+
<span
|
|
56
|
+
v-if="complete && i === currentIndex"
|
|
57
|
+
class="inline-flex size-5 items-center justify-center rounded-full bg-primary text-xs font-medium text-inverted"
|
|
58
|
+
>
|
|
59
|
+
<UiIcon name="check" class="size-3" aria-hidden="true" />
|
|
60
|
+
<span class="sr-only">Done:</span>
|
|
61
|
+
</span>
|
|
62
|
+
<span
|
|
63
|
+
v-else
|
|
64
|
+
class="inline-flex size-5 items-center justify-center rounded-full text-xs font-medium transition-colors"
|
|
65
|
+
:class="i === currentIndex ? 'bg-primary/15 text-primary ring-1 ring-primary/30' : 'bg-elevated text-muted'"
|
|
66
|
+
>
|
|
67
|
+
{{ i + 1 }}
|
|
68
|
+
</span>
|
|
69
|
+
<span
|
|
70
|
+
class="hidden text-sm sm:inline"
|
|
71
|
+
:class="i === currentIndex ? 'text-highlighted font-medium' : 'text-muted'"
|
|
72
|
+
>{{ s.label }}</span>
|
|
73
|
+
</span>
|
|
74
|
+
<span v-if="i < steps.length - 1" class="h-px w-3 bg-accented sm:w-4 lg:w-6" aria-hidden="true" />
|
|
75
|
+
</li>
|
|
76
|
+
</ol>
|
|
77
|
+
</nav>
|
|
78
|
+
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { UiFocusedSurface, UiLogoPro } from '#components'
|
|
3
|
+
// Guided-onboarding wizard chrome: the focused surface (UiFocusedSurface) plus
|
|
4
|
+
// the product mark and a centered max-w-2xl content column. Step content +
|
|
5
|
+
// progress + nav go in the default slot. Shared by the team-create onboarding
|
|
6
|
+
// (layers/saas) and the brand-kit showcase. `#logo` replaces the Pro logo for a
|
|
7
|
+
// site with its own mark.
|
|
8
|
+
//
|
|
9
|
+
// Height falls through to the surface (attrs): the live page passes
|
|
10
|
+
// `min-h-screen`; an embedded preview passes a fixed height.
|
|
11
|
+
const {
|
|
12
|
+
exitLabel = 'Dashboard',
|
|
13
|
+
showExit = true,
|
|
14
|
+
as = 'main',
|
|
15
|
+
wide = false,
|
|
16
|
+
} = defineProps<{
|
|
17
|
+
exitLabel?: string
|
|
18
|
+
showExit?: boolean
|
|
19
|
+
as?: string
|
|
20
|
+
wide?: boolean
|
|
21
|
+
}>()
|
|
22
|
+
const emit = defineEmits<{ exit: [] }>()
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<UiFocusedSurface :as="as" align="top" :exit-label="exitLabel" :show-exit="showExit" @exit="emit('exit')">
|
|
27
|
+
<div class="w-full mx-auto mt-10 md:mt-16 relative z-10" :class="wide ? 'max-w-6xl' : 'max-w-2xl'">
|
|
28
|
+
<div class="flex items-center gap-1 mb-8 [&>span]:!ml-0">
|
|
29
|
+
<slot name="logo">
|
|
30
|
+
<UiLogoPro />
|
|
31
|
+
</slot>
|
|
32
|
+
<!-- Account affordance for a wizard with no exit link (first-run
|
|
33
|
+
onboarding): the user must always be able to see who they are
|
|
34
|
+
signed in as and get out. Filled by the consumer — this layer
|
|
35
|
+
can't reach the session. -->
|
|
36
|
+
<div v-if="$slots.account" class="ml-auto flex items-center gap-1">
|
|
37
|
+
<slot name="account" />
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
<slot />
|
|
41
|
+
</div>
|
|
42
|
+
</UiFocusedSurface>
|
|
43
|
+
</template>
|