@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.
Files changed (225) hide show
  1. package/README.md +101 -2
  2. package/app/app.config.ts +596 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +232 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +125 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +55 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +68 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +29 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/chartAnnotations.ts +326 -0
  180. package/app/utils/countries.ts +337 -0
  181. package/app/utils/export-download.ts +100 -0
  182. package/app/utils/favicon-fallback.ts +12 -0
  183. package/app/utils/icon-color.ts +105 -0
  184. package/app/utils/number.ts +74 -0
  185. package/app/utils/render-link.ts +14 -0
  186. package/app/utils/slot-text.ts +18 -0
  187. package/app/utils/squircle.ts +154 -0
  188. package/app/utils/topEntityStack.ts +197 -0
  189. package/app/utils/vue.ts +57 -0
  190. package/build/design-system.ts +47 -0
  191. package/build/icon-server-bundle.ts +276 -0
  192. package/build/runtime/icon-server-bundle.ts +29 -0
  193. package/css/global.css +1892 -0
  194. package/nuxt.config.ts +132 -0
  195. package/package.json +86 -3
  196. package/public/brand-icon-dark.png +0 -0
  197. package/public/brand-icon-light.png +0 -0
  198. package/public/favicon-dark.png +0 -0
  199. package/public/favicon-light.png +0 -0
  200. package/public/favicon.ico +0 -0
  201. package/public/fonts/README.md +31 -0
  202. package/public/fonts/fira-code-OFL.txt +93 -0
  203. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  204. package/public/fonts/hubot-sans-OFL.txt +93 -0
  205. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  206. package/public/icon-dark.png +0 -0
  207. package/public/icon.png +0 -0
  208. package/public/touch-icon-dark.png +0 -0
  209. package/public/touch-icon.png +0 -0
  210. package/server/api/favicon.get.ts +122 -0
  211. package/server/utils/favicon-source.ts +51 -0
  212. package/shared/contracts/feedback.ts +42 -0
  213. package/shared/contracts/index.ts +1 -0
  214. package/shared/delivery-channels.ts +53 -0
  215. package/shared/icons/index.ts +143 -0
  216. package/shared/icons/registry.ts +347 -0
  217. package/shared/icons/ui-slots.ts +58 -0
  218. package/shared/markdown.ts +44 -0
  219. package/shared/motion.ts +120 -0
  220. package/shared/rangi.ts +60 -0
  221. package/shared/rich-text.ts +34 -0
  222. package/shared/ui-icons.ts +6 -0
  223. package/shared/utils/urls.ts +64 -0
  224. package/types/index.ts +1 -0
  225. package/types/ui.ts +339 -0
