@nuxtseo/design-system 0.1.1 → 0.1.3
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 +83 -17
- 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/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 +78 -43
- 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/composables/design-system-config.ts +29 -0
- package/app/composables/useConfirm.ts +31 -3
- 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/build/design-system.ts +48 -12
- package/css/global.css +46 -9
- package/nuxt.config.ts +2 -20
- package/package.json +6 -5
- package/build/fonts.ts +0 -39
- package/public/fonts/README.md +0 -33
- 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
|
@@ -10,10 +10,16 @@ const {
|
|
|
10
10
|
numeric = false,
|
|
11
11
|
rowHeader = false,
|
|
12
12
|
visibleFrom,
|
|
13
|
+
colspan,
|
|
14
|
+
title,
|
|
13
15
|
} = defineProps<UiTableCellProps & {
|
|
14
16
|
size?: UiTableSize
|
|
15
17
|
noPadding?: boolean
|
|
16
18
|
rowHeader?: boolean
|
|
19
|
+
/** Columns this cell spans, e.g. a full-width note row. */
|
|
20
|
+
colspan?: number | string
|
|
21
|
+
/** Native tooltip, e.g. the full value behind a truncated cell. */
|
|
22
|
+
title?: string
|
|
17
23
|
}>()
|
|
18
24
|
|
|
19
25
|
const resolvedAlign = computed(() => numeric ? 'right' : align)
|
|
@@ -25,7 +31,9 @@ const padClass = computed(() => noPadding ? '' : 'px-3')
|
|
|
25
31
|
<component
|
|
26
32
|
:is="rowHeader ? 'th' : 'td'"
|
|
27
33
|
:scope="rowHeader ? 'row' : undefined"
|
|
28
|
-
|
|
34
|
+
:colspan="colspan"
|
|
35
|
+
:title="title"
|
|
36
|
+
class="ui-table-cell font-normal text-default relative"
|
|
29
37
|
:class="[
|
|
30
38
|
uiTableCellSizeClass[size],
|
|
31
39
|
padClass,
|
|
@@ -12,11 +12,17 @@ const {
|
|
|
12
12
|
sortDirection = false,
|
|
13
13
|
ariaLabel,
|
|
14
14
|
visibleFrom,
|
|
15
|
+
colspan,
|
|
16
|
+
title,
|
|
15
17
|
} = defineProps<UiTableCellProps & {
|
|
16
18
|
sortable?: boolean
|
|
17
19
|
noPadding?: boolean
|
|
18
20
|
sortDirection?: 'asc' | 'desc' | false
|
|
19
21
|
ariaLabel?: string
|
|
22
|
+
/** Columns this header spans, e.g. a group header over two metric columns. */
|
|
23
|
+
colspan?: number | string
|
|
24
|
+
/** Native tooltip, e.g. what a terse column label measures. */
|
|
25
|
+
title?: string
|
|
20
26
|
}>()
|
|
21
27
|
|
|
22
28
|
const emit = defineEmits<{ sort: [event: MouseEvent] }>()
|
|
@@ -39,14 +45,16 @@ const sortIconClass = computed(() => sortDirection === 'desc' ? 'rotate-180' : '
|
|
|
39
45
|
<template>
|
|
40
46
|
<th
|
|
41
47
|
scope="col"
|
|
42
|
-
|
|
48
|
+
:colspan="colspan"
|
|
49
|
+
:title="title"
|
|
50
|
+
class="text-label ui-table-head text-left whitespace-nowrap border-b border-default bg-default"
|
|
43
51
|
:class="[padClass, numeric ? 'text-right tabular-nums' : '', visibleFrom ? uiTableVisibleFromClass[visibleFrom] : '']"
|
|
44
52
|
:aria-sort="sortable ? sortDirection === 'asc' ? 'ascending' : sortDirection === 'desc' ? 'descending' : 'none' : undefined"
|
|
45
53
|
>
|
|
46
54
|
<button
|
|
47
55
|
v-if="sortable"
|
|
48
56
|
type="button"
|
|
49
|
-
class="flex min-h-11 w-full items-center gap-1 rounded px-3 py-2.5 text-label text-muted transition-colors hover:text-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0"
|
|
57
|
+
class="flex min-h-11 w-full items-center gap-1 rounded px-3 py-2.5 text-label ui-table-head text-muted transition-colors hover:text-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0"
|
|
50
58
|
:class="justify"
|
|
51
59
|
:aria-label="ariaLabel"
|
|
52
60
|
@click="emit('sort', $event)"
|
|
@@ -37,7 +37,7 @@ const sortIcon = computed(() => {
|
|
|
37
37
|
// Chevron rotates 180° between asc/desc — a single icon animates rather than swapping.
|
|
38
38
|
const sortIconRotate = computed(() => sortDirection === 'desc' ? 'rotate-180' : '')
|
|
39
39
|
|
|
40
|
-
const buttonClass = 'flex min-h-11 items-center gap-1 w-full text-label cursor-pointer select-none transition-colors rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0'
|
|
40
|
+
const buttonClass = 'flex min-h-11 items-center gap-1 w-full text-label ui-table-head cursor-pointer select-none transition-colors rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0'
|
|
41
41
|
</script>
|
|
42
42
|
|
|
43
43
|
<template>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
|
+
import { readDesignSystemConfig } from '../../composables/design-system-config'
|
|
3
4
|
// The greydient application primitive. Turns intent into a correctly-rendered
|
|
4
|
-
// atmospheric layer — palette × geometry × intensity, plus grain — with the
|
|
5
|
+
// atmospheric layer — palette × geometry × intensity, plus opt-in grain — with the
|
|
5
6
|
// mask/opacity/mode-handling all centralized in global.css (`.ui-atmosphere`).
|
|
6
7
|
// Consumers place it as the first child of a `relative` container (content then
|
|
7
8
|
// sits above it); for `sky` it spans the top of the nearest positioned ancestor.
|
|
@@ -18,7 +19,8 @@ const {
|
|
|
18
19
|
palette: paletteProp,
|
|
19
20
|
geometry: geometryProp,
|
|
20
21
|
intensity: intensityProp,
|
|
21
|
-
grain =
|
|
22
|
+
grain = undefined,
|
|
23
|
+
bloomMask = undefined,
|
|
22
24
|
preset,
|
|
23
25
|
} = defineProps<{
|
|
24
26
|
/** Which greydient — the day-cycle of visibility states. */
|
|
@@ -27,8 +29,17 @@ const {
|
|
|
27
29
|
geometry?: Geometry
|
|
28
30
|
/** How present, per mode. subtle behind text; vivid is reserved for tiny identity surfaces. */
|
|
29
31
|
intensity?: Intensity
|
|
30
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Fine grain for tooth. Unset, it follows `designSystem.atmosphere.grain` in
|
|
34
|
+
* app.config, which is off unless a site turns it on.
|
|
35
|
+
*/
|
|
31
36
|
grain?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Fade the `bloom` geometry to a centred glow. Unset, it follows
|
|
39
|
+
* `designSystem.atmosphere.bloomMask` in app.config, which is off unless a
|
|
40
|
+
* site turns it on. Other geometries keep their own masks.
|
|
41
|
+
*/
|
|
42
|
+
bloomMask?: boolean
|
|
32
43
|
/** Semantic shortcut — resolves palette+geometry+intensity from one intent. Explicit props win. */
|
|
33
44
|
preset?: Preset
|
|
34
45
|
}>()
|
|
@@ -50,6 +61,12 @@ const base = computed(() => (preset ? PRESETS[preset] : { palette: 'ash' as Pale
|
|
|
50
61
|
const palette = computed(() => paletteProp ?? base.value.palette)
|
|
51
62
|
const geometry = computed(() => geometryProp ?? base.value.geometry)
|
|
52
63
|
const intensity = computed(() => intensityProp ?? base.value.intensity)
|
|
64
|
+
|
|
65
|
+
// Grain and the bloom mask are brand choices, so a site opts in once. Nuxt
|
|
66
|
+
// SEO's `layers/brand` app.config turns both on.
|
|
67
|
+
const siteAtmosphere = readDesignSystemConfig().atmosphere
|
|
68
|
+
const hasGrain = computed(() => grain ?? siteAtmosphere?.grain ?? false)
|
|
69
|
+
const hasBloomMask = computed(() => bloomMask ?? siteAtmosphere?.bloomMask ?? false)
|
|
53
70
|
</script>
|
|
54
71
|
|
|
55
72
|
<template>
|
|
@@ -58,7 +75,8 @@ const intensity = computed(() => intensityProp ?? base.value.intensity)
|
|
|
58
75
|
class="ui-atmosphere"
|
|
59
76
|
:data-geometry="geometry"
|
|
60
77
|
:data-intensity="intensity"
|
|
61
|
-
:data-grain="
|
|
78
|
+
:data-grain="hasGrain ? '' : undefined"
|
|
79
|
+
:data-bloom-mask="geometry === 'bloom' && hasBloomMask ? '' : undefined"
|
|
62
80
|
:style="{ '--_atmo-greydient': `var(--greydient-${palette})` }"
|
|
63
81
|
/>
|
|
64
82
|
</template>
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type { VNodeChild } from 'vue'
|
|
3
|
+
import type { ConfirmResult, ConfirmTone } from '../../composables/useConfirm'
|
|
4
|
+
import { ref } from 'vue'
|
|
3
5
|
import { UiButton, UiIcon, UModal } from '#components'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* UiConfirmDialog: the one "are you sure" for an action that is hard to undo.
|
|
7
9
|
*
|
|
8
10
|
* Open it through `useConfirm()`, which resolves `true` for the confirm button
|
|
9
|
-
* and `false` for Cancel, Escape, or a click outside.
|
|
10
|
-
*
|
|
11
|
+
* and `false` for Cancel, Escape, or a click outside. With an `action`, the
|
|
12
|
+
* confirm button runs it and shows its loading state until it settles, and
|
|
13
|
+
* the dialog cannot be dismissed meanwhile. Then the dialog closes; a failed
|
|
14
|
+
* action rejects the `useConfirm()` promise with its error.
|
|
15
|
+
*
|
|
16
|
+
* The `#body` slot, or the `body` render function from `useConfirm()`, holds
|
|
17
|
+
* rich content under the description, such as a bold Site name.
|
|
11
18
|
*
|
|
12
19
|
* Focus lands on Cancel for a `danger` tone, so Enter on a stray keypress
|
|
13
20
|
* cancels. A `default` tone focuses the confirm button.
|
|
@@ -18,6 +25,8 @@ const {
|
|
|
18
25
|
confirmLabel,
|
|
19
26
|
cancelLabel = 'Cancel',
|
|
20
27
|
tone = 'danger',
|
|
28
|
+
body,
|
|
29
|
+
action,
|
|
21
30
|
} = defineProps<{
|
|
22
31
|
/** A question that names the action and its object: "Disconnect Google?" */
|
|
23
32
|
title: string
|
|
@@ -27,13 +36,43 @@ const {
|
|
|
27
36
|
confirmLabel: string
|
|
28
37
|
cancelLabel?: string
|
|
29
38
|
tone?: ConfirmTone
|
|
39
|
+
/** Rich content under the description. The `#body` slot wins over it. */
|
|
40
|
+
body?: () => VNodeChild
|
|
41
|
+
/** Runs on confirm while the confirm button shows its loading state. */
|
|
42
|
+
action?: () => Promise<unknown>
|
|
30
43
|
}>()
|
|
31
44
|
|
|
32
45
|
const emit = defineEmits<{
|
|
33
|
-
/**
|
|
34
|
-
close: [
|
|
46
|
+
/** Nuxt UI's overlay resolves the open promise with this result. */
|
|
47
|
+
close: [result: ConfirmResult]
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
defineSlots<{
|
|
51
|
+
body?: () => unknown
|
|
35
52
|
}>()
|
|
36
53
|
|
|
54
|
+
const pending = ref(false)
|
|
55
|
+
|
|
56
|
+
async function confirm(): Promise<void> {
|
|
57
|
+
if (!action) {
|
|
58
|
+
emit('close', { _tag: 'Confirmed' })
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
pending.value = true
|
|
62
|
+
// Promise.resolve().then() turns a synchronous throw into a rejection, so
|
|
63
|
+
// the dialog always leaves its loading state.
|
|
64
|
+
const result = await Promise.resolve().then(action).then(
|
|
65
|
+
(): ConfirmResult => ({ _tag: 'Confirmed' }),
|
|
66
|
+
(error: unknown): ConfirmResult => ({ _tag: 'ActionFailed', error }),
|
|
67
|
+
)
|
|
68
|
+
pending.value = false
|
|
69
|
+
emit('close', result)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Body() {
|
|
73
|
+
return body?.()
|
|
74
|
+
}
|
|
75
|
+
|
|
37
76
|
// Reka focuses the first focusable element unless this handler moves focus.
|
|
38
77
|
// Pick the button by tone instead of trusting the DOM order.
|
|
39
78
|
function onOpenAutoFocus(event: Event): void {
|
|
@@ -51,6 +90,7 @@ function onOpenAutoFocus(event: Event): void {
|
|
|
51
90
|
:title="title"
|
|
52
91
|
:description="description"
|
|
53
92
|
:close="false"
|
|
93
|
+
:dismissible="!pending"
|
|
54
94
|
:content="{ onOpenAutoFocus }"
|
|
55
95
|
:ui="{ content: 'sm:max-w-md', description: 'text-pretty' }"
|
|
56
96
|
>
|
|
@@ -62,6 +102,11 @@ function onOpenAutoFocus(event: Event): void {
|
|
|
62
102
|
{{ title }}
|
|
63
103
|
</span>
|
|
64
104
|
</template>
|
|
105
|
+
<template v-if="$slots.body || body" #body>
|
|
106
|
+
<slot name="body">
|
|
107
|
+
<Body />
|
|
108
|
+
</slot>
|
|
109
|
+
</template>
|
|
65
110
|
<template #footer>
|
|
66
111
|
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
67
112
|
<UiButton
|
|
@@ -69,14 +114,16 @@ function onOpenAutoFocus(event: Event): void {
|
|
|
69
114
|
class="min-h-11 sm:min-h-0"
|
|
70
115
|
data-confirm-focus="cancel"
|
|
71
116
|
:label="cancelLabel"
|
|
72
|
-
|
|
117
|
+
:disabled="pending"
|
|
118
|
+
@click="emit('close', { _tag: 'Cancelled' })"
|
|
73
119
|
/>
|
|
74
120
|
<UiButton
|
|
75
121
|
:purpose="tone === 'danger' ? 'danger' : 'cta'"
|
|
76
122
|
class="min-h-11 sm:min-h-0"
|
|
77
123
|
data-confirm-focus="confirm"
|
|
78
124
|
:label="confirmLabel"
|
|
79
|
-
|
|
125
|
+
:loading="pending"
|
|
126
|
+
@click="confirm"
|
|
80
127
|
/>
|
|
81
128
|
</div>
|
|
82
129
|
</template>
|
|
@@ -21,6 +21,11 @@ import { ONBOARDING_INTENTS } from './onboarding-intents'
|
|
|
21
21
|
// The goals list and the copy name the product, so a site that extends this
|
|
22
22
|
// layer passes its own `intents` and any `copy` keys it rewords. The option
|
|
23
23
|
// ids are shared and stored, so they never change.
|
|
24
|
+
//
|
|
25
|
+
// A site with its own look draws the controls through two scoped slots and
|
|
26
|
+
// keeps this component's answer logic: `#option` draws one single-choice
|
|
27
|
+
// option and `#intents` draws the goals and the free-text box. Saving stays
|
|
28
|
+
// with the site: this layer never fetches.
|
|
24
29
|
const {
|
|
25
30
|
intents = ONBOARDING_INTENTS,
|
|
26
31
|
copy: copyOverrides = {},
|
|
@@ -28,6 +33,19 @@ const {
|
|
|
28
33
|
intents?: readonly OnboardingIntent[]
|
|
29
34
|
copy?: Partial<OnboardingAnswersCopy>
|
|
30
35
|
}>()
|
|
36
|
+
defineSlots<{
|
|
37
|
+
/** One single-choice option. `pick` selects it, or clears it when it is already picked. */
|
|
38
|
+
option?: (props: { question: SingleKey, option: { id: string, label: string }, selected: boolean, pick: () => void }) => unknown
|
|
39
|
+
/** The goals and the free-text box, under the goals question. */
|
|
40
|
+
intents?: (props: {
|
|
41
|
+
intents: readonly OnboardingIntent[]
|
|
42
|
+
selected: readonly string[]
|
|
43
|
+
toggle: (intent: string) => void
|
|
44
|
+
other: string
|
|
45
|
+
setOther: (other: string) => void
|
|
46
|
+
}) => unknown
|
|
47
|
+
}>()
|
|
48
|
+
|
|
31
49
|
const copy = computed<OnboardingAnswersCopy>(() => ({ ...ONBOARDING_ANSWERS_COPY, ...copyOverrides }))
|
|
32
50
|
const answers = defineModel<OnboardingAnswersDraft>({ required: true })
|
|
33
51
|
|
|
@@ -69,16 +87,24 @@ const restClass = 'border-accented bg-default/80 text-default hover:border-prima
|
|
|
69
87
|
{{ item.question }}
|
|
70
88
|
</legend>
|
|
71
89
|
<div class="flex flex-wrap gap-2">
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
<template v-for="option in item.options" :key="option.id">
|
|
91
|
+
<slot
|
|
92
|
+
name="option"
|
|
93
|
+
:question="item.key"
|
|
94
|
+
:option="option"
|
|
95
|
+
:selected="answers[item.key] === option.id"
|
|
96
|
+
:pick="() => pick(item.key, option.id)"
|
|
97
|
+
>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
:aria-pressed="answers[item.key] === option.id"
|
|
101
|
+
:class="[chipClass, answers[item.key] === option.id ? selectedClass : restClass]"
|
|
102
|
+
@click="pick(item.key, option.id)"
|
|
103
|
+
>
|
|
104
|
+
{{ option.label }}
|
|
105
|
+
</button>
|
|
106
|
+
</slot>
|
|
107
|
+
</template>
|
|
82
108
|
</div>
|
|
83
109
|
</fieldset>
|
|
84
110
|
|
|
@@ -86,42 +112,51 @@ const restClass = 'border-accented bg-default/80 text-default hover:border-prima
|
|
|
86
112
|
<legend class="text-sm font-medium text-highlighted">
|
|
87
113
|
{{ copy.intentsQuestion }}
|
|
88
114
|
</legend>
|
|
89
|
-
<p class="text-xs text-muted">
|
|
115
|
+
<p v-if="copy.intentsHint" class="text-xs text-muted">
|
|
90
116
|
{{ copy.intentsHint }}
|
|
91
117
|
</p>
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
>
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
aria-
|
|
118
|
+
<slot
|
|
119
|
+
name="intents"
|
|
120
|
+
:intents="intents"
|
|
121
|
+
:selected="answers.intents"
|
|
122
|
+
:toggle="toggleIntent"
|
|
123
|
+
:other="answers.other ?? ''"
|
|
124
|
+
:set-other="setOther"
|
|
125
|
+
>
|
|
126
|
+
<div class="flex flex-wrap gap-2">
|
|
127
|
+
<button
|
|
128
|
+
v-for="intent in intents"
|
|
129
|
+
:key="intent.id"
|
|
130
|
+
type="button"
|
|
131
|
+
:aria-pressed="answers.intents.includes(intent.id)"
|
|
132
|
+
:class="[chipClass, answers.intents.includes(intent.id) ? selectedClass : restClass]"
|
|
133
|
+
@click="toggleIntent(intent.id)"
|
|
134
|
+
>
|
|
135
|
+
<UiIcon
|
|
136
|
+
:name="answers.intents.includes(intent.id) ? 'check' : intent.icon"
|
|
137
|
+
class="size-4 shrink-0"
|
|
138
|
+
:class="answers.intents.includes(intent.id) ? 'text-primary' : 'text-muted'"
|
|
139
|
+
aria-hidden="true"
|
|
140
|
+
/>
|
|
141
|
+
{{ intent.label }}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="space-y-1.5 pt-1">
|
|
145
|
+
<label :for="otherId" class="text-xs text-muted">
|
|
146
|
+
{{ copy.otherLabel }}
|
|
147
|
+
</label>
|
|
148
|
+
<UTextarea
|
|
149
|
+
:id="otherId"
|
|
150
|
+
:model-value="answers.other"
|
|
151
|
+
:maxlength="ONBOARDING_OTHER_MAX_LENGTH"
|
|
152
|
+
:rows="2"
|
|
153
|
+
autoresize
|
|
154
|
+
:placeholder="copy.otherPlaceholder"
|
|
155
|
+
class="w-full"
|
|
156
|
+
@update:model-value="setOther(String($event ?? ''))"
|
|
106
157
|
/>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
</div>
|
|
110
|
-
<div class="space-y-1.5 pt-1">
|
|
111
|
-
<label :for="otherId" class="text-xs text-muted">
|
|
112
|
-
{{ copy.otherLabel }}
|
|
113
|
-
</label>
|
|
114
|
-
<UTextarea
|
|
115
|
-
:id="otherId"
|
|
116
|
-
:model-value="answers.other"
|
|
117
|
-
:maxlength="ONBOARDING_OTHER_MAX_LENGTH"
|
|
118
|
-
:rows="2"
|
|
119
|
-
autoresize
|
|
120
|
-
:placeholder="copy.otherPlaceholder"
|
|
121
|
-
class="w-full"
|
|
122
|
-
@update:model-value="setOther(String($event ?? ''))"
|
|
123
|
-
/>
|
|
124
|
-
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</slot>
|
|
125
160
|
</fieldset>
|
|
126
161
|
</div>
|
|
127
162
|
</template>
|
|
@@ -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' }}
|
|
@@ -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
|
+
}
|