@alfakit/vue 0.1.9 → 0.1.10

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 (122) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/app/components.d.ts.map +1 -1
  3. package/dist/app/components.js +1 -0
  4. package/dist/app/components.js.map +1 -1
  5. package/dist/components/dev-tools/DevToolsInspector.js.map +1 -1
  6. package/dist/components/dev-tools/DevToolsInspector.vue.d.ts.map +1 -1
  7. package/dist/components/dev-tools/DevToolsInspector.vue_vue_type_script_setup_true_lang.js +1 -1
  8. package/dist/components/dev-tools/DevToolsInspector.vue_vue_type_script_setup_true_lang.js.map +1 -1
  9. package/dist/components/fields/CheckboxListField.js.map +1 -1
  10. package/dist/components/fields/CheckboxListField.vue.d.ts.map +1 -1
  11. package/dist/components/fields/CheckboxListField.vue_vue_type_script_setup_true_lang.js +2 -2
  12. package/dist/components/fields/CheckboxListField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  13. package/dist/components/fields/DatePickerField.js.map +1 -1
  14. package/dist/components/fields/DatePickerField.vue.d.ts.map +1 -1
  15. package/dist/components/fields/DatePickerField.vue_vue_type_script_setup_true_lang.js +34 -32
  16. package/dist/components/fields/DatePickerField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  17. package/dist/components/fields/RichEditorSuggestions.js.map +1 -1
  18. package/dist/components/fields/RichEditorSuggestions.vue.d.ts +1 -1
  19. package/dist/components/fields/RichEditorSuggestions.vue.d.ts.map +1 -1
  20. package/dist/components/fields/RichEditorSuggestions.vue_vue_type_script_setup_true_lang.js +29 -23
  21. package/dist/components/fields/RichEditorSuggestions.vue_vue_type_script_setup_true_lang.js.map +1 -1
  22. package/dist/components/fields/RichEditorToolbarGroup.js.map +1 -1
  23. package/dist/components/fields/RichEditorToolbarGroup.vue.d.ts.map +1 -1
  24. package/dist/components/fields/RichEditorToolbarGroup.vue_vue_type_script_setup_true_lang.js +9 -4
  25. package/dist/components/fields/RichEditorToolbarGroup.vue_vue_type_script_setup_true_lang.js.map +1 -1
  26. package/dist/components/file-manager/FileManagerView.js.map +1 -1
  27. package/dist/components/file-manager/FileManagerView.vue.d.ts.map +1 -1
  28. package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js +5 -1
  29. package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js.map +1 -1
  30. package/dist/components/layout/CalloutComponent.js.map +1 -1
  31. package/dist/components/layout/CalloutComponent.vue.d.ts.map +1 -1
  32. package/dist/components/layout/CalloutComponent.vue_vue_type_script_setup_true_lang.js +43 -39
  33. package/dist/components/layout/CalloutComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
  34. package/dist/components/layout/EmptyStateComponent.js.map +1 -1
  35. package/dist/components/layout/EmptyStateComponent.vue.d.ts.map +1 -1
  36. package/dist/components/layout/EmptyStateComponent.vue_vue_type_script_setup_true_lang.js +29 -26
  37. package/dist/components/layout/EmptyStateComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
  38. package/dist/components/layout/MixedContent.js.map +1 -1
  39. package/dist/components/layout/MixedContent.vue.d.ts +5 -3
  40. package/dist/components/layout/MixedContent.vue.d.ts.map +1 -1
  41. package/dist/components/layout/MixedContent.vue_vue_type_script_setup_true_lang.js +5 -4
  42. package/dist/components/layout/MixedContent.vue_vue_type_script_setup_true_lang.js.map +1 -1
  43. package/dist/components/layout/SectionComponent.js.map +1 -1
  44. package/dist/components/layout/SectionComponent.vue.d.ts.map +1 -1
  45. package/dist/components/layout/SectionComponent.vue_vue_type_script_setup_true_lang.js +12 -8
  46. package/dist/components/layout/SectionComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
  47. package/dist/components/notifications/NotificationsMenu.js.map +1 -1
  48. package/dist/components/notifications/NotificationsMenu.vue.d.ts.map +1 -1
  49. package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js +133 -104
  50. package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
  51. package/dist/components/panel/ShortcutsOverlay.js.map +1 -1
  52. package/dist/components/panel/ShortcutsOverlay.vue.d.ts.map +1 -1
  53. package/dist/components/panel/ShortcutsOverlay.vue_vue_type_script_setup_true_lang.js +20 -19
  54. package/dist/components/panel/ShortcutsOverlay.vue_vue_type_script_setup_true_lang.js.map +1 -1
  55. package/dist/components/table/ColumnManager.js.map +1 -1
  56. package/dist/components/table/ColumnManager.vue.d.ts.map +1 -1
  57. package/dist/components/table/ColumnManager.vue_vue_type_script_setup_true_lang.js +6 -6
  58. package/dist/components/table/ColumnManager.vue_vue_type_script_setup_true_lang.js.map +1 -1
  59. package/dist/components/table/Spreadsheet.js.map +1 -1
  60. package/dist/components/table/Spreadsheet.vue.d.ts.map +1 -1
  61. package/dist/components/table/Spreadsheet.vue_vue_type_script_setup_true_lang.js +37 -33
  62. package/dist/components/table/Spreadsheet.vue_vue_type_script_setup_true_lang.js.map +1 -1
  63. package/dist/components/translation-manager/TranslationManagerView.js.map +1 -1
  64. package/dist/components/translation-manager/TranslationManagerView.vue.d.ts.map +1 -1
  65. package/dist/components/translation-manager/TranslationManagerView.vue_vue_type_script_setup_true_lang.js +1 -1
  66. package/dist/components/translation-manager/TranslationManagerView.vue_vue_type_script_setup_true_lang.js.map +1 -1
  67. package/dist/components/ui/SidebarMenuButton.js.map +1 -1
  68. package/dist/components/ui/SidebarMenuButton.vue.d.ts.map +1 -1
  69. package/dist/components/ui/SidebarMenuButton.vue_vue_type_script_setup_true_lang.js +12 -12
  70. package/dist/components/ui/SidebarMenuButton.vue_vue_type_script_setup_true_lang.js.map +1 -1
  71. package/dist/components/widgets/StatCard.js.map +1 -1
  72. package/dist/components/widgets/StatCard.vue.d.ts.map +1 -1
  73. package/dist/components/widgets/StatCard.vue_vue_type_script_setup_true_lang.js +30 -30
  74. package/dist/components/widgets/StatCard.vue_vue_type_script_setup_true_lang.js.map +1 -1
  75. package/dist/eject-manifest.json +2 -2
  76. package/dist/pages/AlfakitPage.js.map +1 -1
  77. package/dist/pages/AlfakitPage.vue.d.ts.map +1 -1
  78. package/dist/pages/AlfakitPage.vue_vue_type_script_setup_true_lang.js +16 -19
  79. package/dist/pages/AlfakitPage.vue_vue_type_script_setup_true_lang.js.map +1 -1
  80. package/dist/standalone/AlfakitComponent.js.map +1 -1
  81. package/dist/standalone/AlfakitComponent.vue.d.ts.map +1 -1
  82. package/dist/standalone/AlfakitComponent.vue_vue_type_script_setup_true_lang.js +2 -2
  83. package/dist/standalone/AlfakitComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
  84. package/dist/standalone/runtime.d.ts.map +1 -1
  85. package/dist/standalone/runtime.js +25 -25
  86. package/dist/standalone/runtime.js.map +1 -1
  87. package/dist/standalone.css +2 -1
  88. package/dist/starter-kit/AlfakitStarterKitRoot.js.map +1 -1
  89. package/dist/starter-kit/AlfakitStarterKitRoot.vue.d.ts.map +1 -1
  90. package/dist/starter-kit/AlfakitStarterKitRoot.vue_vue_type_script_setup_true_lang.js +1 -1
  91. package/dist/starter-kit/AlfakitStarterKitRoot.vue_vue_type_script_setup_true_lang.js.map +1 -1
  92. package/dist/starter-kit/navigation.d.ts +6 -1
  93. package/dist/starter-kit/navigation.d.ts.map +1 -1
  94. package/dist/starter-kit/navigation.js +11 -10
  95. package/dist/starter-kit/navigation.js.map +1 -1
  96. package/dist/styles.css +2 -1
  97. package/dist/themes.css +4 -0
  98. package/package.json +2 -2
  99. package/src/app/components.ts +3 -0
  100. package/src/components/dev-tools/DevToolsInspector.vue +1 -1
  101. package/src/components/fields/CheckboxListField.vue +2 -2
  102. package/src/components/fields/DatePickerField.vue +1 -0
  103. package/src/components/fields/RichEditorSuggestions.vue +13 -6
  104. package/src/components/fields/RichEditorToolbarGroup.vue +16 -1
  105. package/src/components/file-manager/FileManagerView.vue +3 -1
  106. package/src/components/layout/CalloutComponent.vue +15 -2
  107. package/src/components/layout/EmptyStateComponent.vue +10 -1
  108. package/src/components/layout/MixedContent.vue +12 -4
  109. package/src/components/layout/SectionComponent.vue +20 -4
  110. package/src/components/notifications/NotificationsMenu.vue +45 -1
  111. package/src/components/panel/ShortcutsOverlay.vue +27 -22
  112. package/src/components/table/ColumnManager.vue +6 -4
  113. package/src/components/table/Spreadsheet.vue +9 -4
  114. package/src/components/translation-manager/TranslationManagerView.vue +1 -1
  115. package/src/components/ui/SidebarMenuButton.vue +6 -0
  116. package/src/components/widgets/StatCard.vue +6 -3
  117. package/src/pages/AlfakitPage.vue +13 -3
  118. package/src/standalone/AlfakitComponent.vue +8 -4
  119. package/src/standalone/runtime.ts +3 -0
  120. package/src/starter-kit/AlfakitStarterKitRoot.vue +3 -2
  121. package/src/starter-kit/navigation.ts +12 -1
  122. package/src/styles.css +2 -1
