@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.
Files changed (54) hide show
  1. package/README.md +153 -7
  2. package/app/app.config.ts +6 -10
  3. package/app/components/container/UiAlert.vue +4 -2
  4. package/app/components/container/UiErrorState.vue +5 -3
  5. package/app/components/container/UiSectionHeader.vue +12 -2
  6. package/app/components/container/UiToast.vue +1 -37
  7. package/app/components/container/UiToaster.vue +1 -11
  8. package/app/components/container/UiWidgetState.vue +7 -5
  9. package/app/components/data/UiDataList.vue +6 -4
  10. package/app/components/data/UiDataTableSection.vue +1 -0
  11. package/app/components/data/UiMetricLabel.vue +3 -1
  12. package/app/components/data/UiStat.vue +1 -60
  13. package/app/components/data/UiStats.vue +1 -1
  14. package/app/components/data/UiTable.vue +16 -52
  15. package/app/components/data/UiTableTd.vue +9 -1
  16. package/app/components/data/UiTableTh.vue +10 -2
  17. package/app/components/data/cells/UiTableHeaderCell.vue +1 -1
  18. package/app/components/element/UiAtmosphere.vue +22 -4
  19. package/app/components/element/UiConfirmDialog.vue +54 -7
  20. package/app/components/element/UiOnboardingAnswers.vue +95 -58
  21. package/app/components/element/UiSelect.vue +15 -2
  22. package/app/components/element/UiSkeleton.vue +50 -1
  23. package/app/components/element/UiTooltip.vue +7 -2
  24. package/app/components/element/UiTrend.vue +16 -8
  25. package/app/components/element/onboarding-answers.ts +25 -0
  26. package/app/composables/design-system-config.ts +29 -0
  27. package/app/composables/useConfirm.ts +31 -3
  28. package/app/plugins/favicon.ts +7 -21
  29. package/app/shared/error-state.ts +13 -12
  30. package/app/shared/failure-copy.ts +53 -0
  31. package/app/shared/stat.ts +65 -0
  32. package/app/shared/table.ts +74 -1
  33. package/app/shared/toast.ts +52 -0
  34. package/app/shared/widget-failure.ts +3 -2
  35. package/app/utils/app-favicon.ts +64 -0
  36. package/build/design-system.ts +58 -0
  37. package/build/external-links.ts +38 -0
  38. package/css/global.css +112 -71
  39. package/nuxt.config.ts +19 -26
  40. package/package.json +22 -14
  41. package/public/brand-icon-dark.png +0 -0
  42. package/public/brand-icon-light.png +0 -0
  43. package/public/favicon-dark.png +0 -0
  44. package/public/favicon-light.png +0 -0
  45. package/public/favicon.ico +0 -0
  46. package/public/fonts/README.md +0 -31
  47. package/public/fonts/fira-code-OFL.txt +0 -93
  48. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  49. package/public/fonts/hubot-sans-OFL.txt +0 -93
  50. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  51. package/public/icon-dark.png +0 -0
  52. package/public/icon.png +0 -0
  53. package/public/touch-icon-dark.png +0 -0
  54. 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, UiTableRowId, UiTableSize } from '../../shared/table'
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, useSlots, useTemplateRef, watch } from 'vue'
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="text-xs font-normal text-default relative"
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="text-xs px-3"
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-component">
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
- class="text-xs font-normal text-default relative"
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
- class="text-label text-left whitespace-nowrap border-b border-default bg-default"
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 = true,
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
- /** Fine grain for tooth. Default on. */
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="grain ? '' : undefined"
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 { ConfirmTone } from '../../composables/useConfirm'
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. The dialog never runs the
10
- * action itself, so the caller keeps its own pending state and error handling.
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
- /** `true` confirms. Nuxt UI's overlay resolves the open promise with it. */
34
- close: [confirmed: boolean]
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
- @click="emit('close', false)"
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
- @click="emit('close', true)"
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 its question name the product, so a site that extends this
22
- // layer passes its own. The other questions and their ids are shared.
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
- intentsQuestion = ONBOARDING_QUESTIONS.intents,
31
+ copy: copyOverrides = {},
26
32
  } = defineProps<{
27
33
  intents?: readonly OnboardingIntent[]
28
- intentsQuestion?: string
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: ONBOARDING_QUESTIONS.role, options: ONBOARDING_ROLES },
37
- { key: 'siteCount', question: ONBOARDING_QUESTIONS.siteCount, options: ONBOARDING_SITE_COUNTS },
38
- { key: 'seoExperience', question: ONBOARDING_QUESTIONS.seoExperience, options: ONBOARDING_SEO_EXPERIENCE },
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 = (typeof singleQuestions)[number]['key']
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
- <button
71
- v-for="option in item.options"
72
- :key="option.id"
73
- type="button"
74
- :aria-pressed="answers[item.key] === option.id"
75
- :class="[chipClass, answers[item.key] === option.id ? selectedClass : restClass]"
76
- @click="pick(item.key, option.id)"
77
- >
78
- {{ option.label }}
79
- </button>
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
- Pick any that fit.
115
+ <p v-if="copy.intentsHint" class="text-xs text-muted">
116
+ {{ copy.intentsHint }}
89
117
  </p>
90
- <div class="flex flex-wrap gap-2">
91
- <button
92
- v-for="intent in intents"
93
- :key="intent.id"
94
- type="button"
95
- :aria-pressed="answers.intents.includes(intent.id)"
96
- :class="[chipClass, answers.intents.includes(intent.id) ? selectedClass : restClass]"
97
- @click="toggleIntent(intent.id)"
98
- >
99
- <UiIcon
100
- :name="answers.intents.includes(intent.id) ? 'check' : intent.icon"
101
- class="size-4 shrink-0"
102
- :class="answers.intents.includes(intent.id) ? 'text-primary' : 'text-muted'"
103
- aria-hidden="true"
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
- {{ intent.label }}
106
- </button>
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="emit('update:modelValue', $event)"
59
+ @update:model-value="updateModel"
47
60
  @blur="emit('blur', $event)"
48
61
  @change="emit('change', $event)"
49
62
  @focus="emit('focus', $event)"