@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
|
@@ -8,12 +8,12 @@ import type {
|
|
|
8
8
|
RowSelectionState,
|
|
9
9
|
SortingState,
|
|
10
10
|
} from '@tanstack/vue-table'
|
|
11
|
-
import type { UiTableColumn, UiTableFeatures,
|
|
11
|
+
import type { UiTableColumn, UiTableFeatures, UiTableProps, UiTableSlots } from '../../shared/table'
|
|
12
12
|
import { FlexRender, functionalUpdate, useTable } from '@tanstack/vue-table'
|
|
13
13
|
import { useIntersectionObserver } from '@vueuse/core'
|
|
14
|
-
import { computed, ref, toRef, useId,
|
|
14
|
+
import { computed, ref, toRef, useId, useTemplateRef, watch } from 'vue'
|
|
15
15
|
import { UiButton, UiSkeleton, UiTableExport, UiTableFrame, UiTableHeaderCell, UPagination } from '#components'
|
|
16
|
-
import { resolveUiTableRowId, uiTableCellSizeClass, uiTableFeatures, uiTableSkeletonSizeClass, uiTableVisibleFromClass } from '../../shared/table'
|
|
16
|
+
import { resolveUiTableRowId, uiTableCellSizeClass, uiTableCellSlotName, uiTableFeatures, uiTableSkeletonSizeClass, uiTableVisibleFromClass } from '../../shared/table'
|
|
17
17
|
import { uiTableExport } from '../../shared/table-export'
|
|
18
18
|
|
|
19
19
|
const {
|
|
@@ -51,6 +51,8 @@ const emit = defineEmits<{
|
|
|
51
51
|
'sortColumn': [column: string]
|
|
52
52
|
}>()
|
|
53
53
|
|
|
54
|
+
const slots = defineSlots<UiTableSlots<T>>()
|
|
55
|
+
|
|
54
56
|
const selectedModel = defineModel<RowSelectionState>('selected')
|
|
55
57
|
const pageModel = defineModel<number>('page', { default: 1 })
|
|
56
58
|
const sortingModel = defineModel<SortingState>('sorting', { default: () => [] })
|
|
@@ -170,8 +172,6 @@ function onRowKeydown(e: KeyboardEvent, row: T, tanstackRow: Row<UiTableFeatures
|
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
|
|
173
|
-
const slots = useSlots()
|
|
174
|
-
|
|
175
175
|
// Optional trailing actions column (row buttons/menus). Rendered outside the
|
|
176
176
|
// TanStack column model so callers express rich actions as a slot, not a cell
|
|
177
177
|
// render fn. `colSpan` accounts for it in the empty/skeleton rows.
|
|
@@ -283,49 +283,6 @@ if (import.meta.dev) {
|
|
|
283
283
|
}
|
|
284
284
|
</script>
|
|
285
285
|
|
|
286
|
-
<script lang="ts">
|
|
287
|
-
export interface UiTableProps<T extends object> {
|
|
288
|
-
data: T[]
|
|
289
|
-
columns: UiTableColumn<T>[]
|
|
290
|
-
selected?: RowSelectionState
|
|
291
|
-
controlledSelection?: boolean
|
|
292
|
-
rowHover?: boolean
|
|
293
|
-
rowClickable?: boolean
|
|
294
|
-
enableSorting?: boolean
|
|
295
|
-
/** Caller owns sort state; UiTable emits @sortColumn and bypasses client-side sorting. */
|
|
296
|
-
manualSorting?: boolean
|
|
297
|
-
pageSize?: number
|
|
298
|
-
ignoreHeader?: boolean
|
|
299
|
-
size?: UiTableSize
|
|
300
|
-
loading?: boolean
|
|
301
|
-
loadingRows?: number
|
|
302
|
-
rowId?: UiTableRowId<T>
|
|
303
|
-
manualPagination?: boolean
|
|
304
|
-
disablePagination?: boolean
|
|
305
|
-
total?: number
|
|
306
|
-
/** Accessible name for the table. Rendered as a visually-hidden <caption>. */
|
|
307
|
-
label: string
|
|
308
|
-
/** Draw the canonical rounded table surface. */
|
|
309
|
-
bordered?: boolean
|
|
310
|
-
/** Pagination density. Compact renders previous, page position, and next only. */
|
|
311
|
-
paginationPreset?: 'default' | 'compact'
|
|
312
|
-
/** Fill the caller's available height and keep pagination pinned to the bottom. */
|
|
313
|
-
fill?: boolean
|
|
314
|
-
/** Extra classes applied per data row — e.g. a selection highlight. */
|
|
315
|
-
rowClass?: (row: T) => string
|
|
316
|
-
/** Row id presented as the active item in a click-through table. */
|
|
317
|
-
activeRowId?: string
|
|
318
|
-
/** Row id to open when the table first receives deep-linked evidence. */
|
|
319
|
-
initialExpandedRowId?: string
|
|
320
|
-
/**
|
|
321
|
-
* Show Export CSV and Export JSON, saving to this file name without the
|
|
322
|
-
* extension. The export holds every filtered and sorted row, not one page.
|
|
323
|
-
* Ignored with `manualPagination`: mount `UiTableExport` with every row.
|
|
324
|
-
*/
|
|
325
|
-
exportFilename?: string
|
|
326
|
-
}
|
|
327
|
-
</script>
|
|
328
|
-
|
|
329
286
|
<template>
|
|
330
287
|
<div class="w-full" :class="fill && 'flex h-full min-h-0 flex-col'">
|
|
331
288
|
<UiTableExport
|
|
@@ -353,7 +310,7 @@ export interface UiTableProps<T extends object> {
|
|
|
353
310
|
<th
|
|
354
311
|
v-for="header in headerGroup.headers"
|
|
355
312
|
:key="header.id"
|
|
356
|
-
class="text-label text-left whitespace-nowrap border-b border-default bg-default"
|
|
313
|
+
class="text-label ui-table-head text-left whitespace-nowrap border-b border-default bg-default"
|
|
357
314
|
:class="[
|
|
358
315
|
uiColumnDef(header.column).noPadding ? '' : header.column.getCanSort() ? 'px-2' : 'px-3',
|
|
359
316
|
getTextAlignClass(uiColumnDef(header.column).align, uiColumnDef(header.column).numeric),
|
|
@@ -397,7 +354,7 @@ export interface UiTableProps<T extends object> {
|
|
|
397
354
|
v-for="cell in row.getVisibleCells()"
|
|
398
355
|
:key="cell.id"
|
|
399
356
|
:scope="uiColumnDef(cell.column).rowHeader ? 'row' : undefined"
|
|
400
|
-
class="
|
|
357
|
+
class="ui-table-cell font-normal text-default relative"
|
|
401
358
|
:class="[
|
|
402
359
|
uiTableCellSizeClass[size],
|
|
403
360
|
uiColumnDef(cell.column).noPadding ? '' : cell.column.getCanSort() ? 'px-2' : 'px-3',
|
|
@@ -422,6 +379,13 @@ export interface UiTableProps<T extends object> {
|
|
|
422
379
|
:base="60"
|
|
423
380
|
:range="50"
|
|
424
381
|
/>
|
|
382
|
+
<slot
|
|
383
|
+
v-else-if="slots[uiTableCellSlotName(cell.column.id)]"
|
|
384
|
+
:name="uiTableCellSlotName(cell.column.id)"
|
|
385
|
+
:row="row"
|
|
386
|
+
:cell="cell"
|
|
387
|
+
:value="cell.getValue()"
|
|
388
|
+
/>
|
|
425
389
|
<FlexRender v-else :cell="cell" />
|
|
426
390
|
</component>
|
|
427
391
|
<td v-if="hasActions" class="text-right whitespace-nowrap px-3" :class="uiTableCellSizeClass[size]" @click.stop>
|
|
@@ -444,7 +408,7 @@ export interface UiTableProps<T extends object> {
|
|
|
444
408
|
<td
|
|
445
409
|
v-for="(col, j) in columns"
|
|
446
410
|
:key="j"
|
|
447
|
-
class="
|
|
411
|
+
class="ui-table-cell px-3"
|
|
448
412
|
:class="[
|
|
449
413
|
uiTableCellSizeClass[size],
|
|
450
414
|
getTextAlignClass(col.align, col.numeric),
|
|
@@ -458,7 +422,7 @@ export interface UiTableProps<T extends object> {
|
|
|
458
422
|
|
|
459
423
|
<tr v-else>
|
|
460
424
|
<td :colspan="colSpan" class="h-24 text-center" role="status">
|
|
461
|
-
<slot name="empty
|
|
425
|
+
<slot name="empty">
|
|
462
426
|
<span class="text-xs font-mono text-dimmed">0 rows · adjust filters</span>
|
|
463
427
|
</slot>
|
|
464
428
|
</td>
|
|
@@ -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>
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { OnboardingAnswersDraft } from './onboarding-answers'
|
|
2
|
+
import type { OnboardingAnswersCopy, OnboardingAnswersDraft } from './onboarding-answers'
|
|
3
3
|
import type { OnboardingIntent } from './onboarding-intents'
|
|
4
|
-
import { useId } from 'vue'
|
|
4
|
+
import { computed, useId } from 'vue'
|
|
5
5
|
import { UiIcon, UTextarea } from '#components'
|
|
6
6
|
import {
|
|
7
|
+
ONBOARDING_ANSWERS_COPY,
|
|
7
8
|
ONBOARDING_OTHER_MAX_LENGTH,
|
|
8
|
-
ONBOARDING_QUESTIONS,
|
|
9
9
|
ONBOARDING_ROLES,
|
|
10
10
|
ONBOARDING_SEO_EXPERIENCE,
|
|
11
11
|
ONBOARDING_SITE_COUNTS,
|
|
@@ -18,27 +18,47 @@ import { ONBOARDING_INTENTS } from './onboarding-intents'
|
|
|
18
18
|
// the live teams/create wizard and the brand-kit showcase both v-model the
|
|
19
19
|
// draft and decide when to save it.
|
|
20
20
|
//
|
|
21
|
-
// The goals list and
|
|
22
|
-
// layer passes its own
|
|
21
|
+
// The goals list and the copy name the product, so a site that extends this
|
|
22
|
+
// layer passes its own `intents` and any `copy` keys it rewords. The option
|
|
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.
|
|
23
29
|
const {
|
|
24
30
|
intents = ONBOARDING_INTENTS,
|
|
25
|
-
|
|
31
|
+
copy: copyOverrides = {},
|
|
26
32
|
} = defineProps<{
|
|
27
33
|
intents?: readonly OnboardingIntent[]
|
|
28
|
-
|
|
34
|
+
copy?: Partial<OnboardingAnswersCopy>
|
|
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
|
|
29
47
|
}>()
|
|
48
|
+
|
|
49
|
+
const copy = computed<OnboardingAnswersCopy>(() => ({ ...ONBOARDING_ANSWERS_COPY, ...copyOverrides }))
|
|
30
50
|
const answers = defineModel<OnboardingAnswersDraft>({ required: true })
|
|
31
51
|
|
|
32
52
|
const id = useId()
|
|
33
53
|
const otherId = `${id}-other`
|
|
34
54
|
|
|
35
|
-
const singleQuestions = [
|
|
36
|
-
{ key: 'role', question:
|
|
37
|
-
{ key: 'siteCount', question:
|
|
38
|
-
{ key: 'seoExperience', question:
|
|
39
|
-
] as const
|
|
55
|
+
const singleQuestions = computed(() => [
|
|
56
|
+
{ key: 'role', question: copy.value.roleQuestion, options: ONBOARDING_ROLES },
|
|
57
|
+
{ key: 'siteCount', question: copy.value.siteCountQuestion, options: ONBOARDING_SITE_COUNTS },
|
|
58
|
+
{ key: 'seoExperience', question: copy.value.seoExperienceQuestion, options: ONBOARDING_SEO_EXPERIENCE },
|
|
59
|
+
] as const)
|
|
40
60
|
|
|
41
|
-
type SingleKey =
|
|
61
|
+
type SingleKey = 'role' | 'siteCount' | 'seoExperience'
|
|
42
62
|
|
|
43
63
|
function pick(key: SingleKey, value: string) {
|
|
44
64
|
answers.value = { ...answers.value, [key]: answers.value[key] === value ? undefined : value }
|
|
@@ -67,59 +87,76 @@ const restClass = 'border-accented bg-default/80 text-default hover:border-prima
|
|
|
67
87
|
{{ item.question }}
|
|
68
88
|
</legend>
|
|
69
89
|
<div class="flex flex-wrap gap-2">
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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>
|
|
80
108
|
</div>
|
|
81
109
|
</fieldset>
|
|
82
110
|
|
|
83
111
|
<fieldset class="space-y-2.5">
|
|
84
112
|
<legend class="text-sm font-medium text-highlighted">
|
|
85
|
-
{{ intentsQuestion }}
|
|
113
|
+
{{ copy.intentsQuestion }}
|
|
86
114
|
</legend>
|
|
87
|
-
<p class="text-xs text-muted">
|
|
88
|
-
|
|
115
|
+
<p v-if="copy.intentsHint" class="text-xs text-muted">
|
|
116
|
+
{{ copy.intentsHint }}
|
|
89
117
|
</p>
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
>
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
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 ?? ''))"
|
|
104
157
|
/>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
</div>
|
|
108
|
-
<div class="space-y-1.5 pt-1">
|
|
109
|
-
<label :for="otherId" class="text-xs text-muted">
|
|
110
|
-
Anything else? Optional.
|
|
111
|
-
</label>
|
|
112
|
-
<UTextarea
|
|
113
|
-
:id="otherId"
|
|
114
|
-
:model-value="answers.other"
|
|
115
|
-
:maxlength="ONBOARDING_OTHER_MAX_LENGTH"
|
|
116
|
-
:rows="2"
|
|
117
|
-
autoresize
|
|
118
|
-
placeholder="Tell us what you're after"
|
|
119
|
-
class="w-full"
|
|
120
|
-
@update:model-value="setOther(String($event ?? ''))"
|
|
121
|
-
/>
|
|
122
|
-
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</slot>
|
|
123
160
|
</fieldset>
|
|
124
161
|
</div>
|
|
125
162
|
</template>
|
|
@@ -18,7 +18,7 @@ import { USelect } from '#components'
|
|
|
18
18
|
defineOptions({ inheritAttrs: false })
|
|
19
19
|
|
|
20
20
|
const props = defineProps<Props>()
|
|
21
|
-
const emit = defineEmits<SelectEmits<T, VK, M
|
|
21
|
+
const emit = defineEmits<Omit<SelectEmits<T, VK, M>, 'update:modelValue'> & { 'update:modelValue': [value: ModelValue] }>()
|
|
22
22
|
/**
|
|
23
23
|
* `open`, `defaultOpen` and `portal` are omitted deliberately, and dropping any
|
|
24
24
|
* of them from this list breaks every select in the app.
|
|
@@ -37,13 +37,26 @@ const emit = defineEmits<SelectEmits<T, VK, M>>()
|
|
|
37
37
|
* No caller passes any of the three, so omitting them costs nothing.
|
|
38
38
|
*/
|
|
39
39
|
type Props = Omit<SelectProps<T, VK, M>, 'defaultValue' | 'modelModifiers' | 'open' | 'defaultOpen' | 'portal'>
|
|
40
|
+
/**
|
|
41
|
+
* The value USelect emits is always a picked item, so the model never emits
|
|
42
|
+
* `undefined` and a caller may v-model a ref that never holds it. The type is
|
|
43
|
+
* read from SelectProps, not rebuilt from Nuxt UI's private ExcludeItem
|
|
44
|
+
* generic. Inside this generic component TypeScript 7.1 cannot prove that
|
|
45
|
+
* USelect's own emitted type matches it, so `updateModel` takes the value
|
|
46
|
+
* untyped and the caller-facing emit carries the type.
|
|
47
|
+
*/
|
|
48
|
+
type ModelValue = NonNullable<SelectProps<T, VK, M>['modelValue']>
|
|
49
|
+
|
|
50
|
+
function updateModel(value: unknown) {
|
|
51
|
+
emit('update:modelValue', value as ModelValue)
|
|
52
|
+
}
|
|
40
53
|
</script>
|
|
41
54
|
|
|
42
55
|
<template>
|
|
43
56
|
<!-- @vue-generic {T, VK, M} -->
|
|
44
57
|
<USelect
|
|
45
58
|
v-bind="{ ...props, ...$attrs }"
|
|
46
|
-
@update:model-value="
|
|
59
|
+
@update:model-value="updateModel"
|
|
47
60
|
@blur="emit('blur', $event)"
|
|
48
61
|
@change="emit('change', $event)"
|
|
49
62
|
@focus="emit('focus', $event)"
|