@@ -0,0 +1,46 @@
1
+ <script lang="ts" setup>
2
+ import { computed } from 'vue'
3
+ /**
4
+ * Sync status indicator: pulsing dot + optional label.
5
+ * Replaces repeated inline sync dot markup across SiteGroup components.
6
+ *
7
+ * - 'syncing' → blue pulse (bg-info)
8
+ * - 'error' / 'failed' → red, solid (bg-error, no pulse — it is not in progress)
9
+ * - 'pending' / other -> muted pulse
10
+ */
11
+ const { status, label, size = '1.5' } = defineProps<{
12
+ status?: 'syncing' | 'pending' | 'error' | string | null
13
+ label?: string
14
+ size?: '1.5' | '2'
15
+ }>()
16
+
17
+ const sizeClass = { 1.5: 'size-1.5', 2: 'size-2' } as const
18
+
19
+ const isError = computed(() => status === 'error' || status === 'failed')
20
+ const dotClass = computed(() => [
21
+ sizeClass[size],
22
+ isError.value ? 'bg-error' : status === 'syncing' ? 'bg-info' : 'bg-accented',
23
+ // An error is a resting state, not progress, so it should not pulse.
24
+ isError.value ? '' : 'motion-safe:animate-pulse',
25
+ ])
26
+ const ariaLabel = computed(() => isError.value ? 'Sync error' : status === 'syncing' ? 'Syncing' : 'Sync pending')
27
+ </script>
28
+
29
+ <template>
30
+ <div v-if="label" class="flex items-center gap-2">
31
+ <span
32
+ class="rounded-full shrink-0"
33
+ :class="dotClass"
34
+ aria-hidden="true"
35
+ />
36
+ <span class="text-xs tabular-nums" :class="isError ? 'text-error' : 'text-muted'">{{ label }}</span>
37
+ <slot />
38
+ </div>
39
+ <span
40
+ v-else
41
+ class="rounded-full shrink-0"
42
+ :class="dotClass"
43
+ role="img"
44
+ :aria-label="ariaLabel"
45
+ />
46
+ </template>
@@ -0,0 +1,245 @@
1
+ <script setup lang="ts" generic="T extends string = string">
2
+ import type { ComponentPublicInstance } from 'vue'
3
+ import type { RouteLocationRaw } from 'vue-router'
4
+ import { useRoute } from 'nuxt/app'
5
+ import { computed, nextTick, onMounted, ref, watch } from 'vue'
6
+ import { NuxtLink } from '#components'
7
+ import { useEdgeFade } from '../../composables/useEdgeFade'
8
+ /**
9
+ * Horizontal tab strip. Owned primitive (ejected from `UTabs`) — the tab
10
+ * *intents* in DESIGN.md map to a semantic `kind`, and the responsive + a11y
11
+ * behaviour lives in one place. Two modes, one look:
12
+ *
13
+ * - **Route mode** (`:links`, each with a `to`): tabs that change the URL and
14
+ * render via `<NuxtPage />`. This is *navigation*, so it renders a `<nav>` of
15
+ * `NuxtLink`s with `aria-current="page"` (set by NuxtLink); active derives
16
+ * from the route and navigation is `:to` (no `@click`/`router.push`).
17
+ *
18
+ * - **Select mode** (`:items` + `v-model`): an in-page view switch that flips a
19
+ * reactive value the page reads (panels rendered by the consumer via `v-if`).
20
+ * Renders a `role="tablist"` of `<button role="tab">` with `aria-selected`.
21
+ *
22
+ * Both modes share `kind`:
23
+ * - `kind="page"` — primary strip under `ProPageHeader`; white underline on
24
+ * the active tab (text-level emphasis, never the CTA violet).
25
+ * - `kind="subnav"` — nested one level below; quiet `bg-elevated` pill.
26
+ *
27
+ * For a compact, enclosed segmented toggle in a section header, use `UiTogglePill`.
28
+ * For tabs that need the primitive to render panel slots, use `UTabs`.
29
+ */
30
+
31
+ export interface UiTabLink {
32
+ label: string
33
+ /** Any NuxtLink target: a path string or a `{ name, params }` route object. */
34
+ to: RouteLocationRaw
35
+ /**
36
+ * Match a section when its landing link differs from the current page, e.g.
37
+ * a Guides tab that stays active on every page below `/guides`. Replaces the
38
+ * exact-route match for this tab.
39
+ */
40
+ active?: (path: string) => boolean
41
+ disabled?: boolean
42
+ /** Route tab links default to no prefetch so tabs never start data work on hover/visibility. */
43
+ prefetch?: boolean
44
+ /**
45
+ * Extra classes for this tab (e.g. `text-dimmed` to mark a tab whose feature
46
+ * isn't set up yet). Applied alongside the shared tab styling; the active
47
+ * state still wins via `exact-active-class`.
48
+ */
49
+ class?: string
50
+ }
51
+ export interface UiTabItem<T extends string = string> {
52
+ label: string
53
+ value: T
54
+ disabled?: boolean
55
+ /** Stable id for the tab control when the consumer renders a tabpanel. */
56
+ tabId?: string
57
+ /** Stable id of the tabpanel controlled by this tab. */
58
+ panelId?: string
59
+ }
60
+
61
+ const { kind = 'page', links, items, ariaLabel = 'Tabs', modelValue } = defineProps<{
62
+ kind?: 'page' | 'subnav'
63
+ /** Route mode: NuxtLink tabs; active derives from the route. */
64
+ links?: UiTabLink[]
65
+ /** Select mode: in-page selection bound to `v-model`. Pass with v-model, not `links`. */
66
+ items?: UiTabItem<T>[]
67
+ /** Accessible name for the nav landmark / tablist. */
68
+ ariaLabel?: string
69
+ /** Select-mode value. Ignored in route mode. */
70
+ modelValue?: T
71
+ }>()
72
+
73
+ const emit = defineEmits<{ 'update:modelValue': [value: T] }>()
74
+
75
+ defineSlots<{
76
+ /** Trailing content inside a tab (e.g. a lock icon). Receives the link/item. */
77
+ trailing?: (props: { link: UiTabLink | UiTabItem<T> }) => unknown
78
+ }>()
79
+
80
+ const model = computed({
81
+ get: () => modelValue,
82
+ set: (value: T) => emit('update:modelValue', value),
83
+ })
84
+ const route = useRoute()
85
+
86
+ const isSelect = computed(() => !!items)
87
+ const enabledItems = computed(() => items?.filter(i => !i.disabled) ?? [])
88
+
89
+ // Root is the horizontal scroll container; one ref drives both keyboard roving
90
+ // (select mode) and the overflow-fade affordance (both modes).
91
+ const rootEl = ref<HTMLElement>()
92
+ function setRoot(el: Element | ComponentPublicInstance | null) {
93
+ const root = el && '$el' in el ? el.$el : el
94
+ rootEl.value = root instanceof HTMLElement ? root : undefined
95
+ }
96
+
97
+ // Edge fades: only show the gradient on a side that actually has more tabs to
98
+ // reach. Self-activates when the strip overflows (i.e. narrow / mobile widths)
99
+ // and clears when it fits, so desktop never masks.
100
+ const { fadeStart, fadeEnd, update: updateFades } = useEdgeFade(rootEl, scrollSelectedIntoView)
101
+
102
+ onMounted(scrollSelectedIntoView)
103
+ watch(() => (items?.length ?? 0) + (links?.length ?? 0), scrollSelectedIntoView)
104
+
105
+ // Bring the active tab into view on load and after URL/model-driven selection.
106
+ // This is especially important for grouped strips where the selected item may
107
+ // live beyond the narrow viewport.
108
+ function scrollSelectedIntoView() {
109
+ nextTick(() => {
110
+ requestAnimationFrame(() => {
111
+ const root = rootEl.value
112
+ const selected = root?.querySelector<HTMLElement>('[aria-selected="true"], [aria-current="true"], [aria-current="page"], .router-link-exact-active')
113
+ if (root && selected) {
114
+ const rootBounds = root.getBoundingClientRect()
115
+ const selectedBounds = selected.getBoundingClientRect()
116
+ const selectedLeft = selectedBounds.left - rootBounds.left + root.scrollLeft
117
+ root.scrollLeft = Math.max(0, selectedLeft - (root.clientWidth - selectedBounds.width) / 2)
118
+ }
119
+ updateFades()
120
+ })
121
+ })
122
+ }
123
+ watch(model, scrollSelectedIntoView)
124
+ watch(() => route.fullPath, scrollSelectedIntoView, { flush: 'post' })
125
+
126
+ // Roving tabindex: only the active tab is in the Tab sequence (APG tablist).
127
+ // When nothing is selected yet, the first enabled tab is the entry point so the
128
+ // tablist stays keyboard-reachable.
129
+ function isTabStop(item: UiTabItem<T>): boolean {
130
+ if (item.disabled)
131
+ return false
132
+ if (enabledItems.value.some(enabled => enabled.value === model.value))
133
+ return model.value === item.value
134
+ return item.value === enabledItems.value[0]?.value
135
+ }
136
+
137
+ // Arrow/Home/End move between tabs with automatic activation (switching is
138
+ // cheap — the consumer just re-renders a v-if panel). Horizontal orientation.
139
+ function onKeydown(e: KeyboardEvent) {
140
+ if (!items || !['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(e.key))
141
+ return
142
+ const enabled = enabledItems.value
143
+ if (!enabled.length)
144
+ return
145
+ e.preventDefault()
146
+ const current = enabled.findIndex(i => i.value === model.value)
147
+ let nextIdx: number
148
+ if (e.key === 'Home')
149
+ nextIdx = 0
150
+ else if (e.key === 'End')
151
+ nextIdx = enabled.length - 1
152
+ else
153
+ nextIdx = ((current === -1 ? 0 : current) + (e.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length
154
+ const next = enabled[nextIdx]!
155
+ model.value = next.value
156
+ const domIdx = items.findIndex(i => i.value === next.value)
157
+ nextTick(() => rootEl.value?.querySelectorAll<HTMLElement>('[role="tab"]')[domIdx]?.focus())
158
+ }
159
+
160
+ // Shared styling so route + select tabs are visually identical; `kind` and
161
+ // active-state are the only differences. The focus ring is inset: the strip
162
+ // scrolls (`overflow-x-auto`), and an outset ring on a tab that fills the strip
163
+ // height was clipped to a sliver.
164
+ const baseClass = 'group/uitab relative inline-flex items-center justify-center gap-1.5 shrink-0 whitespace-nowrap min-h-11 min-w-11 sm:min-h-0 sm:min-w-0 text-sm font-medium text-muted transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary focus-visible:rounded-sm'
165
+ const kindBase = computed(() => kind === 'page'
166
+ ? 'px-1 pb-2.5 -mb-px border-b-2 border-transparent'
167
+ : 'px-2.5 py-1 rounded-md')
168
+ // A section tab (`active`) names itself current. Only it binds `aria-current`:
169
+ // a bound attribute, even an undefined one, replaces the `aria-current="page"`
170
+ // the link sets on an exact route.
171
+ function sectionCurrent(link: UiTabLink): { 'aria-current'?: 'true' } {
172
+ if (link.disabled || !link.active)
173
+ return {}
174
+ return { 'aria-current': link.active(route.path) ? 'true' : undefined }
175
+ }
176
+ const activeClass = computed(() => kind === 'page'
177
+ ? 'text-highlighted border-inverted!'
178
+ : 'bg-elevated text-default')
179
+ </script>
180
+
181
+ <template>
182
+ <component
183
+ :is="isSelect ? 'div' : 'nav'"
184
+ :ref="setRoot"
185
+ :role="isSelect ? 'tablist' : undefined"
186
+ :aria-orientation="isSelect ? 'horizontal' : undefined"
187
+ :aria-label="ariaLabel"
188
+ class="ui-tabs ui-edge-fade flex items-stretch overflow-x-auto"
189
+ :class="kind === 'page' ? 'gap-4 border-b border-default' : 'gap-0 w-fit max-w-full rounded-lg bg-muted p-0.5'"
190
+ :data-fade-start="fadeStart || undefined"
191
+ :data-fade-end="fadeEnd || undefined"
192
+ @keydown="isSelect ? onKeydown($event) : undefined"
193
+ >
194
+ <!-- Select mode: in-page view switch -->
195
+ <template v-if="isSelect">
196
+ <button
197
+ v-for="item in items"
198
+ :id="item.tabId"
199
+ :key="item.value"
200
+ type="button"
201
+ role="tab"
202
+ :aria-controls="item.panelId"
203
+ :aria-selected="model === item.value"
204
+ :tabindex="isTabStop(item) ? 0 : -1"
205
+ :disabled="item.disabled"
206
+ class="cursor-pointer"
207
+ :class="[
208
+ baseClass,
209
+ kindBase,
210
+ model === item.value ? activeClass : '',
211
+ item.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:text-default',
212
+ ]"
213
+ @click="model = item.value"
214
+ >
215
+ <span>{{ item.label }}</span>
216
+ <slot name="trailing" :link="item" />
217
+ </button>
218
+ </template>
219
+
220
+ <!-- Route mode: navigation -->
221
+ <template v-else>
222
+ <component
223
+ :is="link.disabled ? 'span' : NuxtLink"
224
+ v-for="link in links"
225
+ :key="link.label"
226
+ :to="link.disabled ? undefined : link.to"
227
+ :prefetch="link.disabled ? undefined : (link.prefetch ?? false)"
228
+ :aria-disabled="link.disabled || undefined"
229
+ :tabindex="link.disabled ? -1 : undefined"
230
+ :exact-active-class="link.disabled || link.active ? undefined : activeClass"
231
+ v-bind="sectionCurrent(link)"
232
+ :class="[
233
+ baseClass,
234
+ kindBase,
235
+ link.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:text-default',
236
+ !link.disabled && link.active?.(route.path) ? activeClass : '',
237
+ link.class,
238
+ ]"
239
+ >
240
+ <span>{{ link.label }}</span>
241
+ <slot name="trailing" :link="link" />
242
+ </component>
243
+ </template>
244
+ </component>
245
+ </template>
@@ -0,0 +1,86 @@
1
+ <script setup lang="ts" generic="T extends string | number = string">
2
+ import { ToggleGroupItem, ToggleGroupRoot } from 'reka-ui'
3
+ import { UiIcon, UiTooltip } from '#components'
4
+
5
+ export interface TogglePillOption<T extends string | number = string> {
6
+ value: T
7
+ label: string
8
+ icon?: string
9
+ disabled?: boolean
10
+ tooltip?: string
11
+ }
12
+
13
+ const { options, label } = defineProps<{
14
+ options: TogglePillOption<T>[]
15
+ /** Accessible name for the segmented control (screen-reader only). */
16
+ label?: string
17
+ }>()
18
+
19
+ const modelValue = defineModel<T>({ required: true })
20
+
21
+ // reka ToggleGroup (type=single) round-trips the value through a DOM attr, so
22
+ // it always emits a string; map back to the original option so a numeric `T`
23
+ // keeps its type. `null`/deselect is swallowed — this is a required single-select.
24
+ function onUpdate(value: unknown) {
25
+ if (typeof value !== 'string')
26
+ return
27
+ const match = options.find(o => String(o.value) === value)
28
+ if (match)
29
+ modelValue.value = match.value
30
+ }
31
+
32
+ // An iconed option marks its label `data-toolbar-label`, so `ProDataToolbar`
33
+ // can drop it to the icon (the item keeps its aria-label) before a line wraps.
34
+ // Outside that toolbar the attribute does nothing.
35
+ //
36
+ // Canonical segmented-control proportions come from the Search Console
37
+ // portfolio toolbar: a fixed 28px desktop item inside a bordered, inset shell.
38
+ // Keeping the dimensions here prevents each dashboard concern from drifting.
39
+ const itemClass = 'h-11 min-w-11 sm:h-7 sm:min-w-0 px-2.5 inline-flex items-center justify-center gap-1.5 text-sm font-medium rounded-md transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-primary'
40
+ </script>
41
+
42
+ <template>
43
+ <ToggleGroupRoot
44
+ :model-value="String(modelValue)"
45
+ type="single"
46
+ :aria-label="label"
47
+ class="flex items-center gap-0.5 p-0.5 rounded-lg bg-[var(--ui-bg-elevated)]/60 border border-default"
48
+ @update:model-value="onUpdate"
49
+ >
50
+ <template v-for="opt in options" :key="String(opt.value)">
51
+ <UiTooltip v-if="opt.tooltip" :text="opt.tooltip" :delay-duration="200">
52
+ <ToggleGroupItem
53
+ :value="String(opt.value)"
54
+ :disabled="opt.disabled"
55
+ :aria-label="opt.label"
56
+ class="data-disabled:text-dimmed/60 data-disabled:cursor-not-allowed cursor-pointer"
57
+ :class="[
58
+ itemClass,
59
+ modelValue === opt.value
60
+ ? 'bg-default text-default shadow-sm'
61
+ : 'text-muted hover:text-default',
62
+ ]"
63
+ >
64
+ <UiIcon v-if="opt.icon" :name="opt.icon" class="size-3.5" aria-hidden="true" />
65
+ <span :data-toolbar-label="opt.icon ? '' : undefined">{{ opt.label }}</span>
66
+ </ToggleGroupItem>
67
+ </UiTooltip>
68
+ <ToggleGroupItem
69
+ v-else
70
+ :value="String(opt.value)"
71
+ :disabled="opt.disabled"
72
+ :aria-label="opt.label"
73
+ class="data-disabled:text-dimmed/60 data-disabled:cursor-not-allowed cursor-pointer"
74
+ :class="[
75
+ itemClass,
76
+ modelValue === opt.value
77
+ ? 'bg-default text-default shadow-sm'
78
+ : 'text-muted hover:text-default',
79
+ ]"
80
+ >
81
+ <UiIcon v-if="opt.icon" :name="opt.icon" class="size-3.5" aria-hidden="true" />
82
+ <span :data-toolbar-label="opt.icon ? '' : undefined">{{ opt.label }}</span>
83
+ </ToggleGroupItem>
84
+ </template>
85
+ </ToggleGroupRoot>
86
+ </template>