@@ -73,7 +73,7 @@ const toggle = (key: string, checked: boolean) => {
73
73
  <div class="flex flex-col gap-2">
74
74
  <div
75
75
  v-if="props.component.searchable || (props.component.bulkToggleable && !readOnly)"
76
- class="flex items-center gap-2"
76
+ class="flex min-w-0 flex-wrap items-center gap-2"
77
77
  >
78
78
  <Input
79
79
  v-if="props.component.searchable"
@@ -82,7 +82,7 @@ const toggle = (key: string, checked: boolean) => {
82
82
  :aria-label="props.component.searchPrompt ?? t('search', {}, 'Search')"
83
83
  class="h-8"
84
84
  />
85
- <div v-if="props.component.bulkToggleable && !readOnly" class="flex gap-1">
85
+ <div v-if="props.component.bulkToggleable && !readOnly" class="flex flex-wrap gap-1">
86
86
  <Button
87
87
  v-if="!props.component.selectAllAction?.hidden"
88
88
  type="button"
@@ -251,6 +251,7 @@ const weekStartsOn = computed(
251
251
  <PopoverContent class="w-auto p-0" align="start">
252
252
  <Calendar
253
253
  :model-value="calendarValue"
254
+ :calendar-label="props.component.label || t('form.pick_date', {}, 'Pick a date')"
254
255
  :locale="locale"
255
256
  :dir="direction"
256
257
  :today="toCalendarDate(wallClockNow(props.component.timezone))"
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import type { EditorSuggestions } from '@alfakit/core'
2
+ import { fixedContainingBlockOffset, type EditorSuggestions } from '@alfakit/core'
3
3
  import type { Editor } from '@tiptap/vue-3'
4
+ import { computed } from 'vue'
4
5
  import { useT } from '../../context'
5
6
  import { cn } from '../../lib/utils'
6
7
  import { useStore } from '../../composables/use-store'
@@ -13,6 +14,16 @@ const props = defineProps<{
13
14
  }>()
14
15
  const t = useT()
15
16
  const state = useStore(props.suggestions.store)
17
+ // Inside a modal the dialog's transform is the containing block of this fixed list.
18
+ const position = computed(() => {
19
+ const coords = state.value.coords
20
+
21
+ if (!coords) return undefined
22
+
23
+ const offset = fixedContainingBlockOffset(props.editor?.view.dom)
24
+
25
+ return { left: `${coords.left - offset.x}px`, top: `${coords.bottom + 4 - offset.y}px` }
26
+ })
16
27
  </script>
17
28
 
18
29
  <template>
@@ -23,11 +34,7 @@ const state = useStore(props.suggestions.store)
23
34
  :aria-label="t('editor.suggestions', {}, 'Suggestions')"
24
35
  data-slot="rich-editor-suggestions"
25
36
  class="bg-popover text-popover-foreground fixed z-50 min-w-48 overflow-hidden rounded-md border p-1 shadow-md"
26
- :style="
27
- state.coords
28
- ? { left: `${state.coords.left}px`, top: `${state.coords.bottom + 4}px` }
29
- : undefined
30
- "
37
+ :style="position"
31
38
  >
32
39
  <div
33
40
  v-if="state.loading && state.items.length === 0"
@@ -44,6 +44,20 @@ const active = computed(() => {
44
44
  return buttons.value.some((name) => props.commands[name]?.active?.(props.editor))
45
45
  })
46
46
  const label = (name: string) => commandLabel(name, props.commands, translate)
47
+ // After a pick, typing goes on in the editor (the command focused it); the menu would
48
+ // otherwise return focus to its trigger. Closing without a pick (Escape) still does.
49
+ let picked = false
50
+ const pick = (name: string) => {
51
+ picked = true
52
+ emit('run', name)
53
+ }
54
+ const closeAutoFocus = (event: Event) => {
55
+ if (!picked) return
56
+
57
+ picked = false
58
+ event.preventDefault()
59
+ props.editor.commands.focus()
60
+ }
47
61
  const pressed = (name: string) => {
48
62
  void props.tick
49
63
 
@@ -79,6 +93,7 @@ const pressed = (name: string) => {
79
93
  align="start"
80
94
  :aria-label="props.group.label"
81
95
  :class="cn(!props.group.textual && 'flex min-w-0 flex-row gap-0.5')"
96
+ @close-auto-focus="closeAutoFocus"
82
97
  >
83
98
  <DropdownMenuItem
84
99
  v-for="name in buttons"
@@ -86,7 +101,7 @@ const pressed = (name: string) => {
86
101
  :aria-label="props.group.textual ? undefined : label(name)"
87
102
  :title="label(name)"
88
103
  :class="cn(pressed(name) && 'bg-accent text-accent-foreground')"
89
- @select="emit('run', name)"
104
+ @select="pick(name)"
90
105
  >
91
106
  <RichEditorToolIcon :name="name" :label="label(name)" :tools="props.tools" />
92
107
  <span v-if="props.group.textual">{{ label(name) }}</span>
@@ -1112,7 +1112,8 @@ const onPreviewKeyDown = (event: KeyboardEvent) => {
1112
1112
  :open="dialog?.type === 'createFolder' || dialog?.type === 'rename'"
1113
1113
  @update:open="onDialogOpen"
1114
1114
  >
1115
- <DialogContent data-slot="file-manager-dialog">
1115
+ <!-- The title and the labelled field say it all: no description. -->
1116
+ <DialogContent data-slot="file-manager-dialog" :aria-describedby="undefined">
1116
1117
  <form class="flex flex-col gap-4" @submit.prevent="submitName">
1117
1118
  <DialogHeader>
1118
1119
  <DialogTitle>
@@ -1396,6 +1397,7 @@ const onPreviewKeyDown = (event: KeyboardEvent) => {
1396
1397
  <DialogContent
1397
1398
  class="flex max-h-[90vh] flex-col sm:max-w-4xl"
1398
1399
  data-slot="file-manager-preview"
1400
+ :aria-describedby="undefined"
1399
1401
  @keydown="onPreviewKeyDown"
1400
1402
  >
1401
1403
  <DialogHeader>
@@ -6,7 +6,9 @@ import { colorClass, textColorClasses } from '../../lib/colors'
6
6
  import { Icon } from '../../lib/icons'
7
7
  import { cn } from '../../lib/utils'
8
8
  import { usePageActionContext } from '../actions/action-context'
9
+ import { useOptionalFormStore } from '../form/form-context'
9
10
  import ActionList from '../actions/ActionList.vue'
11
+ import { useFieldActionContext } from '../schema/field-actions'
10
12
  import MixedContent from './MixedContent.vue'
11
13
 
12
14
  const CALLOUT_COLORS: Record<string, string> = {
@@ -29,7 +31,12 @@ const CALLOUT_ICON_SIZES: Record<string, string> = {
29
31
  }
30
32
 
31
33
  const props = defineProps<{ component: CalloutSchema }>()
32
- const context = usePageActionContext()
34
+ // Inside a form the actions run as field actions of this component, so `$get` / `$set`
35
+ // reach the form (like a section's header actions).
36
+ const form = useOptionalFormStore()
37
+ const pageContext = usePageActionContext()
38
+ const fieldContext = useFieldActionContext(() => props.component)
39
+ const context = computed(() => (form ? fieldContext.value : pageContext))
33
40
  const tone = computed(() => props.component.color ?? props.component.status)
34
41
  // `iconColor()`, or the status colour once `color(null)` removed the background.
35
42
  const iconColor = computed(
@@ -78,10 +85,16 @@ const iconColor = computed(
78
85
  size="sm"
79
86
  :class="cn('mt-2', actionsAlignmentClass(props.component.footerActionsAlignment))"
80
87
  />
81
- <MixedContent :items="props.component.footer" name="callout-footer" class="mt-2" />
88
+ <MixedContent
89
+ :items="props.component.footer"
90
+ :context="context"
91
+ name="callout-footer"
92
+ class="mt-2"
93
+ />
82
94
  </div>
83
95
  <MixedContent
84
96
  :items="props.component.controls"
97
+ :context="context"
85
98
  name="callout-controls"
86
99
  class="-me-1 -mt-1 shrink-0 gap-1"
87
100
  />
@@ -1,14 +1,22 @@
1
1
  <script setup lang="ts">
2
2
  import type { EmptyStateComponentSchema } from '@alfakit/core'
3
+ import { computed } from 'vue'
3
4
  import { colorClass, iconBackgroundColorClasses } from '../../lib/colors'
4
5
  import { Icon } from '../../lib/icons'
5
6
  import { cn } from '../../lib/utils'
6
7
  import { usePageActionContext } from '../actions/action-context'
8
+ import { useOptionalFormStore } from '../form/form-context'
7
9
  import ActionList from '../actions/ActionList.vue'
10
+ import { useFieldActionContext } from '../schema/field-actions'
8
11
  import MixedContent from './MixedContent.vue'
9
12
 
10
13
  const props = defineProps<{ component: EmptyStateComponentSchema }>()
11
- const context = usePageActionContext()
14
+ // Inside a form the actions run as field actions of this component, so `$get` / `$set`
15
+ // reach the form (like a section's header actions).
16
+ const form = useOptionalFormStore()
17
+ const pageContext = usePageActionContext()
18
+ const fieldContext = useFieldActionContext(() => props.component)
19
+ const context = computed(() => (form ? fieldContext.value : pageContext))
12
20
  </script>
13
21
 
14
22
  <template>
@@ -49,6 +57,7 @@ const context = usePageActionContext()
49
57
  />
50
58
  <MixedContent
51
59
  :items="props.component.footer"
60
+ :context="context"
52
61
  name="empty-state-footer"
53
62
  class="justify-center"
54
63
  />
@@ -1,5 +1,10 @@
1
1
  <script setup lang="ts">
2
- import type { AnyActionSchema, ContentSlotItem, SchemaComponent } from '@alfakit/core'
2
+ import type {
3
+ ActionContext,
4
+ AnyActionSchema,
5
+ ContentSlotItem,
6
+ SchemaComponent,
7
+ } from '@alfakit/core'
3
8
  import { computed, type HTMLAttributes } from 'vue'
4
9
  import { cn } from '../../lib/utils'
5
10
  import { usePageActionContext } from '../actions/action-context'
@@ -8,16 +13,19 @@ import RenderComponent from '../schema/RenderComponent.vue'
8
13
 
9
14
  /**
10
15
  * Actions and components placed in a layout component's fixed spot (a section's
11
- * `afterHeader()`, a callout's `controls()`). Actions run in the page's action context,
12
- * like section header actions.
16
+ * `afterHeader()`, a callout's `controls()`). Actions run in the owner's action context
17
+ * (`context`: inside a form, the owner's field context, so `$get` / `$set` work), else the
18
+ * page's.
13
19
  */
14
20
  const props = defineProps<{
15
21
  items: ContentSlotItem[] | undefined
16
22
  name: string
17
23
  class?: HTMLAttributes['class']
24
+ context?: ActionContext
18
25
  }>()
19
26
 
20
- const context = usePageActionContext()
27
+ const pageContext = usePageActionContext()
28
+ const context = computed(() => props.context ?? pageContext)
21
29
 
22
30
  type Group =
23
31
  | { kind: 'actions'; key: string; actions: AnyActionSchema[] }
@@ -104,7 +104,11 @@ const t = useT()
104
104
  :context="context"
105
105
  size="sm"
106
106
  />
107
- <MixedContent :items="props.component.afterHeader" name="section-after-header" />
107
+ <MixedContent
108
+ :items="props.component.afterHeader"
109
+ name="section-after-header"
110
+ :context="context"
111
+ />
108
112
  <CollapsibleTrigger v-if="props.component.collapsible" as-child>
109
113
  <Button
110
114
  type="button"
@@ -155,7 +159,11 @@ const t = useT()
155
159
  :context="context"
156
160
  :class="actionsAlignmentClass(props.component.footerActionsAlignment) || undefined"
157
161
  />
158
- <MixedContent :items="props.component.footer" name="section-footer-content" />
162
+ <MixedContent
163
+ :items="props.component.footer"
164
+ name="section-footer-content"
165
+ :context="context"
166
+ />
159
167
  </div>
160
168
  </div>
161
169
  </CollapsibleContent>
@@ -208,7 +216,11 @@ const t = useT()
208
216
  :context="context"
209
217
  size="sm"
210
218
  />
211
- <MixedContent :items="props.component.afterHeader" name="section-after-header" />
219
+ <MixedContent
220
+ :items="props.component.afterHeader"
221
+ name="section-after-header"
222
+ :context="context"
223
+ />
212
224
  <CollapsibleTrigger v-if="props.component.collapsible" as-child>
213
225
  <Button
214
226
  type="button"
@@ -250,7 +262,11 @@ const t = useT()
250
262
  :context="context"
251
263
  :class="actionsAlignmentClass(props.component.footerActionsAlignment) || undefined"
252
264
  />
253
- <MixedContent :items="props.component.footer" name="section-footer-content" />
265
+ <MixedContent
266
+ :items="props.component.footer"
267
+ name="section-footer-content"
268
+ :context="context"
269
+ />
254
270
  </div>
255
271
  </CollapsibleContent>
256
272
  </section>
@@ -4,9 +4,13 @@ import {
4
4
  notificationTintStyles,
5
5
  onDatabaseNotificationsSent,
6
6
  onOpenDatabaseNotifications,
7
+ pwaStore,
8
+ startPwa,
9
+ togglePushNotifications,
7
10
  } from '@alfakit/core'
8
- import { BellIcon, CheckCheckIcon, Trash2Icon } from '@lucide/vue'
11
+ import { BellIcon, BellOffIcon, BellRingIcon, CheckCheckIcon, Trash2Icon } from '@lucide/vue'
9
12
  import { computed, onMounted, onScopeDispose, ref, watch } from 'vue'
13
+ import { toast } from 'vue-sonner'
10
14
  import { useStore } from '../../composables/use-store'
11
15
  import { useDirection, useFormatter, usePanel, useT } from '../../context'
12
16
  import { cssColor, textColorClasses } from '../../lib/colors'
@@ -94,6 +98,24 @@ onScopeDispose(() => stopOpenListener?.())
94
98
 
95
99
  onScopeDispose(() => center?.stopPolling())
96
100
  const format = useFormatter()
101
+
102
+ // `pwa(push: true)` in a starter kit: the app's user menu has no AlfaKit entries, so Web
103
+ // Push is turned on or off from the bell's menu.
104
+ const pwa = useStore(pwaStore)
105
+ const pushToggle = computed(
106
+ () =>
107
+ Boolean(panel.value.starterKit && panel.value.pwa?.push) && pwa.value.push !== 'unsupported',
108
+ )
109
+ onMounted(() => {
110
+ if (panel.value.starterKit && panel.value.pwa?.push) startPwa(panel.value.pwa)
111
+ })
112
+ async function togglePush() {
113
+ const { message } = await togglePushNotifications()
114
+ const text = t(message.key, {}, message.fallback)
115
+
116
+ if (message.error) toast.error(text)
117
+ else toast.success(text)
118
+ }
97
119
  </script>
98
120
 
99
121
  <template>
@@ -150,6 +172,28 @@ const format = useFormatter()
150
172
  <div class="flex items-center justify-between border-b px-3 py-2">
151
173
  <span class="text-sm font-semibold">{{ t('notifications', {}, 'Notifications') }}</span>
152
174
  <div class="flex items-center gap-1">
175
+ <Button
176
+ v-if="pushToggle"
177
+ type="button"
178
+ variant="ghost"
179
+ size="icon-sm"
180
+ data-slot="pwa-push"
181
+ :disabled="pwa.pushBusy || pwa.push === 'denied'"
182
+ :title="
183
+ pwa.push === 'subscribed'
184
+ ? t('pwa.notifications.disable', {}, 'Turn off notifications')
185
+ : t('pwa.notifications.enable', {}, 'Turn on notifications')
186
+ "
187
+ :aria-label="
188
+ pwa.push === 'subscribed'
189
+ ? t('pwa.notifications.disable', {}, 'Turn off notifications')
190
+ : t('pwa.notifications.enable', {}, 'Turn on notifications')
191
+ "
192
+ @click="void togglePush()"
193
+ >
194
+ <BellOffIcon v-if="pwa.push === 'subscribed'" />
195
+ <BellRingIcon v-else />
196
+ </Button>
153
197
  <Button
154
198
  v-if="state.unreadCount > 0"
155
199
  type="button"
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { ShortcutListGroup, ShortcutListItem } from '@alfakit/core'
3
3
  import {
4
- bindingFromEvent,
4
+ ShortcutRecorder,
5
5
  flattenActionGroup,
6
6
  saveShortcutOverrides,
7
7
  shortcutOverlayGroups,
@@ -76,47 +76,52 @@ const change = (item: ShortcutListItem) => {
76
76
  recording.value = item.id
77
77
  }
78
78
 
79
- // While recording, the next key press is the new binding (Escape cancels). Listening in the
80
- // capture phase keeps the key from closing the dialog or running another shortcut.
79
+ // While recording, the keys pressed are the new binding: one key (with modifiers) or a
80
+ // sequence ("g o"), finished a second after the last key (Escape cancels). Listening in
81
+ // the capture phase keeps the keys from closing the dialog or running another shortcut.
81
82
  watch(recording, (id, _old, onCleanup) => {
82
83
  if (!id || typeof window === 'undefined') return
83
84
 
84
85
  shortcuts.setPaused(true)
85
86
 
86
- const listener = (event: KeyboardEvent) => {
87
- event.preventDefault()
88
- event.stopPropagation()
89
-
90
- if (event.key === 'Escape') {
87
+ const recorder = new ShortcutRecorder({
88
+ onDone: (binding) => {
91
89
  recording.value = null
92
- message.value = t('shortcuts.cancelled', {}, 'Change cancelled.')
93
90
 
94
- return
95
- }
91
+ const conflict = shortcuts.conflict([binding], id)
96
92
 
97
- const binding = bindingFromEvent(event)
93
+ if (conflict) {
94
+ message.value = t(
95
+ 'shortcuts.conflict',
96
+ { label: conflict.label },
97
+ `Already used by “${conflict.label}”.`,
98
+ )
98
99
 
99
- if (!binding) return
100
+ return
101
+ }
100
102
 
101
- const conflict = shortcuts.conflict([binding], id)
103
+ void save(id, [binding])
104
+ },
105
+ })
102
106
 
103
- recording.value = null
107
+ const listener = (event: KeyboardEvent) => {
108
+ event.preventDefault()
109
+ event.stopPropagation()
104
110
 
105
- if (conflict) {
106
- message.value = t(
107
- 'shortcuts.conflict',
108
- { label: conflict.label },
109
- `Already used by “${conflict.label}”.`,
110
- )
111
+ if (event.key === 'Escape') {
112
+ recorder.cancel()
113
+ recording.value = null
114
+ message.value = t('shortcuts.cancelled', {}, 'Change cancelled.')
111
115
 
112
116
  return
113
117
  }
114
118
 
115
- void save(id, [binding])
119
+ recorder.press(event)
116
120
  }
117
121
 
118
122
  window.addEventListener('keydown', listener, true)
119
123
  onCleanup(() => {
124
+ recorder.cancel()
120
125
  window.removeEventListener('keydown', listener, true)
121
126
  shortcuts.setPaused(false)
122
127
  })
@@ -169,7 +169,7 @@ const drop = (name: string) => {
169
169
  dropTarget.value = null
170
170
  }
171
171
 
172
- const save = async (order: string[]): Promise<boolean> => {
172
+ const save = async (order: string[], options: { reload?: boolean } = {}): Promise<boolean> => {
173
173
  const endpoint = panel.value.endpoints.tableColumns
174
174
 
175
175
  if (!endpoint) return false
@@ -177,7 +177,7 @@ const save = async (order: string[]): Promise<boolean> => {
177
177
  saving.value = true
178
178
 
179
179
  try {
180
- await saveColumnOrder(endpoint, props.token, props.component, order)
180
+ await saveColumnOrder(endpoint, props.token, props.component, order, options)
181
181
 
182
182
  return true
183
183
  } catch (error) {
@@ -273,7 +273,8 @@ const apply = async () => {
273
273
  announcement.value = ''
274
274
  open.value = false
275
275
 
276
- if (order) await save(order)
276
+ // The visibility visit renders the new order too: no second reload racing it.
277
+ if (order) await save(order, { reload: !columns })
277
278
 
278
279
  if (columns) visitTable(props.table.identifier, { columns }, ['page'])
279
280
  }
@@ -282,7 +283,8 @@ const apply = async () => {
282
283
  const reset = async () => {
283
284
  if (reorderable.value) {
284
285
  orderDraft.value = null
285
- await save([])
286
+ // The visibility visit below renders the defined order too.
287
+ await save([], { reload: !toggleable.value })
286
288
  }
287
289
 
288
290
  if (toggleable.value) {
@@ -524,15 +524,20 @@ const vFocus = { mounted: (element: HTMLElement) => element.focus() }
524
524
  <DialogHeader>
525
525
  <DialogTitle>{{ t('table.spreadsheet.paste_title', {}, 'Paste cells') }}</DialogTitle>
526
526
  <DialogDescription>
527
- {{
527
+ <!-- One line per count: translations can't join two sentences. -->
528
+ <span class="block">{{
528
529
  choice(
529
530
  'table.spreadsheet.paste_summary',
530
531
  preview?.plan.changes.length ?? 0,
531
532
  {},
532
533
  ':count cell will change|:count cells will change',
533
534
  )
534
- }}
535
- <template v-if="(preview?.plan.skipped ?? 0) > 0">
535
+ }}</span>
536
+ <span
537
+ v-if="(preview?.plan.skipped ?? 0) > 0"
538
+ class="block"
539
+ data-slot="spreadsheet-paste-skipped"
540
+ >
536
541
  {{
537
542
  choice(
538
543
  'table.spreadsheet.paste_skipped',
@@ -541,7 +546,7 @@ const vFocus = { mounted: (element: HTMLElement) => element.focus() }
541
546
  ':count read-only cell is skipped|:count read-only cells are skipped',
542
547
  )
543
548
  }}
544
- </template>
549
+ </span>
545
550
  </DialogDescription>
546
551
  </DialogHeader>
547
552
  <div data-slot="spreadsheet-paste" class="flex flex-col gap-3">
@@ -356,7 +356,7 @@ const editLabel = (key: string, locale: string) =>
356
356
  <tr>
357
357
  <th
358
358
  scope="col"
359
- class="bg-muted sticky start-0 z-10 min-w-56 border-b px-3 py-2 text-start font-medium"
359
+ class="bg-card sticky start-0 z-10 min-w-56 border-b [background-image:linear-gradient(var(--muted),var(--muted))] px-3 py-2 text-start font-medium"
360
360
  >
361
361
  {{ t('translation-manager.key', {}, 'Key') }}
362
362
  </th>
@@ -8,6 +8,10 @@ import TooltipContent from './TooltipContent.vue'
8
8
  import TooltipTrigger from './TooltipTrigger.vue'
9
9
  import { useSidebar } from './use-sidebar'
10
10
 
11
+ // Attributes from an `as-child` parent (CollapsibleTrigger's click handler and ARIA state,
12
+ // a nav item's extra attributes) go on the button, not on the renderless Tooltip root.
13
+ defineOptions({ inheritAttrs: false })
14
+
11
15
  const props = withDefaults(
12
16
  defineProps<
13
17
  PrimitiveProps & {
@@ -27,6 +31,7 @@ const { isMobile, state } = useSidebar()
27
31
  <Tooltip v-if="props.tooltip">
28
32
  <TooltipTrigger as-child>
29
33
  <Primitive
34
+ v-bind="$attrs"
30
35
  data-slot="sidebar-menu-button"
31
36
  data-sidebar="menu-button"
32
37
  :data-size="props.size"
@@ -46,6 +51,7 @@ const { isMobile, state } = useSidebar()
46
51
  </Tooltip>
47
52
  <Primitive
48
53
  v-else
54
+ v-bind="$attrs"
49
55
  data-slot="sidebar-menu-button"
50
56
  data-sidebar="menu-button"
51
57
  :data-size="props.size"
@@ -39,7 +39,9 @@ const props = defineProps<{ stat: StatSchema }>()
39
39
  >
40
40
  <div class="flex items-start justify-between gap-3">
41
41
  <div class="flex flex-col gap-1">
42
- <span class="text-muted-foreground text-sm font-medium">{{ props.stat.label }}</span>
42
+ <span class="text-muted-foreground text-sm font-medium"
43
+ ><bdi>{{ props.stat.label }}</bdi></span
44
+ >
43
45
  <span
44
46
  v-if="(props.stat.value === null || props.stat.value === '') && props.stat.placeholder"
45
47
  class="text-muted-foreground text-2xl font-semibold tracking-tight"
@@ -54,7 +56,7 @@ const props = defineProps<{ stat: StatSchema }>()
54
56
  props.stat.color && textColorClasses[props.stat.color],
55
57
  )
56
58
  "
57
- >{{ props.stat.value }}</span
59
+ ><bdi>{{ props.stat.value }}</bdi></span
58
60
  >
59
61
  <span
60
62
  v-if="props.stat.description"
@@ -72,7 +74,8 @@ const props = defineProps<{ stat: StatSchema }>()
72
74
  :name="props.stat.descriptionIcon"
73
75
  class="size-3.5"
74
76
  />
75
- {{ props.stat.description }}
77
+ <!-- Mixed-direction text (a Latin "-3% this month" in Arabic) keeps its order. -->
78
+ <bdi>{{ props.stat.description }}</bdi>
76
79
  <Icon
77
80
  v-if="props.stat.descriptionIcon && props.stat.descriptionIconPosition === 'after'"
78
81
  :name="props.stat.descriptionIcon"
@@ -1,5 +1,10 @@
1
1
  <script setup lang="ts">
2
- import { openDefaultAction, resolveLazyComponents, type DefaultActionOpener } from '@alfakit/core'
2
+ import {
3
+ customPageProps,
4
+ openDefaultAction,
5
+ resolveLazyComponents,
6
+ type DefaultActionOpener,
7
+ } from '@alfakit/core'
3
8
  import { Head } from '@inertiajs/vue3'
4
9
  import { computed, onMounted, watch } from 'vue'
5
10
  import { providePageActionContext } from '../components/actions/action-context'
@@ -28,6 +33,11 @@ const title = computed(() =>
28
33
  : panel.value.brandName,
29
34
  )
30
35
  const custom = computed(() => (page.value?.view ? pages[page.value.view.component] : undefined))
36
+ // A custom component brings its own `PageShell` (as in panel mode, where it is the Inertia
37
+ // page itself) and takes the live props, so `reloadProps()` reaches it.
38
+ const customProps = computed(() =>
39
+ customPageProps(page.value?.view?.props, props.value as Record<string, unknown>),
40
+ )
31
41
 
32
42
  // The page's default action (`$defaultAction`, `?tableAction=`) opens once the page has
33
43
  // hydrated, and once per page: re-renders after the action ran don't open it again.
@@ -61,9 +71,9 @@ providePageActionContext({
61
71
  )
62
72
  }}
63
73
  </div>
74
+ <component :is="custom" v-else-if="page.view && custom" v-bind="customProps" :page="page" />
64
75
  <PageShell v-else :page="page">
65
- <component :is="custom" v-if="page.view && custom" v-bind="page.view.props" :page="page" />
66
- <PageContent v-else :page="page" />
76
+ <PageContent :page="page" />
67
77
  </PageShell>
68
78
  </template>
69
79
  </template>
@@ -130,15 +130,19 @@ watch(
130
130
  // on the page renders one (checked after every component mounted).
131
131
  const ownsToaster = ref(false)
132
132
 
133
- onMounted(() => {
134
- ensureStandaloneRuntime()
135
-
136
- // Partial reloads (sorting, paging, inline edits) request this component's own prop.
133
+ // Partial reloads (sorting, paging, inline edits) request this component's own prop.
134
+ // Registered in setup (browser only), before the children's `onMounted`: a `deferLoading()`
135
+ // table's first reload must already ask for this prop, not the panel `page` prop.
136
+ if (typeof window !== 'undefined') {
137
137
  watch(
138
138
  () => findStandalonePropName(inertiaPage.props as Record<string, unknown>, props.component),
139
139
  (name, _old, onCleanup) => onCleanup(registerStandaloneProp(name ?? null)),
140
140
  { immediate: true },
141
141
  )
142
+ }
143
+
144
+ onMounted(() => {
145
+ ensureStandaloneRuntime()
142
146
 
143
147
  // The watcher runs before the new payload renders: hand it over, or the response would
144
148
  // count as stale and lazy widgets (a calendar after its action) keep their old schema.
@@ -3,6 +3,7 @@ import {
3
3
  ComponentRegistry,
4
4
  FlashNotificationTracker,
5
5
  Translator,
6
+ installPrefetchInvalidation,
6
7
  reloadOnExpiredPage,
7
8
  resolveRequestErrorNotification,
8
9
  type NotificationSchema,
@@ -27,6 +28,8 @@ export function ensureStandaloneRuntime(): void {
27
28
 
28
29
  browserReady = true
29
30
  reloadOnExpiredPage()
31
+ // Pages restored by Back after a change are reloaded; shared with a surrounding panel.
32
+ installPrefetchInvalidation()
30
33
  }
31
34
 
32
35
  /**