@alfakit/vue 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 (136) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/components/actions/ActionButton.js.map +1 -1
  3. package/dist/components/actions/ActionButton.vue.d.ts.map +1 -1
  4. package/dist/components/actions/ActionButton.vue_vue_type_script_setup_true_lang.js +160 -159
  5. package/dist/components/actions/ActionButton.vue_vue_type_script_setup_true_lang.js.map +1 -1
  6. package/dist/components/actions/ActionModal.js.map +1 -1
  7. package/dist/components/actions/ActionModal.vue.d.ts.map +1 -1
  8. package/dist/components/actions/ActionModal.vue_vue_type_script_setup_true_lang.js +214 -214
  9. package/dist/components/actions/ActionModal.vue_vue_type_script_setup_true_lang.js.map +1 -1
  10. package/dist/components/cms/CmsLinkButton.vue.d.ts +1 -1
  11. package/dist/components/fields/ColorPickerField.js.map +1 -1
  12. package/dist/components/fields/ColorPickerField.vue.d.ts.map +1 -1
  13. package/dist/components/fields/ColorPickerField.vue_vue_type_script_setup_true_lang.js +1 -0
  14. package/dist/components/fields/ColorPickerField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  15. package/dist/components/fields/ComboboxSelect.js.map +1 -1
  16. package/dist/components/fields/ComboboxSelect.vue.d.ts.map +1 -1
  17. package/dist/components/fields/ComboboxSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  18. package/dist/components/fields/ComboboxSelect.vue_vue_type_script_setup_true_lang.js.map +1 -1
  19. package/dist/components/fields/FileUploadField.js.map +1 -1
  20. package/dist/components/fields/FileUploadField.vue.d.ts.map +1 -1
  21. package/dist/components/fields/FileUploadField.vue_vue_type_script_setup_true_lang.js +183 -183
  22. package/dist/components/fields/FileUploadField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  23. package/dist/components/fields/OneTimeCodeInputField.js.map +1 -1
  24. package/dist/components/fields/OneTimeCodeInputField.vue_vue_type_script_setup_true_lang.js +1 -2
  25. package/dist/components/fields/OneTimeCodeInputField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  26. package/dist/components/fields/SelectField.js.map +1 -1
  27. package/dist/components/fields/SelectField.vue.d.ts.map +1 -1
  28. package/dist/components/fields/SelectField.vue_vue_type_script_setup_true_lang.js +2 -2
  29. package/dist/components/fields/SelectField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  30. package/dist/components/fields/TableColumnHeaders.js.map +1 -1
  31. package/dist/components/fields/TableColumnHeaders.vue.d.ts.map +1 -1
  32. package/dist/components/fields/TableColumnHeaders.vue_vue_type_script_setup_true_lang.js +6 -3
  33. package/dist/components/fields/TableColumnHeaders.vue_vue_type_script_setup_true_lang.js.map +1 -1
  34. package/dist/components/file-manager/FileManagerView.js.map +1 -1
  35. package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js +1 -1
  36. package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js.map +1 -1
  37. package/dist/components/form/FormBlock.js.map +1 -1
  38. package/dist/components/form/FormBlock.vue.d.ts.map +1 -1
  39. package/dist/components/form/FormBlock.vue_vue_type_script_setup_true_lang.js +81 -77
  40. package/dist/components/form/FormBlock.vue_vue_type_script_setup_true_lang.js.map +1 -1
  41. package/dist/components/form/form-context.js +25 -25
  42. package/dist/components/form/form-context.js.map +1 -1
  43. package/dist/components/layout/FusedGroupComponent.js.map +1 -1
  44. package/dist/components/layout/FusedGroupComponent.vue.d.ts.map +1 -1
  45. package/dist/components/layout/FusedGroupComponent.vue_vue_type_script_setup_true_lang.js +1 -0
  46. package/dist/components/layout/FusedGroupComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
  47. package/dist/components/layout/TabsComponent.js.map +1 -1
  48. package/dist/components/layout/TabsComponent.vue.d.ts.map +1 -1
  49. package/dist/components/layout/TabsComponent.vue_vue_type_script_setup_true_lang.js +76 -55
  50. package/dist/components/layout/TabsComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
  51. package/dist/components/notifications/NotificationsMenu.js.map +1 -1
  52. package/dist/components/notifications/NotificationsMenu.vue.d.ts.map +1 -1
  53. package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js +44 -42
  54. package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
  55. package/dist/components/panel/GlobalSearch.js.map +1 -1
  56. package/dist/components/panel/GlobalSearch.vue.d.ts.map +1 -1
  57. package/dist/components/panel/GlobalSearch.vue_vue_type_script_setup_true_lang.js +1 -0
  58. package/dist/components/panel/GlobalSearch.vue_vue_type_script_setup_true_lang.js.map +1 -1
  59. package/dist/components/panel/ShortcutKeys.js.map +1 -1
  60. package/dist/components/panel/ShortcutKeys.vue.d.ts.map +1 -1
  61. package/dist/components/panel/ShortcutKeys.vue_vue_type_script_setup_true_lang.js +4 -1
  62. package/dist/components/panel/ShortcutKeys.vue_vue_type_script_setup_true_lang.js.map +1 -1
  63. package/dist/components/panel/TenantMenu.js.map +1 -1
  64. package/dist/components/panel/TenantMenu.vue_vue_type_script_setup_true_lang.js +2 -2
  65. package/dist/components/panel/TenantMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
  66. package/dist/components/table/GroupHeaderRow.js.map +1 -1
  67. package/dist/components/table/GroupHeaderRow.vue.d.ts.map +1 -1
  68. package/dist/components/table/GroupHeaderRow.vue_vue_type_script_setup_true_lang.js +55 -34
  69. package/dist/components/table/GroupHeaderRow.vue_vue_type_script_setup_true_lang.js.map +1 -1
  70. package/dist/components/table/SelectCell.js.map +1 -1
  71. package/dist/components/table/SelectCell.vue.d.ts.map +1 -1
  72. package/dist/components/table/SelectCell.vue_vue_type_script_setup_true_lang.js +24 -19
  73. package/dist/components/table/SelectCell.vue_vue_type_script_setup_true_lang.js.map +1 -1
  74. package/dist/components/table/TextInputCell.js.map +1 -1
  75. package/dist/components/table/TextInputCell.vue.d.ts.map +1 -1
  76. package/dist/components/table/TextInputCell.vue_vue_type_script_setup_true_lang.js +38 -33
  77. package/dist/components/table/TextInputCell.vue_vue_type_script_setup_true_lang.js.map +1 -1
  78. package/dist/components.css +347 -0
  79. package/dist/eject-manifest.json +2 -2
  80. package/dist/lib/Icon.js.map +1 -1
  81. package/dist/lib/Icon.vue.d.ts.map +1 -1
  82. package/dist/lib/Icon.vue_vue_type_script_setup_true_lang.js +18 -19
  83. package/dist/lib/Icon.vue_vue_type_script_setup_true_lang.js.map +1 -1
  84. package/dist/lib/hydration.d.ts +9 -0
  85. package/dist/lib/hydration.d.ts.map +1 -0
  86. package/dist/lib/hydration.js +17 -0
  87. package/dist/lib/hydration.js.map +1 -0
  88. package/dist/lib/icons.d.ts +3 -5
  89. package/dist/lib/icons.d.ts.map +1 -1
  90. package/dist/lib/icons.js +19 -22
  91. package/dist/lib/icons.js.map +1 -1
  92. package/dist/lib/lazy.d.ts +1 -1
  93. package/dist/lib/lazy.d.ts.map +1 -1
  94. package/dist/lib/lazy.js +12 -11
  95. package/dist/lib/lazy.js.map +1 -1
  96. package/dist/standalone/AlfakitToaster.js.map +1 -1
  97. package/dist/standalone/AlfakitToaster.vue.d.ts.map +1 -1
  98. package/dist/standalone/AlfakitToaster.vue_vue_type_script_setup_true_lang.js +23 -15
  99. package/dist/standalone/AlfakitToaster.vue_vue_type_script_setup_true_lang.js.map +1 -1
  100. package/dist/standalone.css +47 -0
  101. package/dist/starter-kit/widgets/SearchTrigger.js.map +1 -1
  102. package/dist/starter-kit/widgets/SearchTrigger.vue.d.ts.map +1 -1
  103. package/dist/starter-kit/widgets/SearchTrigger.vue_vue_type_script_setup_true_lang.js +1 -0
  104. package/dist/starter-kit/widgets/SearchTrigger.vue_vue_type_script_setup_true_lang.js.map +1 -1
  105. package/dist/starter-kit/widgets/TenantChooser.js.map +1 -1
  106. package/dist/starter-kit/widgets/TenantChooser.vue_vue_type_script_setup_true_lang.js +1 -1
  107. package/dist/starter-kit/widgets/TenantChooser.vue_vue_type_script_setup_true_lang.js.map +1 -1
  108. package/dist/themes.css +2 -334
  109. package/package.json +3 -3
  110. package/src/components/actions/ActionButton.vue +14 -6
  111. package/src/components/actions/ActionModal.vue +3 -1
  112. package/src/components/fields/ColorPickerField.vue +1 -0
  113. package/src/components/fields/ComboboxSelect.vue +1 -1
  114. package/src/components/fields/FileUploadField.vue +14 -11
  115. package/src/components/fields/OneTimeCodeInputField.vue +1 -1
  116. package/src/components/fields/SelectField.vue +4 -2
  117. package/src/components/fields/TableColumnHeaders.vue +6 -1
  118. package/src/components/file-manager/FileManagerView.vue +1 -1
  119. package/src/components/form/FormBlock.vue +24 -0
  120. package/src/components/form/form-context.ts +2 -2
  121. package/src/components/layout/FusedGroupComponent.vue +1 -0
  122. package/src/components/layout/TabsComponent.vue +61 -5
  123. package/src/components/notifications/NotificationsMenu.vue +11 -0
  124. package/src/components/panel/GlobalSearch.vue +1 -0
  125. package/src/components/panel/ShortcutKeys.vue +1 -0
  126. package/src/components/panel/TenantMenu.vue +2 -2
  127. package/src/components/table/GroupHeaderRow.vue +27 -0
  128. package/src/components/table/SelectCell.vue +5 -2
  129. package/src/components/table/TextInputCell.vue +11 -5
  130. package/src/lib/Icon.vue +6 -5
  131. package/src/lib/hydration.ts +35 -0
  132. package/src/lib/icons.ts +6 -11
  133. package/src/lib/lazy.ts +5 -6
  134. package/src/standalone/AlfakitToaster.vue +12 -2
  135. package/src/starter-kit/widgets/SearchTrigger.vue +1 -0
  136. package/src/starter-kit/widgets/TenantChooser.vue +1 -1
