@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,549 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ButtonProps } from '@nuxt/ui'
|
|
3
|
+
import type { NavigationDestination } from '../../shared/navigation'
|
|
4
|
+
import type { UiIcon as UiIconName } from '../../shared/ui-icons'
|
|
5
|
+
import type { SlotTextOptions } from '../../utils/slot-text'
|
|
6
|
+
import { useMediaQuery, useMounted, useMouseInElement } from '@vueuse/core'
|
|
7
|
+
import { animateValue, m, useReducedMotion } from 'motion-v'
|
|
8
|
+
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, useSlots, useTemplateRef } from 'vue'
|
|
9
|
+
import { UButton, UiIcon, UiSlotText } from '#components'
|
|
10
|
+
import { liftPresets } from '../../shared/motion'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* UiButton — the single button primitive. UButton + motion-v lift/press
|
|
14
|
+
* + cursor-tracked surface FX, driven by a semantic `purpose` prop.
|
|
15
|
+
*
|
|
16
|
+
* `purpose` is the only styling knob — there is no raw `color`/`variant`.
|
|
17
|
+
* Each purpose resolves to a fixed UButton color+variant so button intent is
|
|
18
|
+
* consistent everywhere:
|
|
19
|
+
* - `cta` primary commit / page action — inverted neutral solid.
|
|
20
|
+
* - `secondary` alternative action — neutral outline.
|
|
21
|
+
* - `quiet` low-emphasis: alerts, inline, icon-only, back/nav — neutral ghost.
|
|
22
|
+
* - `danger` destructive — error soft (error ghost when icon-only).
|
|
23
|
+
* - `link` tertiary action below a cta/secondary — neutral link, no chrome,
|
|
24
|
+
* no FX/lift (the "see other plans" / "or paste a list" tier).
|
|
25
|
+
*
|
|
26
|
+
* Still forwarded: `size`, `block`, `loading`, `disabled`, `to`, `type`,
|
|
27
|
+
* `icon`/`trailing-icon`, `class`, all slots. `intensity` may be set
|
|
28
|
+
* explicitly to override the per-purpose motion default.
|
|
29
|
+
*
|
|
30
|
+
* Effect layers (FX-only intensities, all hover-gated, reduced-motion-aware):
|
|
31
|
+
* 1. Wrapper lift — motion-v spring scale + y on hover, settle on tap.
|
|
32
|
+
* 2. Halo — the shared `--elevation-hover` token, accent-tinted via `--fx-accent`.
|
|
33
|
+
* 3. Spotlight — radial gradient anchored to cursor (`--mb-mx/--mb-my`).
|
|
34
|
+
* 4. Specular sweep — thin skewed band on hover-enter for CTA only.
|
|
35
|
+
* 5. Trailing-icon nudge — +1.5px on hover when `#trailing` is set.
|
|
36
|
+
*
|
|
37
|
+
* Modern CSS leveraged: `@property`, `color-mix(in oklab, ...)`, `mask-image`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
defineOptions({ inheritAttrs: false })
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
purpose = 'secondary',
|
|
44
|
+
intensity: intensityProp,
|
|
45
|
+
block = false,
|
|
46
|
+
size,
|
|
47
|
+
loading = false,
|
|
48
|
+
disabled = false,
|
|
49
|
+
icon,
|
|
50
|
+
leadingIcon,
|
|
51
|
+
trailingIcon,
|
|
52
|
+
animatedLabel = true,
|
|
53
|
+
...buttonProps
|
|
54
|
+
} = defineProps<MotionButtonProps>()
|
|
55
|
+
const emit = defineEmits<{
|
|
56
|
+
click: [event: MouseEvent]
|
|
57
|
+
}>()
|
|
58
|
+
type Purpose = 'cta' | 'secondary' | 'quiet' | 'danger' | 'link'
|
|
59
|
+
type Intensity = 'subtle' | 'default' | 'cta'
|
|
60
|
+
|
|
61
|
+
interface MotionExtras {
|
|
62
|
+
purpose?: Purpose
|
|
63
|
+
intensity?: Intensity
|
|
64
|
+
// Curated semantic icon names (raw `i-*` strings still accepted).
|
|
65
|
+
icon?: UiIconName
|
|
66
|
+
leadingIcon?: UiIconName
|
|
67
|
+
trailingIcon?: UiIconName
|
|
68
|
+
animatedLabel?: boolean | SlotTextOptions
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// UButton props minus the styling knobs purpose now owns and the icon props
|
|
72
|
+
// MotionExtras re-types to the curated `UiIcon` union.
|
|
73
|
+
type MotionButtonProps = Omit<ButtonProps, 'color' | 'variant' | 'icon' | 'leadingIcon' | 'trailingIcon' | 'onClick' | 'to' | 'href'> & MotionExtras & { to?: NavigationDestination, href?: NavigationDestination }
|
|
74
|
+
|
|
75
|
+
// Semantic icon role names pass straight to UButton; @nuxt/icon's global aliases
|
|
76
|
+
// resolve them to the active set's id (raw `i-*` ids pass through unchanged).
|
|
77
|
+
const slots = useSlots()
|
|
78
|
+
|
|
79
|
+
// Icon-only when there is no label content (no default slot, no `label` prop).
|
|
80
|
+
const isIconOnly = computed(() => !slots.default && !('label' in buttonProps))
|
|
81
|
+
|
|
82
|
+
// Dev-time guard: an icon-only button with no accessible name ships silently as
|
|
83
|
+
// an unlabelled control. Warn so the caller passes `aria-label` (or `title`).
|
|
84
|
+
if (import.meta.dev) {
|
|
85
|
+
const attrs = useAttrs()
|
|
86
|
+
onMounted(() => {
|
|
87
|
+
if (isIconOnly.value && !attrs['aria-label'] && !attrs['aria-labelledby'] && !attrs.title) {
|
|
88
|
+
console.warn('[UiButton] icon-only button has no accessible name — pass `aria-label` (or `title`).', { icon, leadingIcon, trailingIcon })
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// purpose → resolved UButton color + variant.
|
|
94
|
+
const resolved = computed<{ color: ButtonProps['color'], variant: ButtonProps['variant'] }>(() => {
|
|
95
|
+
switch (purpose) {
|
|
96
|
+
case 'cta':
|
|
97
|
+
return { color: 'neutral', variant: 'solid' }
|
|
98
|
+
case 'quiet':
|
|
99
|
+
return { color: 'neutral', variant: 'ghost' }
|
|
100
|
+
case 'link':
|
|
101
|
+
return { color: 'neutral', variant: 'link' }
|
|
102
|
+
case 'danger':
|
|
103
|
+
return { color: 'error', variant: isIconOnly.value ? 'ghost' : 'soft' }
|
|
104
|
+
case 'secondary':
|
|
105
|
+
default:
|
|
106
|
+
return { color: 'neutral', variant: 'outline' }
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
const color = computed(() => resolved.value.color)
|
|
110
|
+
const variant = computed(() => resolved.value.variant)
|
|
111
|
+
|
|
112
|
+
// Per-purpose motion default; `intensity` prop overrides.
|
|
113
|
+
// cta at lg/xl → full CTA FX; smaller cta → default. quiet → subtle (no FX).
|
|
114
|
+
const intensity = computed<Intensity>(() => {
|
|
115
|
+
if (intensityProp)
|
|
116
|
+
return intensityProp
|
|
117
|
+
if (purpose === 'cta')
|
|
118
|
+
return (size === 'xl' || size === 'lg' || !size) ? 'cta' : 'default'
|
|
119
|
+
if (purpose === 'quiet' || purpose === 'link' || size === 'xs')
|
|
120
|
+
return 'subtle'
|
|
121
|
+
return 'default'
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const reduced = useReducedMotion()
|
|
125
|
+
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
|
|
126
|
+
const wrapperEl = useTemplateRef<HTMLElement>('wrapperEl')
|
|
127
|
+
const shellEl = useTemplateRef<HTMLElement>('shellEl')
|
|
128
|
+
const clipEl = useTemplateRef<HTMLElement>('clipEl')
|
|
129
|
+
const hovered = ref(false)
|
|
130
|
+
let resizeObserver: ResizeObserver | undefined
|
|
131
|
+
|
|
132
|
+
const isDisabled = computed(() => disabled || loading)
|
|
133
|
+
// Mount-gated: `canHover` is a media query, so it's false on the server and
|
|
134
|
+
// flips true at hydration on desktop — rendering the v-if FX spans straight
|
|
135
|
+
// from `canHover` mismatches on every desktop pageview. The FX layers are all
|
|
136
|
+
// hover-gated (invisible at rest), so deferring them to post-mount is free.
|
|
137
|
+
const isMounted = useMounted()
|
|
138
|
+
const showFx = computed(() => isMounted.value && canHover.value && intensity.value !== 'subtle' && !isDisabled.value)
|
|
139
|
+
|
|
140
|
+
// Only attach the mouse tracker when FX render — saves ~100 listeners on
|
|
141
|
+
// dashboards full of subtle/icon buttons.
|
|
142
|
+
const trackerTarget = computed(() => (showFx.value ? wrapperEl.value : null))
|
|
143
|
+
const { elementX, elementY, elementWidth, elementHeight, isOutside }
|
|
144
|
+
= useMouseInElement(trackerTarget)
|
|
145
|
+
|
|
146
|
+
const mxPct = computed(() => {
|
|
147
|
+
if (!showFx.value || isOutside.value || !elementWidth.value)
|
|
148
|
+
return 50
|
|
149
|
+
return Math.max(0, Math.min(100, (elementX.value / elementWidth.value) * 100))
|
|
150
|
+
})
|
|
151
|
+
const myPct = computed(() => {
|
|
152
|
+
if (!showFx.value || isOutside.value || !elementHeight.value)
|
|
153
|
+
return 50
|
|
154
|
+
return Math.max(0, Math.min(100, (elementY.value / elementHeight.value) * 100))
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const lift = computed(() => {
|
|
158
|
+
// Link is a text affordance, not a surface — never lift/scale on hover.
|
|
159
|
+
if (reduced.value || !canHover.value || purpose === 'link')
|
|
160
|
+
return { hover: {}, tap: {}, transition: {} }
|
|
161
|
+
const preset = liftPresets[intensity.value]
|
|
162
|
+
// A full-width button scaling horizontally overflows its container and gets
|
|
163
|
+
// clipped by any `overflow:hidden` ancestor. Keep the vertical lift, drop the
|
|
164
|
+
// scale so block buttons stay within bounds.
|
|
165
|
+
if (block) {
|
|
166
|
+
return {
|
|
167
|
+
hover: { y: preset.hover.y },
|
|
168
|
+
tap: { y: preset.tap.y },
|
|
169
|
+
transition: preset.transition,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return preset
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const wrapperStyle = computed(() => {
|
|
176
|
+
// Hover capability is only authoritative in the browser. Keep the default
|
|
177
|
+
// FX variables deterministic through SSR/hydration, then let the mouse
|
|
178
|
+
// tracker update them; subtle/disabled controls never consume the values.
|
|
179
|
+
if (intensity.value === 'subtle' || isDisabled.value)
|
|
180
|
+
return undefined
|
|
181
|
+
return {
|
|
182
|
+
'--mb-mx': `${mxPct.value}%`,
|
|
183
|
+
'--mb-my': `${myPct.value}%`,
|
|
184
|
+
'--fx-accent': 'var(--ui-text)',
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// `w-fit` keeps the wrapper hugging its content even when it's a flex/grid item
|
|
189
|
+
// (an inline-flex box blockifies to flex and would otherwise stretch to the
|
|
190
|
+
// column's full width, dragging the halo/spot FX overlays out with it).
|
|
191
|
+
const wrapperClass = computed(() => block ? 'flex w-full' : 'inline-flex w-fit max-w-full')
|
|
192
|
+
const leadingIconName = computed(() => loading ? 'loading' : (leadingIcon || icon))
|
|
193
|
+
// `useReducedMotion()` is browser-only knowledge. Gate the initial pose on the
|
|
194
|
+
// same mounted state as the hover FX so SSR and hydration both render the icon
|
|
195
|
+
// at rest. Later keyed icon changes can still animate on motion-enabled clients.
|
|
196
|
+
const leadingInitial = computed(() => isMounted.value && !reduced.value
|
|
197
|
+
? { opacity: 0, x: -6, scale: 0.82 }
|
|
198
|
+
: false)
|
|
199
|
+
const hasLeading = computed(() => !!slots.leading || !!leadingIconName.value)
|
|
200
|
+
const hasTrailing = computed(() => !!slots.trailing)
|
|
201
|
+
const buttonLabel = computed(() => typeof buttonProps.label === 'string' ? buttonProps.label : undefined)
|
|
202
|
+
const shouldAutoAnimateLabel = computed(() => {
|
|
203
|
+
if (!buttonLabel.value || slots.default || block || loading || animatedLabel === false)
|
|
204
|
+
return false
|
|
205
|
+
return buttonLabel.value.length <= 24
|
|
206
|
+
})
|
|
207
|
+
const animatedLabelOptions = computed(() => {
|
|
208
|
+
if (!shouldAutoAnimateLabel.value)
|
|
209
|
+
return null
|
|
210
|
+
|
|
211
|
+
return typeof animatedLabel === 'object' ? animatedLabel : {}
|
|
212
|
+
})
|
|
213
|
+
const hasAnimatedLabel = computed(() => !!animatedLabelOptions.value && !!buttonLabel.value)
|
|
214
|
+
const resolvedButtonProps = computed(() => {
|
|
215
|
+
if (!hasAnimatedLabel.value)
|
|
216
|
+
return buttonProps
|
|
217
|
+
const { label: _label, ...rest } = buttonProps
|
|
218
|
+
return rest
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
function buttonEl() {
|
|
222
|
+
return domNode(clipEl.value)?.querySelector<HTMLElement>('.ui-motion-button__btn') ?? null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function domNode(value: unknown): HTMLElement | null {
|
|
226
|
+
if (Array.isArray(value))
|
|
227
|
+
return domNode(value[0])
|
|
228
|
+
|
|
229
|
+
if (value && typeof (value as HTMLElement).querySelector === 'function')
|
|
230
|
+
return value as HTMLElement
|
|
231
|
+
|
|
232
|
+
const el = (value as { $el?: unknown } | null)?.$el
|
|
233
|
+
if (el && typeof (el as HTMLElement).querySelector === 'function')
|
|
234
|
+
return el as HTMLElement
|
|
235
|
+
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The shell follows the button's width with a spring when the label, an icon
|
|
240
|
+
// or the loading state changes it. At rest the shell has no width of its own,
|
|
241
|
+
// so it hugs the button exactly and a late web font cannot leave it clipped.
|
|
242
|
+
// The ResizeObserver reports the button's border box after layout, so no
|
|
243
|
+
// button forces a layout to measure itself while the page hydrates. The clip
|
|
244
|
+
// does not shrink (`shrink-0`), so a pinned shell never changes the width the
|
|
245
|
+
// observer reads.
|
|
246
|
+
const SHELL_SPRING = { type: 'spring', stiffness: 420, damping: 34, mass: 0.62 } as const
|
|
247
|
+
let buttonWidth = 0
|
|
248
|
+
// The width the shell paints now: the latest spring frame while one runs.
|
|
249
|
+
let shellWidth = 0
|
|
250
|
+
let shellAnimation: { stop: () => void } | undefined
|
|
251
|
+
|
|
252
|
+
function releaseShell(shell: HTMLElement) {
|
|
253
|
+
shellAnimation?.stop()
|
|
254
|
+
shellAnimation = undefined
|
|
255
|
+
shell.style.width = ''
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function followButtonWidth(width: number) {
|
|
259
|
+
const previous = buttonWidth
|
|
260
|
+
buttonWidth = width
|
|
261
|
+
const shell = shellEl.value
|
|
262
|
+
// The first report only records the width. A hidden button reports 0, and
|
|
263
|
+
// it must not grow from nothing when it appears.
|
|
264
|
+
if (!shell || block || !previous || !width || previous === width)
|
|
265
|
+
return
|
|
266
|
+
if (reduced.value) {
|
|
267
|
+
releaseShell(shell)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
// The observer runs after layout and before paint, so the frame paints the
|
|
271
|
+
// shell at its previous width. A label change during a spring starts the
|
|
272
|
+
// next spring from the current frame, so nothing here reads layout.
|
|
273
|
+
const from = shellAnimation ? shellWidth : previous
|
|
274
|
+
shellAnimation?.stop()
|
|
275
|
+
shellWidth = from
|
|
276
|
+
shell.style.width = `${from}px`
|
|
277
|
+
shellAnimation = animateValue({
|
|
278
|
+
keyframes: [from, Math.ceil(width)],
|
|
279
|
+
...SHELL_SPRING,
|
|
280
|
+
onUpdate: (value: number) => {
|
|
281
|
+
shellWidth = value
|
|
282
|
+
shell.style.width = `${value}px`
|
|
283
|
+
},
|
|
284
|
+
onComplete: () => {
|
|
285
|
+
shellAnimation = undefined
|
|
286
|
+
shell.style.width = ''
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
onMounted(() => {
|
|
292
|
+
const el = buttonEl()
|
|
293
|
+
if (!el || typeof ResizeObserver === 'undefined')
|
|
294
|
+
return
|
|
295
|
+
resizeObserver = new ResizeObserver(([entry]) => {
|
|
296
|
+
if (!entry)
|
|
297
|
+
return
|
|
298
|
+
// `borderBoxSize` includes the border; the content box left the shell 2px short.
|
|
299
|
+
const box = entry.borderBoxSize?.[0]
|
|
300
|
+
followButtonWidth(box ? box.inlineSize : entry.target.getBoundingClientRect().width)
|
|
301
|
+
})
|
|
302
|
+
resizeObserver.observe(el)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
onBeforeUnmount(() => {
|
|
306
|
+
resizeObserver?.disconnect()
|
|
307
|
+
shellAnimation?.stop()
|
|
308
|
+
})
|
|
309
|
+
</script>
|
|
310
|
+
|
|
311
|
+
<template>
|
|
312
|
+
<m.div
|
|
313
|
+
ref="wrapperEl"
|
|
314
|
+
tabindex="-1"
|
|
315
|
+
class="ui-motion-button group/mbtn relative isolate"
|
|
316
|
+
:class="[wrapperClass]"
|
|
317
|
+
:while-hover="isDisabled ? undefined : lift.hover"
|
|
318
|
+
:while-press="isDisabled ? undefined : lift.tap"
|
|
319
|
+
:transition="lift.transition"
|
|
320
|
+
:data-intensity="intensity"
|
|
321
|
+
:data-color="color"
|
|
322
|
+
:style="wrapperStyle"
|
|
323
|
+
@hover-start="hovered = !isDisabled"
|
|
324
|
+
@hover-end="hovered = false"
|
|
325
|
+
>
|
|
326
|
+
<!-- Halo — ambient lift via box-shadow rings, follows button shape. -->
|
|
327
|
+
<span
|
|
328
|
+
v-if="showFx"
|
|
329
|
+
class="ui-motion-button__halo pointer-events-none absolute"
|
|
330
|
+
aria-hidden="true"
|
|
331
|
+
/>
|
|
332
|
+
|
|
333
|
+
<!-- Clipper bounds FX overlays to the button shape. Skip overflow:hidden
|
|
334
|
+
when no FX render so the button's native border-radius isn't cropped. -->
|
|
335
|
+
<div
|
|
336
|
+
ref="shellEl"
|
|
337
|
+
tabindex="-1"
|
|
338
|
+
class="ui-motion-button__clip-shell"
|
|
339
|
+
:class="block ? 'flex w-full' : 'inline-flex'"
|
|
340
|
+
>
|
|
341
|
+
<div
|
|
342
|
+
ref="clipEl"
|
|
343
|
+
class="ui-motion-button__clip relative"
|
|
344
|
+
:class="[block ? 'flex w-full' : 'inline-flex w-max max-w-none shrink-0 whitespace-nowrap', { 'ui-motion-button__clip--active': showFx }]"
|
|
345
|
+
>
|
|
346
|
+
<UButton
|
|
347
|
+
v-bind="{ ...resolvedButtonProps, ...$attrs } as ButtonProps"
|
|
348
|
+
:color="color"
|
|
349
|
+
:size="size"
|
|
350
|
+
:variant="variant"
|
|
351
|
+
:loading="false"
|
|
352
|
+
:disabled="isDisabled"
|
|
353
|
+
:block="block"
|
|
354
|
+
:icon="undefined"
|
|
355
|
+
:leading-icon="undefined"
|
|
356
|
+
:trailing-icon="trailingIcon"
|
|
357
|
+
class="ui-motion-button__btn relative z-10 whitespace-nowrap" :class="[{ 'hover:underline underline-offset-2': purpose === 'link' }]"
|
|
358
|
+
@click="emit('click', $event)"
|
|
359
|
+
>
|
|
360
|
+
<template v-if="hasLeading" #leading="slotData">
|
|
361
|
+
<slot v-if="slots.leading" name="leading" v-bind="slotData || {}" />
|
|
362
|
+
<m.span
|
|
363
|
+
v-else-if="leadingIconName"
|
|
364
|
+
:key="leadingIconName"
|
|
365
|
+
class="ui-motion-button__leading inline-flex shrink-0"
|
|
366
|
+
:initial="leadingInitial"
|
|
367
|
+
:animate="{ opacity: 1, x: 0, scale: 1 }"
|
|
368
|
+
:transition="reduced ? { duration: 0 } : { type: 'spring', stiffness: 520, damping: 30, mass: 0.55 }"
|
|
369
|
+
>
|
|
370
|
+
<UiIcon
|
|
371
|
+
:name="leadingIconName"
|
|
372
|
+
class="size-4"
|
|
373
|
+
:class="{ 'animate-spin': loading }"
|
|
374
|
+
aria-hidden="true"
|
|
375
|
+
/>
|
|
376
|
+
</m.span>
|
|
377
|
+
</template>
|
|
378
|
+
|
|
379
|
+
<template v-if="hasAnimatedLabel && buttonLabel && animatedLabelOptions" #default>
|
|
380
|
+
<UiSlotText :text="buttonLabel" :options="animatedLabelOptions" />
|
|
381
|
+
</template>
|
|
382
|
+
|
|
383
|
+
<!-- Forward every UButton slot. Trailing gets a hover-nudge wrapper. -->
|
|
384
|
+
<template v-for="(_, name) in ($slots as Record<string, unknown>)" :key="name" #[name]="slotData">
|
|
385
|
+
<slot v-if="name !== 'leading' && name !== 'trailing'" :name="name" v-bind="slotData || {}" />
|
|
386
|
+
</template>
|
|
387
|
+
|
|
388
|
+
<template v-if="hasTrailing" #trailing="slotData">
|
|
389
|
+
<m.span
|
|
390
|
+
class="inline-flex"
|
|
391
|
+
:animate="{ x: hovered && !reduced ? 1.5 : 0 }"
|
|
392
|
+
:transition="{ duration: 0.22, ease: 'easeOut' }"
|
|
393
|
+
>
|
|
394
|
+
<slot name="trailing" v-bind="slotData || {}" />
|
|
395
|
+
</m.span>
|
|
396
|
+
</template>
|
|
397
|
+
</UButton>
|
|
398
|
+
|
|
399
|
+
<span
|
|
400
|
+
v-if="showFx"
|
|
401
|
+
class="ui-motion-button__spot pointer-events-none absolute inset-0"
|
|
402
|
+
aria-hidden="true"
|
|
403
|
+
/>
|
|
404
|
+
|
|
405
|
+
<span
|
|
406
|
+
v-if="showFx && intensity === 'cta'"
|
|
407
|
+
class="ui-motion-button__shimmer pointer-events-none absolute inset-y-0"
|
|
408
|
+
aria-hidden="true"
|
|
409
|
+
/>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
</m.div>
|
|
413
|
+
</template>
|
|
414
|
+
|
|
415
|
+
<style scoped>
|
|
416
|
+
@property --mb-mx {
|
|
417
|
+
syntax: '<percentage>';
|
|
418
|
+
inherits: true;
|
|
419
|
+
initial-value: 50%;
|
|
420
|
+
}
|
|
421
|
+
@property --mb-my {
|
|
422
|
+
syntax: '<percentage>';
|
|
423
|
+
inherits: true;
|
|
424
|
+
initial-value: 50%;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* Match UButton's native rounded-md (0.375rem) so the clip doesn't crop. */
|
|
428
|
+
.ui-motion-button {
|
|
429
|
+
border-radius: 0.375rem;
|
|
430
|
+
}
|
|
431
|
+
.ui-motion-button[data-intensity="cta"] {
|
|
432
|
+
border-radius: 0.5rem;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/* Callers may opt into a pill. Every motion layer must follow that shape. */
|
|
436
|
+
.ui-motion-button:has(.ui-motion-button__btn.rounded-full) {
|
|
437
|
+
border-radius: 9999px;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.ui-motion-button__clip-shell,
|
|
441
|
+
.ui-motion-button__clip--active {
|
|
442
|
+
overflow: hidden;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* Focus: the clip shell crops any outline the button draws on itself, so the
|
|
446
|
+
wrapper draws it instead, at the shared focus token with a 2px gap (3:1 or
|
|
447
|
+
more against the page and the button, WCAG 1.4.11 and 2.4.7). */
|
|
448
|
+
.ui-motion-button:has(.ui-motion-button__btn:focus-visible) {
|
|
449
|
+
outline: 3px solid var(--focus-ring-color);
|
|
450
|
+
outline-offset: 2px;
|
|
451
|
+
}
|
|
452
|
+
.ui-motion-button__btn:focus-visible {
|
|
453
|
+
outline: none;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.ui-motion-button__clip-shell,
|
|
457
|
+
.ui-motion-button__clip,
|
|
458
|
+
.ui-motion-button__clip--active {
|
|
459
|
+
border-radius: inherit;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
@media (prefers-reduced-motion: reduce) {
|
|
463
|
+
.ui-motion-button__clip-shell {
|
|
464
|
+
transition: none;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* ── Halo ── hover-tier elevation: the shared --elevation-hover token, an
|
|
469
|
+
accent-tinted ambient lift. --fx-accent (set on the wrapper) tints it;
|
|
470
|
+
the cta intensity scales it up locally. */
|
|
471
|
+
.ui-motion-button__halo {
|
|
472
|
+
z-index: 0;
|
|
473
|
+
inset: 0;
|
|
474
|
+
opacity: 0;
|
|
475
|
+
border-radius: inherit;
|
|
476
|
+
background: transparent;
|
|
477
|
+
box-shadow: var(--elevation-hover);
|
|
478
|
+
transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
479
|
+
}
|
|
480
|
+
.ui-motion-button[data-intensity="cta"] .ui-motion-button__halo {
|
|
481
|
+
box-shadow:
|
|
482
|
+
0 3px 12px -3px color-mix(in oklab, var(--fx-accent) 16%, transparent),
|
|
483
|
+
0 12px 28px -10px color-mix(in oklab, var(--fx-accent) 12%, transparent);
|
|
484
|
+
}
|
|
485
|
+
.ui-motion-button:hover .ui-motion-button__halo {
|
|
486
|
+
opacity: 1;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* ── Spotlight — cursor-tracked neutral highlight. */
|
|
490
|
+
.ui-motion-button__spot {
|
|
491
|
+
z-index: 20;
|
|
492
|
+
opacity: 0;
|
|
493
|
+
border-radius: inherit;
|
|
494
|
+
background: radial-gradient(
|
|
495
|
+
circle 60px at var(--mb-mx, 50%) var(--mb-my, 50%),
|
|
496
|
+
color-mix(in oklab, var(--fx-accent) 10%, transparent),
|
|
497
|
+
transparent 70%
|
|
498
|
+
);
|
|
499
|
+
transition: opacity 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
500
|
+
-webkit-mask-image: radial-gradient(ellipse at center, rgb(0 0 0) 60%, rgb(0 0 0 / 0.5) 90%, rgb(0 0 0 / 0) 100%);
|
|
501
|
+
mask-image: radial-gradient(ellipse at center, rgb(0 0 0) 60%, rgb(0 0 0 / 0.5) 90%, rgb(0 0 0 / 0) 100%);
|
|
502
|
+
}
|
|
503
|
+
.ui-motion-button[data-intensity="cta"] .ui-motion-button__spot {
|
|
504
|
+
background: radial-gradient(
|
|
505
|
+
circle 80px at var(--mb-mx, 50%) var(--mb-my, 50%),
|
|
506
|
+
color-mix(in oklab, var(--fx-accent) 14%, transparent),
|
|
507
|
+
transparent 70%
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
.ui-motion-button:hover .ui-motion-button__spot {
|
|
511
|
+
opacity: 1;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/* ── Specular sweep — CTA only, one pass per hover-enter. */
|
|
515
|
+
.ui-motion-button__shimmer {
|
|
516
|
+
z-index: 25;
|
|
517
|
+
width: 22%;
|
|
518
|
+
left: 0;
|
|
519
|
+
transform: translateX(-220%) skewX(-22deg);
|
|
520
|
+
background: linear-gradient(
|
|
521
|
+
90deg,
|
|
522
|
+
transparent,
|
|
523
|
+
color-mix(in oklab, var(--fx-accent) 18%, transparent) 50%,
|
|
524
|
+
transparent
|
|
525
|
+
);
|
|
526
|
+
opacity: 0;
|
|
527
|
+
filter: blur(3px);
|
|
528
|
+
}
|
|
529
|
+
.ui-motion-button[data-intensity="cta"]:hover .ui-motion-button__shimmer {
|
|
530
|
+
opacity: 1;
|
|
531
|
+
animation: ui-mbtn-sweep 1.4s linear(0, 0.05 8%, 0.2 18%, 0.55 40%, 0.85 65%, 0.96 80%, 1) both;
|
|
532
|
+
}
|
|
533
|
+
@keyframes ui-mbtn-sweep {
|
|
534
|
+
0% { transform: translateX(-220%) skewX(-22deg); }
|
|
535
|
+
100% { transform: translateX(400%) skewX(-22deg); }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
@media (prefers-reduced-motion: reduce) {
|
|
539
|
+
.ui-motion-button { transform: none; transition: none; }
|
|
540
|
+
.ui-motion-button__spot,
|
|
541
|
+
.ui-motion-button__shimmer,
|
|
542
|
+
.ui-motion-button__halo { display: none; }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
@media (hover: none) {
|
|
546
|
+
.ui-motion-button__spot,
|
|
547
|
+
.ui-motion-button__shimmer { display: none; }
|
|
548
|
+
}
|
|
549
|
+
</style>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Transition } from 'motion-v'
|
|
3
|
+
import type { UiIcon as UiIconName } from '../../shared/ui-icons'
|
|
4
|
+
import { m } from 'motion-v'
|
|
5
|
+
import { UiAtmosphere, UiIcon } from '#components'
|
|
6
|
+
import { useEntranceMotion } from '../../composables/useEntranceMotion'
|
|
7
|
+
import { entrancePresets } from '../../shared/motion'
|
|
8
|
+
|
|
9
|
+
// The "spark joy" moment (DESIGN principle 8): a success / milestone reveal — a
|
|
10
|
+
// site connected, a waitlist joined, a trial started. This is the ONE place a
|
|
11
|
+
// component defaults to atmosphere (a Dawn bloom = first light), because it is
|
|
12
|
+
// exclusively a celebratory edge, never a data surface. Restraint: the joy is the
|
|
13
|
+
// bloom + a single quiet spring on reveal, NOT confetti. Reduced-motion safe.
|
|
14
|
+
const {
|
|
15
|
+
icon,
|
|
16
|
+
palette = 'dawn',
|
|
17
|
+
title,
|
|
18
|
+
description,
|
|
19
|
+
} = defineProps<{
|
|
20
|
+
/** Optional small mark above the title (a single monochrome-container icon, primary glyph). */
|
|
21
|
+
icon?: UiIconName
|
|
22
|
+
/** Atmosphere palette — Dawn (first light) by default; Dusk for a premium/paid moment. */
|
|
23
|
+
palette?: 'ink' | 'twilight' | 'dawn' | 'dusk' | 'mist' | 'ash'
|
|
24
|
+
title: string
|
|
25
|
+
description?: string
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
// A gentle overshoot — the sanctioned celebratory spring (subtle, no anticipation).
|
|
29
|
+
const medallionPop = { type: 'spring', stiffness: 300, damping: 14, mass: 0.8 } satisfies Transition
|
|
30
|
+
const medallionEntrance = useEntranceMotion({ initial: { opacity: 0, scale: 0.8 }, enter: { opacity: 1, scale: 1 }, transition: medallionPop })
|
|
31
|
+
const textEntrance = useEntranceMotion(entrancePresets.fadeUp)
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<div class="relative isolate overflow-hidden flex flex-col items-center text-center px-6 py-12">
|
|
36
|
+
<UiAtmosphere :palette="palette" geometry="bloom" intensity="present" style="z-index: -1" />
|
|
37
|
+
|
|
38
|
+
<!-- Icon springs in once (a quiet overshoot), then stands still. -->
|
|
39
|
+
<m.div
|
|
40
|
+
v-if="icon"
|
|
41
|
+
class="inline-flex items-center justify-center size-12 rounded-2xl bg-elevated border border-default mb-5"
|
|
42
|
+
v-bind="medallionEntrance"
|
|
43
|
+
>
|
|
44
|
+
<UiIcon :name="icon" class="size-6 text-primary" aria-hidden="true" />
|
|
45
|
+
</m.div>
|
|
46
|
+
|
|
47
|
+
<m.h2
|
|
48
|
+
class="text-2xl font-display font-semibold tracking-tight text-highlighted"
|
|
49
|
+
v-bind="textEntrance"
|
|
50
|
+
>
|
|
51
|
+
{{ title }}
|
|
52
|
+
</m.h2>
|
|
53
|
+
<m.p
|
|
54
|
+
v-if="description"
|
|
55
|
+
class="text-sm text-muted mt-2 max-w-md leading-relaxed"
|
|
56
|
+
v-bind="textEntrance"
|
|
57
|
+
>
|
|
58
|
+
{{ description }}
|
|
59
|
+
</m.p>
|
|
60
|
+
|
|
61
|
+
<div v-if="$slots.default" class="relative mt-6 [&_button]:min-h-11 [&_a]:min-h-11 sm:[&_button]:min-h-0 sm:[&_a]:min-h-0">
|
|
62
|
+
<slot />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { DeliveryChannelKey } from '../../../shared/delivery-channels'
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
import { UiIcon } from '#components'
|
|
5
|
+
import { DELIVERY_CHANNELS, deliveryChannelsSummary, enabledDeliveryChannels } from '../../../shared/delivery-channels'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* UiChannelChips — the delivery channels of a rule as compact stacked chips.
|
|
9
|
+
*
|
|
10
|
+
* Replaces the stacked list of channel cards that spent a whole column on four
|
|
11
|
+
* words. Chips overlap like an avatar stack so the set reads as ONE object at a
|
|
12
|
+
* glance; the glyph carries the identity (monochrome, per DESIGN.md) and the
|
|
13
|
+
* accessible name carries the words.
|
|
14
|
+
*
|
|
15
|
+
* Accepts either the stored `channels` record or an explicit ordered list.
|
|
16
|
+
*/
|
|
17
|
+
const {
|
|
18
|
+
channels,
|
|
19
|
+
keys,
|
|
20
|
+
size = 'sm',
|
|
21
|
+
empty = 'No delivery channel',
|
|
22
|
+
showEmpty = true,
|
|
23
|
+
} = defineProps<{
|
|
24
|
+
/** The stored channels record, e.g. `{ email: true, discord: false }`. */
|
|
25
|
+
channels?: Partial<Record<DeliveryChannelKey, unknown>> | null
|
|
26
|
+
/** Explicit channel list; wins over `channels` when both are given. */
|
|
27
|
+
keys?: readonly string[]
|
|
28
|
+
size?: 'xs' | 'sm' | 'md'
|
|
29
|
+
empty?: string
|
|
30
|
+
/** Render the empty sentence when nothing is enabled (else render nothing). */
|
|
31
|
+
showEmpty?: boolean
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const resolved = computed<DeliveryChannelKey[]>(() => {
|
|
35
|
+
if (keys)
|
|
36
|
+
return keys.filter((key): key is DeliveryChannelKey => key in DELIVERY_CHANNELS)
|
|
37
|
+
return enabledDeliveryChannels(channels)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const summary = computed(() => deliveryChannelsSummary(resolved.value, empty))
|
|
41
|
+
|
|
42
|
+
const CHIP = {
|
|
43
|
+
xs: { box: 'size-5', icon: 'size-2.5', overlap: '-ml-1.5' },
|
|
44
|
+
sm: { box: 'size-6', icon: 'size-3', overlap: '-ml-2' },
|
|
45
|
+
md: { box: 'size-7', icon: 'size-3.5', overlap: '-ml-2' },
|
|
46
|
+
} as const
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<span v-if="resolved.length" class="inline-flex items-center" role="img" :aria-label="`Delivered via ${summary}`">
|
|
51
|
+
<span
|
|
52
|
+
v-for="(key, index) in resolved"
|
|
53
|
+
:key="key"
|
|
54
|
+
:title="DELIVERY_CHANNELS[key].label"
|
|
55
|
+
class="inline-flex items-center justify-center rounded-full border border-default bg-elevated ring-2 ring-[var(--ui-bg)]"
|
|
56
|
+
:class="[CHIP[size].box, index > 0 && CHIP[size].overlap]"
|
|
57
|
+
>
|
|
58
|
+
<UiIcon :name="DELIVERY_CHANNELS[key].icon" class="text-dimmed" :class="[CHIP[size].icon]" />
|
|
59
|
+
</span>
|
|
60
|
+
</span>
|
|
61
|
+
<span v-else-if="showEmpty" class="text-xs text-muted">{{ empty }}</span>
|
|
62
|
+
</template>
|