@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,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SemanticStatus } from '../../composables/semanticColors'
|
|
3
|
+
import type { UiIcon as UiIconName } from '../../shared/ui-icons'
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
import { UiChip, UiIcon } from '#components'
|
|
6
|
+
import { semanticColors } from '../../composables/semanticColors'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* UiStatusBadge — the semantic status entry.
|
|
10
|
+
*
|
|
11
|
+
* Default (sm) is a colored dot + neutral text: color rides the 6px dot, the
|
|
12
|
+
* label stays `text-default`. This is the calm, stackable treatment for tables
|
|
13
|
+
* and lists where many statuses appear at once — the expected state shouldn't
|
|
14
|
+
* spend the page's color budget (see the color-budget ladder in the brand-kit).
|
|
15
|
+
*
|
|
16
|
+
* - `prominent` escalate to a tinted UiChip fill. Reserve for a SINGLE
|
|
17
|
+
* prominent status (a card header, a hero callout), never in a
|
|
18
|
+
* repeated row.
|
|
19
|
+
* - `size="md"` icon container for empty / error states.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { status = 'neutral', icon, label, size = 'sm', prominent = false, readable = false } = defineProps<{
|
|
23
|
+
status?: SemanticStatus
|
|
24
|
+
icon?: UiIconName
|
|
25
|
+
label?: string
|
|
26
|
+
/** 'sm' = inline indicator, 'md' = icon container (empty/error states) */
|
|
27
|
+
size?: 'sm' | 'md'
|
|
28
|
+
/** Escalate to a tinted chip fill. Reserve for a single prominent status. */
|
|
29
|
+
prominent?: boolean
|
|
30
|
+
/** Keep a report-visible inline label at the 14px readable tier. */
|
|
31
|
+
readable?: boolean
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const colors = computed(() => semanticColors[status])
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<!-- md: icon container (empty / error states) -->
|
|
39
|
+
<div v-if="size === 'md'" class="flex flex-col items-center gap-2">
|
|
40
|
+
<div class="size-12 rounded-2xl flex items-center justify-center" :class="colors.bg">
|
|
41
|
+
<UiIcon v-if="icon" :name="icon" class="size-6" :class="colors.text" aria-hidden="true" />
|
|
42
|
+
</div>
|
|
43
|
+
<slot>
|
|
44
|
+
<span v-if="label" class="text-sm font-medium">{{ label }}</span>
|
|
45
|
+
</slot>
|
|
46
|
+
</div>
|
|
47
|
+
<!-- sm + prominent: tinted chip — singular emphasis only -->
|
|
48
|
+
<UiChip
|
|
49
|
+
v-else-if="prominent"
|
|
50
|
+
purpose="status"
|
|
51
|
+
:status="status"
|
|
52
|
+
:icon="icon"
|
|
53
|
+
:size="readable ? 'md' : 'sm'"
|
|
54
|
+
>
|
|
55
|
+
<slot>{{ label }}</slot>
|
|
56
|
+
</UiChip>
|
|
57
|
+
<!-- sm default: colored dot + neutral text — calm, stackable -->
|
|
58
|
+
<span v-else class="inline-flex items-center gap-1.5 whitespace-nowrap" :class="readable ? 'text-sm' : 'text-xs'">
|
|
59
|
+
<span class="size-1.5 rounded-full shrink-0" :class="colors.dot" aria-hidden="true" />
|
|
60
|
+
<span class="text-default"><slot>{{ label }}</slot></span>
|
|
61
|
+
</span>
|
|
62
|
+
</template>
|
|
@@ -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>
|