@@ -5,6 +5,11 @@ import { cn } from '../../lib/utils'
5
5
 
6
6
  /** Header cells shared by the Repeater and RepeatableEntry tables. */
7
7
  const props = defineProps<{ columns: TableColumnSchema[] }>()
8
+
9
+ // A fixed `width()` (`9rem`, `120px`) is also the column's minimum, so the table scrolls
10
+ // sideways instead of squeezing its fields; percentages stay flexible.
11
+ const widthStyle = (width?: string | null) =>
12
+ !width ? undefined : width.trim().endsWith('%') ? { width } : { width, minWidth: width }
8
13
  </script>
9
14
 
10
15
  <template>
@@ -12,7 +17,7 @@ const props = defineProps<{ columns: TableColumnSchema[] }>()
12
17
  v-for="(column, index) in props.columns"
13
18
  :key="`${column.name ?? 'column'}-${index}`"
14
19
  scope="col"
15
- :style="column.width ? { width: column.width } : undefined"
20
+ :style="widthStyle(column.width)"
16
21
  :class="
17
22
  cn(
18
23
  'text-muted-foreground px-3 py-2 text-xs font-medium',
@@ -436,7 +436,7 @@ const onPreviewKeyDown = (event: KeyboardEvent) => {
436
436
  </label>
437
437
  <nav
438
438
  :aria-label="tt('breadcrumb', {}, 'Folder path')"
439
- class="min-w-0 flex-1"
439
+ class="min-w-0 flex-[1_1_12rem]"
440
440
  data-slot="file-manager-breadcrumb"
441
441
  >
442
442
  <ol class="flex min-w-0 flex-wrap items-center gap-1 text-sm">
@@ -14,6 +14,7 @@ import {
14
14
  registerFormSubmitter,
15
15
  registerPageForm,
16
16
  resolvePartialProps,
17
+ shouldResyncForm,
17
18
  wizardProgressStore,
18
19
  } from '@alfakit/core'
19
20
  import { router } from '@inertiajs/vue3'
@@ -102,6 +103,10 @@ watch(token, (value) => store.setToken(value))
102
103
  // block may arrive before or after Inertia's `success` event, so handle both orders.
103
104
  let submittedBlock: FormBlockSchema | null = null
104
105
  let refillPending = false
106
+ // Another action succeeded and its new block has not arrived yet.
107
+ let resyncPending = false
108
+ // The block when the latest non-GET visit started, to tell whether its response arrived.
109
+ let visitBlock: FormBlockSchema | null = null
105
110
 
106
111
  watch(
107
112
  () => props.block,
@@ -110,6 +115,12 @@ watch(
110
115
  refillPending = false
111
116
  store.fill(block)
112
117
  }
118
+
119
+ if (resyncPending) {
120
+ resyncPending = false
121
+
122
+ if (!store.isDirty) store.fill(block)
123
+ }
113
124
  },
114
125
  )
115
126
 
@@ -153,11 +164,24 @@ const offStart = router.on('start', (event) => {
153
164
  if (submitting && !submitVisit && visit.method !== 'get') {
154
165
  submitVisit = visit.id
155
166
  }
167
+
168
+ if (visit.method !== 'get') visitBlock = props.block
156
169
  })
157
170
 
158
171
  const offSuccess = router.on('success', (event) => {
159
172
  const id = submitVisit
160
173
 
174
+ // Another action changed the record: show the server's new form unless it holds edits.
175
+ if (!id && shouldResyncForm(pageFlash(event.detail.page), store)) {
176
+ if (props.block !== visitBlock) {
177
+ store.fill(props.block)
178
+ } else {
179
+ resyncPending = true
180
+ }
181
+
182
+ return
183
+ }
184
+
161
185
  if (!id || (event.detail.visitId ?? id) !== id) return
162
186
 
163
187
  // A halted action (rate limit, `halt()`) saved nothing: keep what the user typed.
@@ -1,5 +1,5 @@
1
1
  import type { BaseField, FieldAriaAttributes, FormState, FormStore } from '@alfakit/core'
2
- import { fieldAria } from '@alfakit/core'
2
+ import { fieldAria, fieldDomId } from '@alfakit/core'
3
3
  import {
4
4
  computed,
5
5
  inject,
@@ -75,7 +75,7 @@ export function useField<T = unknown>(field: MaybeRefOrGetter<BaseField>): Field
75
75
  // is in flight, so locking inputs would only swallow keystrokes.
76
76
  const disabled = computed(() => Boolean(toValue(field).disabled))
77
77
  const readOnly = computed(() => Boolean(toValue(field).readOnly))
78
- const id = toValue(field).id ?? `alfakit-${path.replace(/[^a-zA-Z0-9_-]/g, '-')}`
78
+ const id = toValue(field).id ?? fieldDomId(store.idPrefix, path)
79
79
  const aria = computed(() => fieldAria(id, toValue(field), error.value))
80
80
 
81
81
  return {
@@ -40,6 +40,7 @@ const errors = computed(() =>
40
40
  <span
41
41
  v-if="props.component.label"
42
42
  :id="labelId"
43
+ data-slot="label"
43
44
  class="text-sm leading-none font-medium select-none"
44
45
  >
45
46
  {{ props.component.label }}
@@ -1,10 +1,21 @@
1
1
  <script setup lang="ts">
2
2
  import type { TabsSchema } from '@alfakit/core'
3
- import { readPersisted, schemaGapClass, writePersisted } from '@alfakit/core'
3
+ import {
4
+ componentHasErrors,
5
+ readPersisted,
6
+ schemaGapClass,
7
+ tabFromQuery,
8
+ tabQueryValue,
9
+ tabToReveal,
10
+ writePersisted,
11
+ } from '@alfakit/core'
4
12
  import { usePage } from '@inertiajs/vue3'
5
13
  import { computed, onMounted, ref, watch } from 'vue'
14
+ import { useStoreSelector } from '../../composables/use-store'
15
+ import { useT } from '../../context'
6
16
  import { Icon } from '../../lib/icons'
7
17
  import { cn } from '../../lib/utils'
18
+ import { useOptionalFormStore } from '../form/form-context'
8
19
  import SchemaRenderer from '../schema/SchemaRenderer.vue'
9
20
  import Tabs from '../ui/Tabs.vue'
10
21
  import TabsContent from '../ui/TabsContent.vue'
@@ -17,11 +28,15 @@ const props = defineProps<{ component: TabsSchema }>()
17
28
  const tabs = computed(() => props.component.schema ?? [])
18
29
  // The page URL (not `window.location`) so the server renders the same tab as the browser.
19
30
  const page = usePage()
31
+ // The tab's readable value (`?tab=billing-address`), or its key in links from older versions.
20
32
  const fromQuery = props.component.queryStringKey
21
- ? new URL(page.url, 'http://localhost').searchParams.get(props.component.queryStringKey)
33
+ ? tabFromQuery(
34
+ tabs.value,
35
+ new URL(page.url, 'http://localhost').searchParams.get(props.component.queryStringKey),
36
+ )
22
37
  : null
23
38
  const active = ref<string>(
24
- tabs.value.find((tab) => tab.key === fromQuery)?.key ??
39
+ fromQuery ??
25
40
  tabs.value[Math.max(0, props.component.activeTab - 1)]?.key ??
26
41
  tabs.value[0]?.key ??
27
42
  '',
@@ -39,6 +54,36 @@ onMounted(() => {
39
54
  if (stored && tabs.value.some((tab) => tab.key === stored)) active.value = stored
40
55
  })
41
56
 
57
+ // Tabs holding a field with a validation error are marked, and when a submit brings new
58
+ // errors while the open tab has none, the first tab with an error opens.
59
+ const TAB_INVALID_CLASSES =
60
+ 'text-destructive data-[state=active]:text-destructive dark:text-destructive dark:data-[state=active]:text-destructive'
61
+ const t = useT()
62
+ const store = useOptionalFormStore()
63
+ const errors = useStoreSelector(
64
+ store?.store ?? {
65
+ get: () => ({ errors: {} as Record<string, string[]> }),
66
+ subscribe: () => () => {},
67
+ },
68
+ (state) => state.errors,
69
+ )
70
+ const invalidTabs = computed(
71
+ () =>
72
+ new Set(
73
+ tabs.value.filter((tab) => componentHasErrors(tab, errors.value)).map((tab) => tab.key),
74
+ ),
75
+ )
76
+
77
+ watch(
78
+ errors,
79
+ (next, previous) => {
80
+ const reveal = tabToReveal(tabs.value, active.value, previous ?? {}, next)
81
+
82
+ if (reveal !== null) active.value = reveal
83
+ },
84
+ { immediate: true },
85
+ )
86
+
42
87
  function changeTab(value: string) {
43
88
  active.value = value
44
89
 
@@ -52,8 +97,9 @@ watch(
52
97
  (value) => {
53
98
  if (!props.component.queryStringKey || typeof window === 'undefined') return
54
99
 
100
+ const tab = tabs.value.find((item) => item.key === value)
55
101
  const url = new URL(window.location.href)
56
- url.searchParams.set(props.component.queryStringKey, value)
102
+ url.searchParams.set(props.component.queryStringKey, tab ? tabQueryValue(tab) : value)
57
103
  window.history.replaceState(window.history.state, '', url)
58
104
  },
59
105
  { immediate: true },
@@ -87,11 +133,21 @@ watch(
87
133
  )
88
134
  "
89
135
  >
90
- <TabsTrigger v-for="tab in tabs" :key="tab.key" :value="tab.key">
136
+ <TabsTrigger
137
+ v-for="tab in tabs"
138
+ :key="tab.key"
139
+ :value="tab.key"
140
+ :data-invalid="invalidTabs.has(tab.key) || undefined"
141
+ :class="invalidTabs.has(tab.key) ? TAB_INVALID_CLASSES : undefined"
142
+ >
91
143
  <Icon v-if="tab.icon && tab.iconPosition !== 'after'" :name="tab.icon" />
92
144
  {{ tab.label }}
93
145
  <Icon v-if="tab.icon && tab.iconPosition === 'after'" :name="tab.icon" />
94
146
  <TabBadge :tab="tab" />
147
+ <template v-if="invalidTabs.has(tab.key)">
148
+ <span aria-hidden="true" class="bg-destructive size-1.5 shrink-0 rounded-full"></span>
149
+ <span class="sr-only">({{ t('translatable.has_errors') }})</span>
150
+ </template>
95
151
  </TabsTrigger>
96
152
  </TabsList>
97
153
  <TabsContent
@@ -67,6 +67,17 @@ watch(open, (isOpen) => {
67
67
  if (isOpen) void center?.load()
68
68
  })
69
69
 
70
+ // Every visit brings the server's unread count (another user after impersonating, or
71
+ // notifications read in another tab): follow it instead of waiting for the next poll.
72
+ watch(
73
+ () => config.value?.unreadCount,
74
+ (count) => {
75
+ if (center && typeof count === 'number' && count !== center.store.get().unreadCount) {
76
+ center.store.update({ unreadCount: count })
77
+ }
78
+ },
79
+ )
80
+
70
81
  // Realtime notifications announce stored ones, so the unread count updates right away.
71
82
  let stopListening: (() => void) | null = null
72
83
  onMounted(() => {
@@ -144,6 +144,7 @@ onBeforeUnmount(() => stopListening?.())
144
144
  >
145
145
  <kbd
146
146
  v-if="hydrated && suffix?.type === 'keyBinding'"
147
+ dir="ltr"
147
148
  class="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline"
148
149
  >{{ formatKeyBinding(suffix.keyBinding) }}</kbd
149
150
  >
@@ -26,6 +26,7 @@ const steps = computed(() => shortcutSteps(props.binding))
26
26
  t('shortcuts.then', {}, 'then')
27
27
  }}</span>
28
28
  <kbd
29
+ dir="ltr"
29
30
  class="bg-muted text-muted-foreground rounded border px-1.5 font-mono text-[10px] leading-5"
30
31
  >{{ step }}</kbd
31
32
  >
@@ -154,7 +154,7 @@ const tenants = computed(() =>
154
154
  <DropdownMenuItem v-else-if="tenancy.profileUrl" as-child>
155
155
  <Link :href="tenancy.profileUrl" class="w-full"
156
156
  ><Icon name="alfakit::panels.tenant-menu.profile-button" :fallback="SettingsIcon" />
157
- {{ t('tenancy.profile', {}, 'Settings') }}</Link
157
+ {{ tenancy.profileLabel ?? t('tenancy.profile', {}, 'Settings') }}</Link
158
158
  >
159
159
  </DropdownMenuItem>
160
160
  <MenuItemEntries v-if="tenancy.menu" :items="tenancy.menu" menu="tenantMenu" />
@@ -162,7 +162,7 @@ const tenants = computed(() =>
162
162
  <DropdownMenuItem v-else-if="tenancy.registrationUrl" as-child>
163
163
  <Link :href="tenancy.registrationUrl" class="w-full"
164
164
  ><Icon name="alfakit::panels.tenant-menu.registration-button" :fallback="PlusIcon" />
165
- {{ t('tenancy.register', {}, 'New') }}</Link
165
+ {{ tenancy.registrationLabel ?? t('tenancy.register', {}, 'New') }}</Link
166
166
  >
167
167
  </DropdownMenuItem>
168
168
  </DropdownMenuContent>
@@ -3,6 +3,7 @@ import type { TableRecordGroupSchema } from '@alfakit/core'
3
3
  import { ChevronDownIcon, ChevronRightIcon } from '@lucide/vue'
4
4
  import { computed } from 'vue'
5
5
  import { useT } from '../../context'
6
+ import { colorClass, textColorClasses } from '../../lib/colors'
6
7
  import { Icon } from '../../lib/icons'
7
8
  import Checkbox from '../ui/Checkbox.vue'
8
9
  import TableCell from '../ui/TableCell.vue'
@@ -20,6 +21,11 @@ const props = defineProps<{
20
21
  const emit = defineEmits<{ toggle: [] }>()
21
22
  const t = useT()
22
23
 
24
+ // An enum group shows its `HasIcon` icon, in its `HasColor` colour, before the title.
25
+ const groupIconClass = computed(
26
+ () => `size-4 shrink-0 ${colorClass(textColorClasses, props.group.color, 'gray')}`,
27
+ )
28
+
23
29
  const toggleLabel = computed(
24
30
  () =>
25
31
  `${props.collapsed ? t('table_grouping.expand', {}, 'Expand group') : t('table_grouping.collapse', {}, 'Collapse group')}: ${props.group.title}`,
@@ -56,6 +62,12 @@ const toggleLabel = computed(
56
62
  class="size-4 shrink-0"
57
63
  aria-hidden="true"
58
64
  />
65
+ <Icon
66
+ v-if="props.group.icon"
67
+ :name="props.group.icon"
68
+ :class="groupIconClass"
69
+ aria-hidden="true"
70
+ />
59
71
  <span class="flex flex-col items-start">
60
72
  <span class="font-medium">{{ props.group.title }}</span>
61
73
  <span
@@ -65,6 +77,21 @@ const toggleLabel = computed(
65
77
  >
66
78
  </span>
67
79
  </button>
80
+ <span
81
+ v-else-if="props.group.icon"
82
+ data-slot="table-group-title"
83
+ class="flex items-center gap-2"
84
+ >
85
+ <Icon :name="props.group.icon" :class="groupIconClass" aria-hidden="true" />
86
+ <span class="flex flex-col items-start">
87
+ <span class="font-medium">{{ props.group.title }}</span>
88
+ <span
89
+ v-if="props.group.description"
90
+ class="text-muted-foreground text-xs font-normal"
91
+ >{{ props.group.description }}</span
92
+ >
93
+ </span>
94
+ </span>
68
95
  <span v-else class="flex flex-col items-start">
69
96
  <span class="font-medium">{{ props.group.title }}</span>
70
97
  <span v-if="props.group.description" class="text-muted-foreground text-xs font-normal">{{
@@ -1,13 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import type { SelectColumnSchema } from '@alfakit/core'
3
3
  import { isOptionGroup } from '@alfakit/core'
4
- import { computed, ref } from 'vue'
4
+ import { computed, ref, useId } from 'vue'
5
5
  import { cn } from '../../lib/utils'
6
6
  import { useCellUpdate, type CellProps } from './cell-update'
7
7
 
8
8
  const props = defineProps<CellProps>()
9
9
  const schema = computed(() => props.column as SelectColumnSchema)
10
10
  const { update, saving, error } = useCellUpdate(props)
11
+ const errorId = useId()
11
12
  const value = ref(
12
13
  props.cell.state === null || props.cell.state === undefined ? '' : String(props.cell.state),
13
14
  )
@@ -29,7 +30,9 @@ const onChange = (event: Event) => {
29
30
  <select
30
31
  :value="value"
31
32
  :disabled="Boolean(props.cell.disabled) || saving"
33
+ :aria-label="props.column.label || props.column.name"
32
34
  :aria-invalid="error ? true : undefined"
35
+ :aria-describedby="error ? errorId : undefined"
33
36
  :class="
34
37
  cn(
35
38
  'border-input dark:bg-input/30 focus-visible:ring-ring/50 h-8 min-w-32 rounded-md border bg-transparent px-2 text-sm shadow-xs outline-none focus-visible:ring-[3px]',
@@ -48,6 +51,6 @@ const onChange = (event: Event) => {
48
51
  {{ option.label }}
49
52
  </option>
50
53
  </select>
51
- <span v-if="error" class="text-destructive block text-xs">{{ error }}</span>
54
+ <span v-if="error" :id="errorId" class="text-destructive block text-xs">{{ error }}</span>
52
55
  </span>
53
56
  </template>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { TextInputColumnSchema } from '@alfakit/core'
3
- import { computed, ref } from 'vue'
3
+ import { computed, ref, useId } from 'vue'
4
4
  import { colorClass, textColorClasses } from '../../lib/colors'
5
5
  import { Icon } from '../../lib/icons'
6
6
  import { cn } from '../../lib/utils'
@@ -10,9 +10,13 @@ import { useCellUpdate, type CellProps } from './cell-update'
10
10
  const props = defineProps<CellProps>()
11
11
  const schema = computed(() => props.column as TextInputColumnSchema)
12
12
  const { update, saving, error } = useCellUpdate(props)
13
- const initial =
14
- props.cell.state === null || props.cell.state === undefined ? '' : String(props.cell.state)
15
- const value = ref(initial)
13
+ const errorId = useId()
14
+ // The saved value: follows the cell after the table reloads, so changing the input back to
15
+ // an earlier value is saved too.
16
+ const initial = computed(() =>
17
+ props.cell.state === null || props.cell.state === undefined ? '' : String(props.cell.state),
18
+ )
19
+ const value = ref(initial.value)
16
20
  // `prefix()` / `suffix()`: text affixes around the input, like the TextInput field.
17
21
  const grouped = computed(() => Boolean(props.cell.prefix || props.cell.suffix))
18
22
  </script>
@@ -57,7 +61,9 @@ const grouped = computed(() => Boolean(props.cell.prefix || props.cell.suffix))
57
61
  :step="schema.step"
58
62
  :inputmode="schema.inputMode"
59
63
  :disabled="Boolean(props.cell.disabled) || saving"
64
+ :aria-label="props.column.label || props.column.name"
60
65
  :aria-invalid="error ? true : undefined"
66
+ :aria-describedby="error ? errorId : undefined"
61
67
  :class="
62
68
  cn(
63
69
  'h-8 min-w-32',
@@ -92,6 +98,6 @@ const grouped = computed(() => Boolean(props.cell.prefix || props.cell.suffix))
92
98
  >{{ props.cell.suffix }}</span
93
99
  >
94
100
  </span>
95
- <span v-if="error" class="text-destructive block text-xs">{{ error }}</span>
101
+ <span v-if="error" :id="errorId" class="text-destructive block text-xs">{{ error }}</span>
96
102
  </span>
97
103
  </template>
package/src/lib/Icon.vue CHANGED
@@ -1,8 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { iconAlias, iconAliasOverride, iconSource, isIconAlias } from '@alfakit/core'
3
- import { computed, onMounted, type Component, type HTMLAttributes } from 'vue'
3
+ import { computed, type Component, type HTMLAttributes } from 'vue'
4
4
  import { useOptionalRegistry } from '../context'
5
- import { iconMap, markIconsMounted, resolveIcon } from './icons'
5
+ import { useAppMounted } from './hydration'
6
+ import { iconMap, resolveIcon } from './icons'
6
7
  import { cn } from './utils'
7
8
 
8
9
  /**
@@ -22,8 +23,8 @@ const props = defineProps<{
22
23
  // `createAlfakit({ components: { 'panel:icon': MyIcon } })` renders every named icon with
23
24
  // another icon set; it receives `name`, `fallback` and `class`.
24
25
  const custom = useOptionalRegistry()?.resolve('panel:icon')
25
- // Named icons render once the page has hydrated (the server render shows none).
26
- onMounted(markIconsMounted)
26
+ // Named icons render once this app has hydrated (the server render shows none).
27
+ const mounted = useAppMounted()
27
28
 
28
29
  const fallbackName = computed(() =>
29
30
  typeof props.fallback === 'string' ? props.fallback : undefined,
@@ -49,7 +50,7 @@ const source = computed(() =>
49
50
  const component = computed(() => {
50
51
  if (source.value?.type !== 'name' || custom) return null
51
52
 
52
- const icons = iconMap()
53
+ const icons = iconMap(mounted.value)
53
54
 
54
55
  // Nothing until the icon map has loaded (and during the server render and hydration).
55
56
  if (!icons) return null
@@ -0,0 +1,35 @@
1
+ import { getCurrentInstance, onMounted, ref, type AppContext, type Ref } from 'vue'
2
+
3
+ /**
4
+ * Whether each app has mounted (hydrated). Kept per app, not per module: loaded icon chunks
5
+ * and lazy components outlive an app, so a second app hydrating in the same page would
6
+ * otherwise draw content its server markup does not contain.
7
+ */
8
+ const mountedApps = new WeakMap<AppContext, Ref<boolean>>()
9
+
10
+ /**
11
+ * Call in a component's setup: `false` until the component's app has mounted (so during
12
+ * the server render and hydration), then `true` for every component in that app, so
13
+ * content rendered later draws at once (reactive). Lazily loaded parts (named icons, heavy
14
+ * fields) render their server markup until then, whatever has loaded already.
15
+ */
16
+ export function useAppMounted(): Ref<boolean> {
17
+ const context = getCurrentInstance()?.appContext
18
+
19
+ // Outside a component there is no hydration to wait for.
20
+ if (!context) return ref(true)
21
+
22
+ let mounted = mountedApps.get(context)
23
+
24
+ if (!mounted) {
25
+ mounted = ref(false)
26
+ mountedApps.set(context, mounted)
27
+ }
28
+
29
+ const flag = mounted
30
+ onMounted(() => {
31
+ flag.value = true
32
+ })
33
+
34
+ return flag
35
+ }
package/src/lib/icons.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { defineAsyncComponent, ref, shallowRef, type Component } from 'vue'
1
+ import { defineAsyncComponent, shallowRef, type Component } from 'vue'
2
2
  import type * as IconModules from './icon-modules'
3
3
 
4
4
  /**
@@ -13,7 +13,7 @@ import type * as IconModules from './icon-modules'
13
13
  export type IconMap = typeof IconModules
14
14
 
15
15
  const loadedMap = shallowRef<IconMap | null>(null)
16
- const mounted = ref(false)
16
+
17
17
  let pending: Promise<IconMap> | undefined
18
18
 
19
19
  const cache = new Map<string, Component | null>()
@@ -37,19 +37,14 @@ export function preloadIcons(): void {
37
37
  loadIconMap()
38
38
  }
39
39
 
40
- /** `<Icon>`s call this once mounted: from then on named icons render (after hydration). */
41
- export function markIconsMounted(): void {
42
- mounted.value = true
43
- }
44
-
45
40
  /**
46
- * The icon map once it has loaded in the browser and the page has hydrated, else `null`
47
- * (reactive). Starts loading it.
41
+ * The icon map once it has loaded in the browser and the `<Icon>`'s app has mounted
42
+ * (`useAppMounted()`), else `null` (reactive). Starts loading it.
48
43
  */
49
- export function iconMap(): IconMap | null {
44
+ export function iconMap(mounted: boolean): IconMap | null {
50
45
  if (!loadedMap.value) loadIconMap()
51
46
 
52
- return mounted.value ? loadedMap.value : null
47
+ return mounted ? loadedMap.value : null
53
48
  }
54
49
 
55
50
  /**
package/src/lib/lazy.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { defineAsyncComponent, defineComponent, h, onMounted, ref, type Component } from 'vue'
1
+ import { defineAsyncComponent, defineComponent, h, type Component } from 'vue'
2
+ import { useAppMounted } from './hydration'
2
3
 
3
4
  /**
4
5
  * A component whose code (and heavy dependencies such as Tiptap or Chart.js) loads on
5
6
  * first use. The server render and hydration show `fallback` and never start the import;
6
- * once loaded, later renders show the component straight away.
7
+ * once the app has mounted, a loaded component renders straight away.
7
8
  */
8
9
  export function lazyComponent(
9
10
  name: string,
@@ -27,11 +28,9 @@ export function lazyComponent(
27
28
  name,
28
29
  inheritAttrs: false,
29
30
  setup(_, { attrs }) {
30
- const mounted = ref(false)
31
+ const mounted = useAppMounted()
31
32
 
32
- onMounted(() => (mounted.value = true))
33
-
34
- return () => h(loaded ?? (mounted.value ? Async : fallback), attrs)
33
+ return () => h(mounted.value ? (loaded ?? Async) : fallback, attrs)
35
34
  },
36
35
  })
37
36
  }
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { resolveToastPosition, type ToastPosition } from '@alfakit/core'
2
+ import { resolveToastPosition, STARTER_KIT_TOASTER_ID, type ToastPosition } from '@alfakit/core'
3
3
  import { onMounted, onUnmounted } from 'vue'
4
+ import { setToasterId } from '../components/notifications/flash-toasts'
4
5
  import Toaster from '../components/ui/Toaster.vue'
5
6
  import { useOptionalAlfakit } from '../context'
6
7
  import { claimToaster, createStandaloneContext, ensureFlashToasts } from './runtime'
@@ -38,15 +39,24 @@ let release: (() => void) | null = null
38
39
  onMounted(() => {
39
40
  if (!props.claimed) release = claimToaster()
40
41
 
42
+ // Our own toaster gets an id, so a `<Toaster />` the app's layout already mounts (the
43
+ // starter kits have one) doesn't show every Alfakit toast a second time.
44
+ if (props.toaster) setToasterId(STARTER_KIT_TOASTER_ID)
45
+
41
46
  ensureFlashToasts(context.registry)
42
47
  })
43
48
 
44
- onUnmounted(() => release?.())
49
+ onUnmounted(() => {
50
+ release?.()
51
+
52
+ if (props.toaster) setToasterId(undefined)
53
+ })
45
54
  </script>
46
55
 
47
56
  <template>
48
57
  <Toaster
49
58
  v-if="props.toaster"
59
+ :id="STARTER_KIT_TOASTER_ID"
50
60
  :position="resolveToastPosition(props.position, props.dir)"
51
61
  :dir="props.dir"
52
62
  rich-colors
@@ -50,6 +50,7 @@ function request() {
50
50
  <span class="sr-only sm:not-sr-only sm:flex-1 sm:text-start">{{ placeholder }}</span>
51
51
  <kbd
52
52
  v-if="hydrated && binding"
53
+ dir="ltr"
53
54
  class="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline"
54
55
  >{{ formatKeyBinding(binding) }}</kbd
55
56
  >
@@ -63,7 +63,7 @@ const visible = computed(
63
63
  <DropdownMenuSeparator v-if="tenancy.tenants.length > 0" />
64
64
  <DropdownMenuItem @select="router.visit(tenancy.registrationUrl!)">
65
65
  <Icon name="alfakit::panels.tenant-menu.registration-button" :fallback="PlusIcon" />
66
- {{ t('tenancy.register', {}, 'New') }}
66
+ {{ tenancy.registrationLabel ?? t('tenancy.register', {}, 'New') }}
67
67
  </DropdownMenuItem>
68
68
  </template>
69
69
  </DropdownMenuContent>