@nuxtseo/design-system 0.1.0 → 0.1.2
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 +153 -7
- package/app/app.config.ts +6 -10
- package/app/components/container/UiAlert.vue +4 -2
- package/app/components/container/UiErrorState.vue +5 -3
- package/app/components/container/UiSectionHeader.vue +12 -2
- package/app/components/container/UiToast.vue +1 -37
- package/app/components/container/UiToaster.vue +1 -11
- package/app/components/container/UiWidgetState.vue +7 -5
- package/app/components/data/UiDataList.vue +6 -4
- package/app/components/data/UiDataTableSection.vue +1 -0
- package/app/components/data/UiMetricLabel.vue +3 -1
- package/app/components/data/UiStat.vue +1 -60
- package/app/components/data/UiStats.vue +1 -1
- package/app/components/data/UiTable.vue +16 -52
- package/app/components/data/UiTableTd.vue +9 -1
- package/app/components/data/UiTableTh.vue +10 -2
- package/app/components/data/cells/UiTableHeaderCell.vue +1 -1
- package/app/components/element/UiAtmosphere.vue +22 -4
- package/app/components/element/UiConfirmDialog.vue +54 -7
- package/app/components/element/UiOnboardingAnswers.vue +95 -58
- package/app/components/element/UiSelect.vue +15 -2
- package/app/components/element/UiSkeleton.vue +50 -1
- package/app/components/element/UiTooltip.vue +7 -2
- package/app/components/element/UiTrend.vue +16 -8
- package/app/components/element/onboarding-answers.ts +25 -0
- package/app/composables/design-system-config.ts +29 -0
- package/app/composables/useConfirm.ts +31 -3
- package/app/plugins/favicon.ts +7 -21
- package/app/shared/error-state.ts +13 -12
- package/app/shared/failure-copy.ts +53 -0
- package/app/shared/stat.ts +65 -0
- package/app/shared/table.ts +74 -1
- package/app/shared/toast.ts +52 -0
- package/app/shared/widget-failure.ts +3 -2
- package/app/utils/app-favicon.ts +64 -0
- package/build/design-system.ts +58 -0
- package/build/external-links.ts +38 -0
- package/css/global.css +112 -71
- package/nuxt.config.ts +19 -26
- package/package.json +22 -14
- 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 +0 -31
- package/public/fonts/fira-code-OFL.txt +0 -93
- package/public/fonts/fira-code-var-latin.woff2 +0 -0
- package/public/fonts/hubot-sans-OFL.txt +0 -93
- 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
|
@@ -10,6 +10,7 @@ defineOptions({ inheritAttrs: false })
|
|
|
10
10
|
const {
|
|
11
11
|
type = 'text',
|
|
12
12
|
lines = 1,
|
|
13
|
+
count = 1,
|
|
13
14
|
index = 0,
|
|
14
15
|
base = 160,
|
|
15
16
|
range = 80,
|
|
@@ -20,10 +21,16 @@ const {
|
|
|
20
21
|
* decide the box — use it whenever the placeholder must match a known layout
|
|
21
22
|
* (a `w-full` row, a fixed-width label). The self-sizing types write an inline
|
|
22
23
|
* `width`/`height`, which silently beats any `w-*`/`h-*` class passed in.
|
|
24
|
+
*
|
|
25
|
+
* The presets draw a whole page-level loading shape: `table-row` (a name
|
|
26
|
+
* column and three value columns), `stat` (stat blocks in a grid), `chart`
|
|
27
|
+
* (16 bars), and `card` (two-column cards). `count` sets how many.
|
|
23
28
|
*/
|
|
24
|
-
type?: 'text' | 'bar' | 'circle' | 'block'
|
|
29
|
+
type?: 'text' | 'bar' | 'circle' | 'block' | 'table-row' | 'stat' | 'chart' | 'card'
|
|
25
30
|
/** Number of skeleton lines (text type only) */
|
|
26
31
|
lines?: number
|
|
32
|
+
/** Number of rows, stat blocks, or cards (presets only). The chart always draws 16 bars. */
|
|
33
|
+
count?: number
|
|
27
34
|
/** Seed index for deterministic width/height variation */
|
|
28
35
|
index?: number
|
|
29
36
|
/** Base size in px — text: width, bar: height percent, circle: diameter */
|
|
@@ -94,6 +101,48 @@ function barHeight(i: number): string {
|
|
|
94
101
|
class="ui-skeleton rounded-full"
|
|
95
102
|
:style="{ width: `${base}px`, height: `${base}px` }"
|
|
96
103
|
/>
|
|
104
|
+
|
|
105
|
+
<!-- Preset: table rows, a name column then three value columns -->
|
|
106
|
+
<div v-else-if="type === 'table-row'" aria-hidden="true" v-bind="attrs" class="space-y-2">
|
|
107
|
+
<div
|
|
108
|
+
v-for="i in count"
|
|
109
|
+
:key="i"
|
|
110
|
+
class="grid grid-cols-[minmax(120px,2fr)_repeat(3,minmax(56px,1fr))] items-center gap-3"
|
|
111
|
+
>
|
|
112
|
+
<div class="ui-skeleton rounded h-3 max-w-full" :style="{ width: textWidth(i) }" />
|
|
113
|
+
<div class="ui-skeleton rounded h-3 w-full" />
|
|
114
|
+
<div class="ui-skeleton rounded h-3 w-2/3 justify-self-end" />
|
|
115
|
+
<div class="ui-skeleton rounded h-3 w-1/2 justify-self-end" />
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<!-- Preset: stat blocks -->
|
|
120
|
+
<div v-else-if="type === 'stat'" aria-hidden="true" v-bind="attrs" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
121
|
+
<div v-for="i in count" :key="i" class="space-y-2 rounded-lg border border-default bg-muted p-3">
|
|
122
|
+
<div class="ui-skeleton rounded h-3 w-20" />
|
|
123
|
+
<div class="ui-skeleton rounded h-6 max-w-full" :style="{ width: textWidth(i + 10) }" />
|
|
124
|
+
<div class="ui-skeleton rounded h-3 w-24" />
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<!-- Preset: a bar chart -->
|
|
129
|
+
<div v-else-if="type === 'chart'" aria-hidden="true" v-bind="attrs" class="flex h-56 items-end gap-2">
|
|
130
|
+
<div
|
|
131
|
+
v-for="i in 16"
|
|
132
|
+
:key="i"
|
|
133
|
+
class="ui-skeleton rounded flex-1"
|
|
134
|
+
:style="{ height: barHeight(i) }"
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<!-- Preset: cards -->
|
|
139
|
+
<div v-else-if="type === 'card'" aria-hidden="true" v-bind="attrs" class="grid gap-3 sm:grid-cols-2">
|
|
140
|
+
<div v-for="i in count" :key="i" class="space-y-3 rounded-lg border border-default bg-muted p-4">
|
|
141
|
+
<div class="ui-skeleton rounded h-4 max-w-full" :style="{ width: textWidth(i) }" />
|
|
142
|
+
<div class="ui-skeleton rounded h-3 w-full" />
|
|
143
|
+
<div class="ui-skeleton rounded h-3 w-2/3" />
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
97
146
|
</template>
|
|
98
147
|
|
|
99
148
|
<!--
|
|
@@ -87,6 +87,12 @@ function captureChildTrigger(event: Event): void {
|
|
|
87
87
|
childTriggerElement.value = event.currentTarget
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// One object, not two `@` listeners: gscdump.com's TypeScript 7.1 native
|
|
91
|
+
// typecheck rejected `@pointermove` and `@focus` on reka's `TooltipTrigger`.
|
|
92
|
+
// Listeners bound through `v-bind` fall through to the rendered root the same
|
|
93
|
+
// way.
|
|
94
|
+
const childTriggerListeners = { onPointermove: captureChildTrigger, onFocus: captureChildTrigger }
|
|
95
|
+
|
|
90
96
|
// Span trigger. Reka listens for `focus` on the trigger itself, and focus does
|
|
91
97
|
// not bubble, so a focusable child (a button, a link, a tabindex cell) never
|
|
92
98
|
// opened the tooltip and the description landed on the span. Listen for
|
|
@@ -231,8 +237,7 @@ export const sizes = {
|
|
|
231
237
|
v-if="$slots.default && triggerAs === 'child'"
|
|
232
238
|
:reference="childTriggerElement"
|
|
233
239
|
as-child
|
|
234
|
-
|
|
235
|
-
@focus="captureChildTrigger"
|
|
240
|
+
v-bind="childTriggerListeners"
|
|
236
241
|
>
|
|
237
242
|
<slot />
|
|
238
243
|
</TooltipTrigger>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
3
|
import { UiIcon } from '#components'
|
|
4
|
+
import { readDesignSystemConfig } from '../../composables/design-system-config'
|
|
4
5
|
import { clamp } from '../../utils/number'
|
|
5
6
|
|
|
6
7
|
const {
|
|
@@ -10,7 +11,7 @@ const {
|
|
|
10
11
|
format = 'number',
|
|
11
12
|
showSign = false,
|
|
12
13
|
inverted = false,
|
|
13
|
-
colored =
|
|
14
|
+
colored = undefined,
|
|
14
15
|
isNew = false,
|
|
15
16
|
isLost = false,
|
|
16
17
|
autoLost = true,
|
|
@@ -29,7 +30,8 @@ const {
|
|
|
29
30
|
/**
|
|
30
31
|
* Trend color budget (DESIGN.md Composition): colored success/error trends
|
|
31
32
|
* belong to the hero zone only. Default renders neutral (sign + value);
|
|
32
|
-
* hero components (UiMetricsRow, UiMetricStat, hero cards) opt in.
|
|
33
|
+
* hero components (UiMetricsRow, UiMetricStat, hero cards) opt in. A site
|
|
34
|
+
* that colours every trend sets `designSystem.trend.colored` in app.config.
|
|
33
35
|
*/
|
|
34
36
|
colored?: boolean
|
|
35
37
|
/** Show a "NEW" badge instead of trend value */
|
|
@@ -51,15 +53,21 @@ const {
|
|
|
51
53
|
precision?: number | 'auto'
|
|
52
54
|
}>()
|
|
53
55
|
|
|
56
|
+
// `2xs` and `xs` read the type seam (`--ui-trend-2xs-size`, `--ui-trend-xs-size`).
|
|
54
57
|
const sizes = {
|
|
55
|
-
'2xs': '
|
|
56
|
-
'xs': '
|
|
58
|
+
'2xs': 'ui-trend-2xs',
|
|
59
|
+
'xs': 'ui-trend-xs',
|
|
57
60
|
'sm': 'text-sm',
|
|
58
61
|
'md': 'text-base',
|
|
59
62
|
'lg': 'text-lg',
|
|
60
63
|
'xl': 'text-xl',
|
|
61
64
|
} as const
|
|
62
65
|
|
|
66
|
+
// An unset prop takes the site's default. Nuxt SEO's app.config sets none, so
|
|
67
|
+
// its trends stay neutral (DESIGN.md, trend colour budget).
|
|
68
|
+
const siteColored = readDesignSystemConfig().trend?.colored ?? false
|
|
69
|
+
const isColored = computed(() => colored ?? siteColored)
|
|
70
|
+
|
|
63
71
|
const numericValue = computed(() => typeof value === 'string' ? Number.parseFloat(value) : value)
|
|
64
72
|
|
|
65
73
|
// Auto-detect "lost" state: percent format and value <= -100 means went to zero
|
|
@@ -78,7 +86,7 @@ const clampsValue = computed(() => shouldClamp && format === 'percent')
|
|
|
78
86
|
|
|
79
87
|
const trendColor = computed(() => {
|
|
80
88
|
const t = trend.value
|
|
81
|
-
if (t === 0 || !
|
|
89
|
+
if (t === 0 || !isColored.value)
|
|
82
90
|
return 'text-muted'
|
|
83
91
|
const positive = inverted ? t === -1 : t === 1
|
|
84
92
|
return positive ? 'text-success' : 'text-error'
|
|
@@ -144,11 +152,11 @@ const classes = computed(() => {
|
|
|
144
152
|
|
|
145
153
|
<template>
|
|
146
154
|
<!-- NEW badge mode -->
|
|
147
|
-
<span v-if="isNew" data-ui="UiTrend" class="inline-flex items-center font-medium uppercase px-1.5 py-0.5 rounded bg-success/10 text-success" :class="sizes[size as keyof typeof sizes]">
|
|
155
|
+
<span v-if="isNew" data-ui="UiTrend" class="inline-flex items-center font-medium [text-transform:var(--ui-trend-badge-case,uppercase)] px-1.5 py-0.5 rounded bg-success/10 text-success" :class="sizes[size as keyof typeof sizes]">
|
|
148
156
|
New
|
|
149
157
|
</span>
|
|
150
158
|
<!-- LOST badge mode (explicit or auto-detected from -100% change) -->
|
|
151
|
-
<span v-else-if="isLost || detectedLost" data-ui="UiTrend" class="inline-flex items-center font-medium uppercase px-1.5 py-0.5 rounded bg-error/10 text-error" :class="sizes[size as keyof typeof sizes]">
|
|
159
|
+
<span v-else-if="isLost || detectedLost" data-ui="UiTrend" class="inline-flex items-center font-medium [text-transform:var(--ui-trend-badge-case,uppercase)] px-1.5 py-0.5 rounded bg-error/10 text-error" :class="sizes[size as keyof typeof sizes]">
|
|
152
160
|
Lost
|
|
153
161
|
</span>
|
|
154
162
|
<!-- Zero trend — show dash -->
|
|
@@ -158,7 +166,7 @@ const classes = computed(() => {
|
|
|
158
166
|
v-else-if="isClamped"
|
|
159
167
|
data-ui="UiTrend"
|
|
160
168
|
class="inline-flex items-center whitespace-nowrap leading-none font-semibold tracking-tight px-1.5 py-0.5 ml-1 rounded cursor-default"
|
|
161
|
-
:class="[classes,
|
|
169
|
+
:class="[classes, isColored ? (trend === 1 ? 'bg-success/10' : 'bg-error/10') : 'bg-muted']"
|
|
162
170
|
:title="fullValue"
|
|
163
171
|
>
|
|
164
172
|
{{ trend === 1 ? '10x+' : 'oof' }}
|
|
@@ -44,6 +44,31 @@ export const ONBOARDING_QUESTIONS = {
|
|
|
44
44
|
intents: 'What do you want to do with Nuxt SEO Pro?',
|
|
45
45
|
} as const
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Every string `UiOnboardingAnswers` renders besides the option labels. COPY.md
|
|
49
|
+
* (Onboarding About you) owns the defaults. A site that extends the layer
|
|
50
|
+
* passes its own through the component's `copy` prop, one key or all of them.
|
|
51
|
+
*/
|
|
52
|
+
export interface OnboardingAnswersCopy {
|
|
53
|
+
roleQuestion: string
|
|
54
|
+
siteCountQuestion: string
|
|
55
|
+
seoExperienceQuestion: string
|
|
56
|
+
intentsQuestion: string
|
|
57
|
+
intentsHint: string
|
|
58
|
+
otherLabel: string
|
|
59
|
+
otherPlaceholder: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const ONBOARDING_ANSWERS_COPY: Readonly<OnboardingAnswersCopy> = {
|
|
63
|
+
roleQuestion: ONBOARDING_QUESTIONS.role,
|
|
64
|
+
siteCountQuestion: ONBOARDING_QUESTIONS.siteCount,
|
|
65
|
+
seoExperienceQuestion: ONBOARDING_QUESTIONS.seoExperience,
|
|
66
|
+
intentsQuestion: ONBOARDING_QUESTIONS.intents,
|
|
67
|
+
intentsHint: 'Pick any that fit.',
|
|
68
|
+
otherLabel: 'Anything else? Optional.',
|
|
69
|
+
otherPlaceholder: 'Tell us what you\'re after',
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
/** Label lookup for stored ids. An unknown id renders as itself. */
|
|
48
73
|
export function onboardingAnswerLabel(options: readonly { id: string, label: string }[], id: string): string {
|
|
49
74
|
return options.find(option => option.id === id)?.label ?? id
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FailureCopy, FailureCopyOverrides } from '../shared/failure-copy'
|
|
2
|
+
import { tryUseNuxtApp, useAppConfig } from 'nuxt/app'
|
|
3
|
+
import { resolveFailureCopy } from '../shared/failure-copy'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The runtime brand seams a site sets under `designSystem` in app.config.
|
|
7
|
+
* README (Brand seams) documents each key. Unset keys keep Nuxt SEO's
|
|
8
|
+
* behaviour, so the layer's own app.config sets none of them.
|
|
9
|
+
*/
|
|
10
|
+
export interface DesignSystemAppConfig {
|
|
11
|
+
/** `UiTrend`'s `colored` when a caller passes none. Default false. */
|
|
12
|
+
readonly trend?: { readonly colored?: boolean }
|
|
13
|
+
/** `UiAtmosphere`'s grain and `bloom` mask when a caller passes none. Default off. */
|
|
14
|
+
readonly atmosphere?: { readonly grain?: boolean, readonly bloomMask?: boolean }
|
|
15
|
+
/** Rewords the failure copy (`FailureCopyOverrides`). */
|
|
16
|
+
readonly failureCopy?: FailureCopyOverrides
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The site's `designSystem` app.config, or none outside a Nuxt app. Call it in setup. */
|
|
20
|
+
export function readDesignSystemConfig(): DesignSystemAppConfig {
|
|
21
|
+
if (!tryUseNuxtApp())
|
|
22
|
+
return {}
|
|
23
|
+
return (useAppConfig().designSystem ?? {}) as DesignSystemAppConfig
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The failure copy with the site's overrides applied. Call it in setup. */
|
|
27
|
+
export function readFailureCopy(): FailureCopy {
|
|
28
|
+
return resolveFailureCopy(readDesignSystemConfig().failureCopy)
|
|
29
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { VNodeChild } from 'vue'
|
|
1
2
|
import { useOverlay } from '#imports'
|
|
2
3
|
import UiConfirmDialog from '../components/element/UiConfirmDialog.vue'
|
|
3
4
|
|
|
@@ -13,6 +14,27 @@ export interface ConfirmOptions {
|
|
|
13
14
|
readonly confirmLabel: string
|
|
14
15
|
readonly cancelLabel?: string
|
|
15
16
|
readonly tone?: ConfirmTone
|
|
17
|
+
/** Rich content under the description: `() => h('p', [h('strong', site.name), ' stops syncing.'])`. */
|
|
18
|
+
readonly body?: () => VNodeChild
|
|
19
|
+
/**
|
|
20
|
+
* The work to run on confirm. The dialog shows its loading state until the
|
|
21
|
+
* action settles, then closes. A rejected action rejects the returned
|
|
22
|
+
* promise with the same error.
|
|
23
|
+
*/
|
|
24
|
+
readonly action?: () => Promise<unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** How the dialog closed. Nuxt UI's overlay hands it to `useConfirm()`. */
|
|
28
|
+
export type ConfirmResult
|
|
29
|
+
= | { readonly _tag: 'Confirmed' }
|
|
30
|
+
| { readonly _tag: 'Cancelled' }
|
|
31
|
+
| { readonly _tag: 'ActionFailed', readonly error: unknown }
|
|
32
|
+
|
|
33
|
+
/** Escape and a click outside close the overlay with no result. */
|
|
34
|
+
function toAnswer(result: ConfirmResult | undefined): boolean {
|
|
35
|
+
if (result?._tag === 'ActionFailed')
|
|
36
|
+
throw result.error
|
|
37
|
+
return result?._tag === 'Confirmed'
|
|
16
38
|
}
|
|
17
39
|
|
|
18
40
|
/**
|
|
@@ -25,8 +47,14 @@ export interface ConfirmOptions {
|
|
|
25
47
|
* ```
|
|
26
48
|
*
|
|
27
49
|
* Resolves `true` only for the confirm button. Cancel, Escape, and a click
|
|
28
|
-
* outside resolve `false`.
|
|
29
|
-
*
|
|
50
|
+
* outside resolve `false`. Pass `action` to run the work inside the dialog:
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* await confirm({ title: 'Remove Site?', confirmLabel: 'Remove', action: () => removeSite(id) })
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Call it in `setup`; the returned function may run any time later. Needs
|
|
57
|
+
* `<UApp>`, which mounts Nuxt UI's overlay provider.
|
|
30
58
|
*/
|
|
31
59
|
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
|
|
32
60
|
const overlay = useOverlay()
|
|
@@ -34,5 +62,5 @@ export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
|
|
|
34
62
|
// a page that asks many times holds no dialog it is not showing.
|
|
35
63
|
return options => overlay.create(UiConfirmDialog, { destroyOnClose: true })
|
|
36
64
|
.open({ ...options })
|
|
37
|
-
.then(result => result
|
|
65
|
+
.then(result => toAnswer(result as ConfirmResult | undefined))
|
|
38
66
|
}
|
package/app/plugins/favicon.ts
CHANGED
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
import { defineNuxtPlugin, useAppConfig, useHead } from 'nuxt/app'
|
|
2
2
|
import { useColorMode } from '#imports'
|
|
3
|
+
import { appFaviconLinks } from '../utils/app-favicon'
|
|
3
4
|
|
|
5
|
+
// Emits the favicon and touch icon links that app.config's `favicon` names,
|
|
6
|
+
// and swaps them with the colour mode. The layer ships no brand images, so an
|
|
7
|
+
// app that sets no `favicon` gets no links from here.
|
|
4
8
|
export default defineNuxtPlugin(() => {
|
|
5
9
|
const { favicon } = useAppConfig()
|
|
6
10
|
const colorMode = useColorMode()
|
|
7
11
|
|
|
8
|
-
useHead(() => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return {
|
|
12
|
-
link: [
|
|
13
|
-
{
|
|
14
|
-
id: 'favicon',
|
|
15
|
-
rel: 'icon',
|
|
16
|
-
type: 'image/png',
|
|
17
|
-
sizes: '32x32',
|
|
18
|
-
href: dark ? favicon.dark : favicon.light,
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
id: 'apple-touch-icon',
|
|
22
|
-
rel: 'apple-touch-icon',
|
|
23
|
-
sizes: '180x180',
|
|
24
|
-
href: dark ? favicon.appleTouchDark : favicon.appleTouchLight,
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
}
|
|
28
|
-
})
|
|
12
|
+
useHead(() => ({
|
|
13
|
+
link: appFaviconLinks(favicon, colorMode.value === 'dark'),
|
|
14
|
+
}))
|
|
29
15
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { FailureCopy } from './failure-copy'
|
|
1
2
|
import type { UiIcon } from './ui-icons'
|
|
2
|
-
import {
|
|
3
|
+
import { FAILURE_COPY } from './failure-copy'
|
|
4
|
+
import { toWidgetFailure } from './widget-failure'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* What `UiErrorState` is allowed to say, and where the words came from.
|
|
@@ -87,8 +89,6 @@ export interface ErrorStateView {
|
|
|
87
89
|
readonly retryable: boolean
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
const GENERIC_NEXT_STEP = 'Try again in a moment. If it keeps happening, contact support.'
|
|
91
|
-
|
|
92
92
|
function resetHint(source: { resetAt?: Date, retryAfter?: number }, now: number): string | null {
|
|
93
93
|
if (source.retryAfter)
|
|
94
94
|
return `Try again in ${source.retryAfter} seconds`
|
|
@@ -108,16 +108,17 @@ function resetHint(source: { resetAt?: Date, retryAfter?: number }, now: number)
|
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
110
|
* Turn a source into the words on screen. Pure: pass the clock in, so the
|
|
111
|
-
* reset hint is testable and the component owns no time.
|
|
111
|
+
* reset hint is testable and the component owns no time. Pass `copy` to use a
|
|
112
|
+
* site's reworded failures (`readFailureCopy()`).
|
|
112
113
|
*/
|
|
113
|
-
export function resolveErrorState(source: ErrorStateSource, now: number): ErrorStateView {
|
|
114
|
+
export function resolveErrorState(source: ErrorStateSource, now: number, copy: FailureCopy = FAILURE_COPY): ErrorStateView {
|
|
114
115
|
if (source._tag === 'copy') {
|
|
115
116
|
return {
|
|
116
117
|
tone: 'error',
|
|
117
118
|
icon: 'caution',
|
|
118
119
|
title: source.title,
|
|
119
120
|
detail: source.detail ?? null,
|
|
120
|
-
nextStep: { _tag: 'generic', text:
|
|
121
|
+
nextStep: { _tag: 'generic', text: copy.nextStep },
|
|
121
122
|
retryable: true,
|
|
122
123
|
}
|
|
123
124
|
}
|
|
@@ -127,24 +128,24 @@ export function resolveErrorState(source: ErrorStateSource, now: number): ErrorS
|
|
|
127
128
|
return {
|
|
128
129
|
tone: 'warning',
|
|
129
130
|
icon: 'clock',
|
|
130
|
-
title:
|
|
131
|
+
title: copy.failures['rate-limited'].title,
|
|
131
132
|
detail: source.detail,
|
|
132
133
|
nextStep: hint
|
|
133
134
|
? { _tag: 'timing', text: hint }
|
|
134
|
-
: { _tag: 'generic', text:
|
|
135
|
+
: { _tag: 'generic', text: copy.failures['rate-limited'].body },
|
|
135
136
|
retryable: true,
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
const failure = toWidgetFailure(source.error)
|
|
140
|
-
const
|
|
141
|
+
const failureCopy = copy.failures[failure._tag]
|
|
141
142
|
const limited = failure._tag === 'rate-limited'
|
|
142
143
|
return {
|
|
143
144
|
tone: limited ? 'warning' : 'error',
|
|
144
145
|
icon: limited ? 'clock' : 'caution',
|
|
145
|
-
title:
|
|
146
|
-
detail:
|
|
146
|
+
title: failureCopy.title,
|
|
147
|
+
detail: failureCopy.body,
|
|
147
148
|
nextStep: null,
|
|
148
|
-
retryable:
|
|
149
|
+
retryable: failureCopy.retryable,
|
|
149
150
|
}
|
|
150
151
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { WidgetFailureCopy, WidgetFailureTag } from './widget-failure'
|
|
2
|
+
import { WIDGET_FAILURE_COPY } from './widget-failure'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every string the failure surfaces render: `UiWidgetState`, `UiDataList` and
|
|
6
|
+
* `UiErrorState`. COPY.md (Shared primitive assets) owns the defaults. A site
|
|
7
|
+
* that extends the layer rewords them through `designSystem.failureCopy` in its
|
|
8
|
+
* app.config. Whether a failure is retryable is behaviour, so no override
|
|
9
|
+
* reaches it.
|
|
10
|
+
*/
|
|
11
|
+
export interface FailureCopy {
|
|
12
|
+
readonly failures: Readonly<Record<WidgetFailureTag, WidgetFailureCopy>>
|
|
13
|
+
/** The retry button on `UiWidgetState` and `UiDataList`. */
|
|
14
|
+
readonly retryLabel: string
|
|
15
|
+
/** The retry button on `UiErrorState`. */
|
|
16
|
+
readonly errorStateRetryLabel: string
|
|
17
|
+
/** The next-step line `UiErrorState` shows under copy a caller wrote. */
|
|
18
|
+
readonly nextStep: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a site may reword. Each key it leaves out keeps the default. */
|
|
22
|
+
export interface FailureCopyOverrides {
|
|
23
|
+
readonly failures?: { readonly [K in WidgetFailureTag]?: Partial<Pick<WidgetFailureCopy, 'title' | 'body'>> }
|
|
24
|
+
readonly retryLabel?: string
|
|
25
|
+
readonly errorStateRetryLabel?: string
|
|
26
|
+
readonly nextStep?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const FAILURE_COPY: FailureCopy = {
|
|
30
|
+
failures: WIDGET_FAILURE_COPY,
|
|
31
|
+
retryLabel: 'Retry',
|
|
32
|
+
errorStateRetryLabel: 'Try again',
|
|
33
|
+
nextStep: 'Try again in a moment. If it keeps happening, contact support.',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const FAILURE_TAGS = Object.keys(WIDGET_FAILURE_COPY) as WidgetFailureTag[]
|
|
37
|
+
|
|
38
|
+
/** Lay a site's overrides over the defaults. Pure, so a test passes its own. */
|
|
39
|
+
export function resolveFailureCopy(overrides: FailureCopyOverrides | undefined): FailureCopy {
|
|
40
|
+
if (!overrides)
|
|
41
|
+
return FAILURE_COPY
|
|
42
|
+
const failures = Object.fromEntries(FAILURE_TAGS.map((tag) => {
|
|
43
|
+
const base = FAILURE_COPY.failures[tag]
|
|
44
|
+
const own = overrides.failures?.[tag]
|
|
45
|
+
return [tag, { title: own?.title ?? base.title, body: own?.body ?? base.body, retryable: base.retryable }]
|
|
46
|
+
})) as Record<WidgetFailureTag, WidgetFailureCopy>
|
|
47
|
+
return {
|
|
48
|
+
failures,
|
|
49
|
+
retryLabel: overrides.retryLabel ?? FAILURE_COPY.retryLabel,
|
|
50
|
+
errorStateRetryLabel: overrides.errorStateRetryLabel ?? FAILURE_COPY.errorStateRetryLabel,
|
|
51
|
+
nextStep: overrides.nextStep ?? FAILURE_COPY.nextStep,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SlotTextOptions } from '../utils/slot-text'
|
|
2
|
+
import type { UiIcon as UiIconName } from './ui-icons'
|
|
3
|
+
|
|
4
|
+
// `UiStat`'s props. They live in this module, not the SFC, so a consumer can
|
|
5
|
+
// name them in its own `defineProps`: the Vue SFC compiler resolves a type
|
|
6
|
+
// from a package `.ts` file and not from a package `.vue` file.
|
|
7
|
+
|
|
8
|
+
type Datum = Record<string, number | string>
|
|
9
|
+
|
|
10
|
+
export interface UiStatProps {
|
|
11
|
+
// link
|
|
12
|
+
to?: string
|
|
13
|
+
|
|
14
|
+
// label
|
|
15
|
+
icon?: UiIconName
|
|
16
|
+
title?: string
|
|
17
|
+
tooltip?: string
|
|
18
|
+
tooltipDescription?: string
|
|
19
|
+
|
|
20
|
+
// value
|
|
21
|
+
value?: string | number | null
|
|
22
|
+
suffix?: string
|
|
23
|
+
valueClass?: string
|
|
24
|
+
format?: (n: number) => string
|
|
25
|
+
|
|
26
|
+
// trend
|
|
27
|
+
trend?: number | null
|
|
28
|
+
/**
|
|
29
|
+
* Formats the trend magnitude. The value `format` never applies: the trend is
|
|
30
|
+
* a percent or a delta, so a currency formatter printed "+$12.5%".
|
|
31
|
+
*/
|
|
32
|
+
trendFormat?: (n: number) => string
|
|
33
|
+
trendSuffix?: string
|
|
34
|
+
trendLabel?: string
|
|
35
|
+
invertTrend?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Trend color budget (DESIGN.md Composition): colored success/error trends
|
|
38
|
+
* belong to the hero zone only. Default renders neutral; `UiStats
|
|
39
|
+
* variant="cards"` (the hero pattern) opts its stats in automatically.
|
|
40
|
+
*/
|
|
41
|
+
trendColored?: boolean
|
|
42
|
+
|
|
43
|
+
// sparkline
|
|
44
|
+
sparkline?: Datum[]
|
|
45
|
+
|
|
46
|
+
// sizing
|
|
47
|
+
size?: 'sm' | 'md' | 'lg'
|
|
48
|
+
/** Keep report-visible labels and deltas at the 14px readable tier. */
|
|
49
|
+
readableLabels?: boolean
|
|
50
|
+
|
|
51
|
+
// state
|
|
52
|
+
loading?: boolean
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rolls compact metric values when they change. Defaults on for numeric-ish
|
|
56
|
+
* values; pass false for dense/realtime surfaces or object options to tune.
|
|
57
|
+
*/
|
|
58
|
+
animatedValue?: boolean | SlotTextOptions
|
|
59
|
+
|
|
60
|
+
// threshold alerting
|
|
61
|
+
status?: 'crisis' | 'warning' | 'good'
|
|
62
|
+
|
|
63
|
+
// render the value area inside a card, keeping the header outside
|
|
64
|
+
card?: boolean
|
|
65
|
+
}
|
package/app/shared/table.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ColumnDef } from '@tanstack/table-core'
|
|
1
|
+
import type { Cell, ColumnDef, Row, RowSelectionState } from '@tanstack/table-core'
|
|
2
2
|
import {
|
|
3
3
|
columnFilteringFeature,
|
|
4
4
|
columnOrderingFeature,
|
|
@@ -103,3 +103,76 @@ export const uiTableVisibleFromClass: Record<UiTableVisibleFrom, string> = {
|
|
|
103
103
|
'xl': 'hidden xl:table-cell',
|
|
104
104
|
'2xl': 'hidden 2xl:table-cell',
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `UiTable`'s props. They live in this module, not the SFC, so a consumer that
|
|
109
|
+
* wraps the table can name them in its own `defineProps`. The Vue SFC compiler
|
|
110
|
+
* resolves a type from a package `.ts` file; it cannot resolve one from a
|
|
111
|
+
* package `.vue` file.
|
|
112
|
+
*/
|
|
113
|
+
export interface UiTableProps<T extends object> {
|
|
114
|
+
data: T[]
|
|
115
|
+
columns: UiTableColumn<T>[]
|
|
116
|
+
selected?: RowSelectionState
|
|
117
|
+
controlledSelection?: boolean
|
|
118
|
+
rowHover?: boolean
|
|
119
|
+
rowClickable?: boolean
|
|
120
|
+
enableSorting?: boolean
|
|
121
|
+
/** Caller owns sort state; UiTable emits @sortColumn and bypasses client-side sorting. */
|
|
122
|
+
manualSorting?: boolean
|
|
123
|
+
pageSize?: number
|
|
124
|
+
ignoreHeader?: boolean
|
|
125
|
+
size?: UiTableSize
|
|
126
|
+
loading?: boolean
|
|
127
|
+
loadingRows?: number
|
|
128
|
+
rowId?: UiTableRowId<T>
|
|
129
|
+
manualPagination?: boolean
|
|
130
|
+
disablePagination?: boolean
|
|
131
|
+
total?: number
|
|
132
|
+
/** Accessible name for the table. Rendered as a visually-hidden <caption>. */
|
|
133
|
+
label: string
|
|
134
|
+
/** Draw the canonical rounded table surface. */
|
|
135
|
+
bordered?: boolean
|
|
136
|
+
/** Pagination density. Compact renders previous, page position, and next only. */
|
|
137
|
+
paginationPreset?: 'default' | 'compact'
|
|
138
|
+
/** Fill the caller's available height and keep pagination pinned to the bottom. */
|
|
139
|
+
fill?: boolean
|
|
140
|
+
/** Extra classes applied per data row — e.g. a selection highlight. */
|
|
141
|
+
rowClass?: (row: T) => string
|
|
142
|
+
/** Row id presented as the active item in a click-through table. */
|
|
143
|
+
activeRowId?: string
|
|
144
|
+
/** Row id to open when the table first receives deep-linked evidence. */
|
|
145
|
+
initialExpandedRowId?: string
|
|
146
|
+
/**
|
|
147
|
+
* Show Export CSV and Export JSON, saving to this file name without the
|
|
148
|
+
* extension. The export holds every filtered and sorted row, not one page.
|
|
149
|
+
* Ignored with `manualPagination`: mount `UiTableExport` with every row.
|
|
150
|
+
*/
|
|
151
|
+
exportFilename?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What a `#<column id>-cell` slot receives: the TanStack row and cell, and the cell's value. */
|
|
155
|
+
export interface UiTableCellSlotProps<T extends object> {
|
|
156
|
+
row: Row<UiTableFeatures, T>
|
|
157
|
+
cell: Cell<UiTableFeatures, T, unknown>
|
|
158
|
+
value: unknown
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `UiTable`'s slots. A `#<column id>-cell` slot draws that column's cells and
|
|
163
|
+
* wins over the column's `cell` renderer. A column with no slot keeps its
|
|
164
|
+
* renderer. `#empty` replaces the zero-rows line.
|
|
165
|
+
*/
|
|
166
|
+
export type UiTableSlots<T extends object> = {
|
|
167
|
+
[K in `${string}-cell`]?: (props: UiTableCellSlotProps<T>) => unknown
|
|
168
|
+
} & {
|
|
169
|
+
'empty'?: () => unknown
|
|
170
|
+
'expanded-component'?: (props: { row: T }) => unknown
|
|
171
|
+
'actions'?: (props: { row: T }) => unknown
|
|
172
|
+
'tfoot'?: () => unknown
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The slot name that draws one column's cells. */
|
|
176
|
+
export function uiTableCellSlotName(columnId: string): `${string}-cell` {
|
|
177
|
+
return `${columnId}-cell`
|
|
178
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
import type { SemanticStatus } from '../composables/semanticColors'
|
|
3
|
+
|
|
4
|
+
// `UiToast` and `UiToaster` props. They live in this module, not the SFCs, so
|
|
5
|
+
// a consumer can name them in its own `defineProps`: the Vue SFC compiler
|
|
6
|
+
// resolves a type from a package `.ts` file and not from a package `.vue` file.
|
|
7
|
+
|
|
8
|
+
export interface ToastAction {
|
|
9
|
+
label: string
|
|
10
|
+
icon?: string
|
|
11
|
+
to?: string
|
|
12
|
+
onClick?: (e: MouseEvent) => void
|
|
13
|
+
/** Visual emphasis. `primary` = filled, `neutral` = quiet. */
|
|
14
|
+
color?: 'primary' | 'neutral'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UiToastProps {
|
|
18
|
+
/** Heading text (string or a render component). */
|
|
19
|
+
title?: string | Component
|
|
20
|
+
/** Body text (string or a render component). */
|
|
21
|
+
description?: string | Component
|
|
22
|
+
/** Semantic status — drives accent + default icon. Wins over `color`. */
|
|
23
|
+
status?: SemanticStatus
|
|
24
|
+
/** Nuxt UI color compatibility for existing `toast.add({ color })` call sites. */
|
|
25
|
+
color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
|
|
26
|
+
/** Icon override. Defaults to the status icon. */
|
|
27
|
+
icon?: string
|
|
28
|
+
/** Inline action buttons. */
|
|
29
|
+
actions?: ToastAction[]
|
|
30
|
+
/**
|
|
31
|
+
* Bug-report handler. When set, failure toasts (error/warning) that carry no
|
|
32
|
+
* action of their own get a "Report bug" button. Wired by the app (the
|
|
33
|
+
* design system must not reach for the feedback drawer itself).
|
|
34
|
+
*/
|
|
35
|
+
reportBug?: (toast: { title?: string, description?: string }) => void
|
|
36
|
+
/** Show the close button. */
|
|
37
|
+
close?: boolean
|
|
38
|
+
/** Auto-dismiss progress 0..1 (remaining), or false to hide the bar. */
|
|
39
|
+
progress?: number | false
|
|
40
|
+
/** Collapsed back card — fade content so the deck reads as peeking cards. */
|
|
41
|
+
peek?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UiToasterProps {
|
|
45
|
+
position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
|
|
46
|
+
/** Max cards rendered in the deck (older ones collapse into a "+N" pill). */
|
|
47
|
+
max?: number
|
|
48
|
+
/** Default auto-dismiss in ms; a per-toast `duration` overrides. 0 = sticky. */
|
|
49
|
+
duration?: number
|
|
50
|
+
/** Passed to every card: adds "Report bug" to failure toasts. See UiToast. */
|
|
51
|
+
reportBug?: UiToastProps['reportBug']
|
|
52
|
+
}
|
|
@@ -121,6 +121,7 @@ export function toWidgetFailure(error: unknown): WidgetFailure {
|
|
|
121
121
|
return { _tag: 'unknown' }
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
/** The copy for a caught value. Pass `copy` to use a site's reworded failures. */
|
|
125
|
+
export function widgetFailureCopy(error: unknown, copy: Readonly<Record<WidgetFailureTag, WidgetFailureCopy>> = WIDGET_FAILURE_COPY): WidgetFailureCopy {
|
|
126
|
+
return copy[toWidgetFailure(error)._tag]
|
|
126
127
|
}
|