@alfakit/vue 0.1.1 → 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 (115) hide show
  1. package/CHANGELOG.md +8 -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/fields/ColorPickerField.js.map +1 -1
  11. package/dist/components/fields/ColorPickerField.vue.d.ts.map +1 -1
  12. package/dist/components/fields/ColorPickerField.vue_vue_type_script_setup_true_lang.js +1 -0
  13. package/dist/components/fields/ColorPickerField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  14. package/dist/components/fields/ComboboxSelect.js.map +1 -1
  15. package/dist/components/fields/ComboboxSelect.vue.d.ts.map +1 -1
  16. package/dist/components/fields/ComboboxSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  17. package/dist/components/fields/ComboboxSelect.vue_vue_type_script_setup_true_lang.js.map +1 -1
  18. package/dist/components/fields/FileUploadField.js.map +1 -1
  19. package/dist/components/fields/FileUploadField.vue.d.ts.map +1 -1
  20. package/dist/components/fields/FileUploadField.vue_vue_type_script_setup_true_lang.js +183 -183
  21. package/dist/components/fields/FileUploadField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  22. package/dist/components/fields/OneTimeCodeInputField.js.map +1 -1
  23. package/dist/components/fields/OneTimeCodeInputField.vue_vue_type_script_setup_true_lang.js +1 -2
  24. package/dist/components/fields/OneTimeCodeInputField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  25. package/dist/components/fields/SelectField.js.map +1 -1
  26. package/dist/components/fields/SelectField.vue.d.ts.map +1 -1
  27. package/dist/components/fields/SelectField.vue_vue_type_script_setup_true_lang.js +2 -2
  28. package/dist/components/fields/SelectField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  29. package/dist/components/fields/TableColumnHeaders.js.map +1 -1
  30. package/dist/components/fields/TableColumnHeaders.vue.d.ts.map +1 -1
  31. package/dist/components/fields/TableColumnHeaders.vue_vue_type_script_setup_true_lang.js +6 -3
  32. package/dist/components/fields/TableColumnHeaders.vue_vue_type_script_setup_true_lang.js.map +1 -1
  33. package/dist/components/file-manager/FileManagerView.js.map +1 -1
  34. package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js +1 -1
  35. package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js.map +1 -1
  36. package/dist/components/form/FormBlock.js.map +1 -1
  37. package/dist/components/form/FormBlock.vue.d.ts.map +1 -1
  38. package/dist/components/form/FormBlock.vue_vue_type_script_setup_true_lang.js +81 -77
  39. package/dist/components/form/FormBlock.vue_vue_type_script_setup_true_lang.js.map +1 -1
  40. package/dist/components/form/form-context.js +25 -25
  41. package/dist/components/form/form-context.js.map +1 -1
  42. package/dist/components/layout/FusedGroupComponent.js.map +1 -1
  43. package/dist/components/layout/FusedGroupComponent.vue.d.ts.map +1 -1
  44. package/dist/components/layout/FusedGroupComponent.vue_vue_type_script_setup_true_lang.js +1 -0
  45. package/dist/components/layout/FusedGroupComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
  46. package/dist/components/layout/TabsComponent.js.map +1 -1
  47. package/dist/components/layout/TabsComponent.vue.d.ts.map +1 -1
  48. package/dist/components/layout/TabsComponent.vue_vue_type_script_setup_true_lang.js +76 -55
  49. package/dist/components/layout/TabsComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
  50. package/dist/components/notifications/NotificationsMenu.js.map +1 -1
  51. package/dist/components/notifications/NotificationsMenu.vue.d.ts.map +1 -1
  52. package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js +44 -42
  53. package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
  54. package/dist/components/panel/GlobalSearch.js.map +1 -1
  55. package/dist/components/panel/GlobalSearch.vue.d.ts.map +1 -1
  56. package/dist/components/panel/GlobalSearch.vue_vue_type_script_setup_true_lang.js +1 -0
  57. package/dist/components/panel/GlobalSearch.vue_vue_type_script_setup_true_lang.js.map +1 -1
  58. package/dist/components/panel/ShortcutKeys.js.map +1 -1
  59. package/dist/components/panel/ShortcutKeys.vue.d.ts.map +1 -1
  60. package/dist/components/panel/ShortcutKeys.vue_vue_type_script_setup_true_lang.js +4 -1
  61. package/dist/components/panel/ShortcutKeys.vue_vue_type_script_setup_true_lang.js.map +1 -1
  62. package/dist/components/panel/TenantMenu.js.map +1 -1
  63. package/dist/components/panel/TenantMenu.vue_vue_type_script_setup_true_lang.js +2 -2
  64. package/dist/components/panel/TenantMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
  65. package/dist/components/table/GroupHeaderRow.js.map +1 -1
  66. package/dist/components/table/GroupHeaderRow.vue.d.ts.map +1 -1
  67. package/dist/components/table/GroupHeaderRow.vue_vue_type_script_setup_true_lang.js +55 -34
  68. package/dist/components/table/GroupHeaderRow.vue_vue_type_script_setup_true_lang.js.map +1 -1
  69. package/dist/components/table/SelectCell.js.map +1 -1
  70. package/dist/components/table/SelectCell.vue.d.ts.map +1 -1
  71. package/dist/components/table/SelectCell.vue_vue_type_script_setup_true_lang.js +24 -19
  72. package/dist/components/table/SelectCell.vue_vue_type_script_setup_true_lang.js.map +1 -1
  73. package/dist/components/table/TextInputCell.js.map +1 -1
  74. package/dist/components/table/TextInputCell.vue.d.ts.map +1 -1
  75. package/dist/components/table/TextInputCell.vue_vue_type_script_setup_true_lang.js +38 -33
  76. package/dist/components/table/TextInputCell.vue_vue_type_script_setup_true_lang.js.map +1 -1
  77. package/dist/components.css +347 -0
  78. package/dist/eject-manifest.json +2 -2
  79. package/dist/standalone/AlfakitToaster.js.map +1 -1
  80. package/dist/standalone/AlfakitToaster.vue.d.ts.map +1 -1
  81. package/dist/standalone/AlfakitToaster.vue_vue_type_script_setup_true_lang.js +23 -15
  82. package/dist/standalone/AlfakitToaster.vue_vue_type_script_setup_true_lang.js.map +1 -1
  83. package/dist/standalone.css +47 -0
  84. package/dist/starter-kit/widgets/SearchTrigger.js.map +1 -1
  85. package/dist/starter-kit/widgets/SearchTrigger.vue.d.ts.map +1 -1
  86. package/dist/starter-kit/widgets/SearchTrigger.vue_vue_type_script_setup_true_lang.js +1 -0
  87. package/dist/starter-kit/widgets/SearchTrigger.vue_vue_type_script_setup_true_lang.js.map +1 -1
  88. package/dist/starter-kit/widgets/TenantChooser.js.map +1 -1
  89. package/dist/starter-kit/widgets/TenantChooser.vue_vue_type_script_setup_true_lang.js +1 -1
  90. package/dist/starter-kit/widgets/TenantChooser.vue_vue_type_script_setup_true_lang.js.map +1 -1
  91. package/dist/themes.css +2 -334
  92. package/package.json +3 -3
  93. package/src/components/actions/ActionButton.vue +14 -6
  94. package/src/components/actions/ActionModal.vue +3 -1
  95. package/src/components/fields/ColorPickerField.vue +1 -0
  96. package/src/components/fields/ComboboxSelect.vue +1 -1
  97. package/src/components/fields/FileUploadField.vue +14 -11
  98. package/src/components/fields/OneTimeCodeInputField.vue +1 -1
  99. package/src/components/fields/SelectField.vue +4 -2
  100. package/src/components/fields/TableColumnHeaders.vue +6 -1
  101. package/src/components/file-manager/FileManagerView.vue +1 -1
  102. package/src/components/form/FormBlock.vue +24 -0
  103. package/src/components/form/form-context.ts +2 -2
  104. package/src/components/layout/FusedGroupComponent.vue +1 -0
  105. package/src/components/layout/TabsComponent.vue +61 -5
  106. package/src/components/notifications/NotificationsMenu.vue +11 -0
  107. package/src/components/panel/GlobalSearch.vue +1 -0
  108. package/src/components/panel/ShortcutKeys.vue +1 -0
  109. package/src/components/panel/TenantMenu.vue +2 -2
  110. package/src/components/table/GroupHeaderRow.vue +27 -0
  111. package/src/components/table/SelectCell.vue +5 -2
  112. package/src/components/table/TextInputCell.vue +11 -5
  113. package/src/standalone/AlfakitToaster.vue +12 -2
  114. package/src/starter-kit/widgets/SearchTrigger.vue +1 -0
  115. package/src/starter-kit/widgets/TenantChooser.vue +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfakit/vue",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Vue 3 + shadcn-vue renderer for Alfakit, the Laravel admin panel builder by Alfa Six.",
5
5
  "license": "MIT",
6
6
  "author": "Alfa Six (https://alfasixbh.com)",
@@ -96,7 +96,7 @@
96
96
  "reka-ui": "^2.11.0",
97
97
  "tailwind-merge": "^3.7.0",
98
98
  "vue-sonner": "^2.0.9",
99
- "@alfakit/core": "0.1.1"
99
+ "@alfakit/core": "0.1.2"
100
100
  },
101
101
  "peerDependencies": {
102
102
  "@inertiajs/vue3": "^3.0.0",
@@ -119,7 +119,7 @@
119
119
  "provenance": true
120
120
  },
121
121
  "scripts": {
122
- "build": "vite build && vue-tsc --declaration --emitDeclarationOnly -p tsconfig.build.json && cp src/styles.css dist/styles.css && cp ../core/themes.css dist/themes.css && cp ../core/standalone.css dist/standalone.css && node ../../scripts/eject-manifest.mjs .",
122
+ "build": "vite build && vue-tsc --declaration --emitDeclarationOnly -p tsconfig.build.json && cp src/styles.css dist/styles.css && cp ../core/themes.css dist/themes.css && cp ../core/components.css dist/components.css && cp ../core/standalone.css dist/standalone.css && node ../../scripts/eject-manifest.mjs .",
123
123
  "dev": "vite build --watch",
124
124
  "typecheck": "vue-tsc --noEmit",
125
125
  "test": "vitest run"
@@ -4,6 +4,8 @@ import {
4
4
  bindsKeyBindings,
5
5
  formatKeyBinding,
6
6
  iconButtonSize,
7
+ isActionLoading,
8
+ isActionProcessing,
7
9
  registerKeyBindings,
8
10
  resolveActionSize,
9
11
  } from '@alfakit/core'
@@ -47,9 +49,12 @@ const props = withDefaults(defineProps<ActionButtonProps>(), {
47
49
  })
48
50
 
49
51
  const runner = useActionRunner()
50
- const loading = useStoreSelector(runner.store, (state) => state.loading === props.action.name)
52
+ // Only this run (this record's button), not the same action in every table row.
53
+ const loading = useStoreSelector(runner.store, (state) =>
54
+ isActionLoading(state, props.action, props.context),
55
+ )
51
56
  const processing = useStoreSelector(runner.store, (state) =>
52
- state.modals.some((modal) => modal.action.name === props.action.name && modal.processing),
57
+ isActionProcessing(state, props.action, props.context),
53
58
  )
54
59
 
55
60
  const variant = computed(
@@ -87,7 +92,7 @@ function buttonClasses(action: ActionSchema, kind: ActionSchema['variant']): str
87
92
  return colorClass(ghostButtonColorClasses, color, 'gray')
88
93
  case 'link':
89
94
  return cn(
90
- 'h-auto p-0 underline-offset-4 hover:underline',
95
+ 'h-auto p-0 underline-offset-4 hover:underline has-[>svg]:px-0',
91
96
  colorClass(textColorClasses, color, 'primary'),
92
97
  )
93
98
  case 'icon-button':
@@ -278,9 +283,12 @@ const disabledTooltip = computed(() => Boolean(props.action.disabled && tooltip.
278
283
  </TooltipTrigger>
279
284
  <TooltipContent>
280
285
  {{ tooltip }}
281
- <kbd v-if="shortcut" class="ms-2 rounded border border-white/20 px-1 font-mono text-[10px]">{{
282
- formatKeyBinding(shortcut)
283
- }}</kbd>
286
+ <kbd
287
+ v-if="shortcut"
288
+ dir="ltr"
289
+ class="ms-2 rounded border border-white/20 px-1 font-mono text-[10px]"
290
+ >{{ formatKeyBinding(shortcut) }}</kbd
291
+ >
284
292
  </TooltipContent>
285
293
  </Tooltip>
286
294
  <template v-else>
@@ -2,6 +2,7 @@
2
2
  import type { OpenModal } from '@alfakit/core'
3
3
  import {
4
4
  actionFormComponent,
5
+ formIdPrefix,
5
6
  actionFormParams,
6
7
  confirmModalClose,
7
8
  FormStore,
@@ -103,6 +104,8 @@ const form = (() => {
103
104
  return new FormStore(schema.value.form, {
104
105
  token: context.token,
105
106
  component: actionFormComponent(action, context),
107
+ // Own field ids: the modal opens over a form whose fields may share names.
108
+ idPrefix: formIdPrefix(actionFormComponent(action, context)),
106
109
  record: context.record ?? null,
107
110
  refreshUrl: panel.value.endpoints.schemaRefresh,
108
111
  optionsUrl: panel.value.endpoints.schemaOptions,
@@ -513,7 +516,6 @@ const hasBody = computed(() =>
513
516
  </Button>
514
517
  </div>
515
518
  </DialogFooter>
516
- <span class="sr-only">{{ t('close', {}, 'Close') }}</span>
517
519
  </DialogContent>
518
520
  </Dialog>
519
521
  </template>
@@ -45,6 +45,7 @@ const onAlpha = (event: Event) =>
45
45
  <Input
46
46
  :id="id"
47
47
  :name="props.component.name"
48
+ dir="ltr"
48
49
  :model-value="value ?? ''"
49
50
  :placeholder="props.component.placeholder ?? (isHex ? '#000000' : props.component.format)"
50
51
  :disabled="disabled"
@@ -210,7 +210,7 @@ const removeTag = (event: Event, key: string) => {
210
210
  )
211
211
  "
212
212
  >
213
- <span class="flex flex-1 flex-wrap items-center gap-1 text-start">
213
+ <span class="flex min-w-0 flex-1 flex-wrap items-center gap-1 text-start">
214
214
  <span v-if="selected.length === 0" class="text-muted-foreground">{{
215
215
  props.component.placeholder ?? t('select_option', {}, 'Select an option')
216
216
  }}</span>
@@ -22,7 +22,7 @@ import {
22
22
  fileUploadAccept,
23
23
  filesFromClipboard,
24
24
  fileUploadPaths,
25
- formatBytes,
25
+ fileManagerSize,
26
26
  insertUploadedFile,
27
27
  moveRepeaterItem,
28
28
  requestJson,
@@ -43,7 +43,7 @@ import {
43
43
  } from '@lucide/vue'
44
44
  import { computed, ref, shallowRef, watch } from 'vue'
45
45
  import { useStore } from '../../composables/use-store'
46
- import { useChoice, usePanel, useT } from '../../context'
46
+ import { useChoice, useFormatter, usePanel, useT } from '../../context'
47
47
  import { cn } from '../../lib/utils'
48
48
  import { useField, useFormStore } from '../form/form-context'
49
49
  import FieldWrapper from '../schema/FieldWrapper.vue'
@@ -59,6 +59,7 @@ interface LocalFile extends UploadedFile {
59
59
 
60
60
  const props = defineProps<{ component: FileUploadSchema }>()
61
61
  const t = useT()
62
+ const formatter = useFormatter()
62
63
  const choice = useChoice()
63
64
  const panel = usePanel()
64
65
  const {
@@ -424,7 +425,9 @@ const onItemDrop = (event: DragEvent, index: number) => {
424
425
  const hints = computed(() =>
425
426
  [
426
427
  props.component.acceptedFileTypes?.join(', '),
427
- props.component.maxSize ? `≤ ${formatBytes(props.component.maxSize * 1024, 0)}` : null,
428
+ props.component.maxSize
429
+ ? `≤ ${fileManagerSize(props.component.maxSize * 1024, formatter.value)}`
430
+ : null,
428
431
  props.component.minFiles
429
432
  ? choice(
430
433
  'form.min_files',
@@ -491,7 +494,7 @@ function cssLength(value: number | string): string {
491
494
  type="button"
492
495
  variant="ghost"
493
496
  size="icon-xs"
494
- :aria-label="t('remove', {}, 'Remove')"
497
+ :aria-label="t('file_upload.remove', { name: file.name }, 'Remove :name')"
495
498
  @click="removeEntry(file)"
496
499
  ><XIcon
497
500
  /></Button>
@@ -525,7 +528,7 @@ function cssLength(value: number | string): string {
525
528
  : file.progress !== undefined && !file.error
526
529
  ? (props.component.uploadingMessage ?? t('form.uploading', {}, 'Uploading…'))
527
530
  : file.size
528
- ? formatBytes(file.size)
531
+ ? fileManagerSize(file.size, formatter)
529
532
  : file.temporary
530
533
  ? t('form.uploading', {}, 'Pending')
531
534
  : '')
@@ -541,7 +544,7 @@ function cssLength(value: number | string): string {
541
544
  type="button"
542
545
  variant="ghost"
543
546
  size="icon-xs"
544
- :aria-label="t('form.move_up', {}, 'Move up')"
547
+ :aria-label="t('file_upload.move_up', { name: file.name }, 'Move :name up')"
545
548
  :disabled="index === 0"
546
549
  @click="move(index, index - 1)"
547
550
  ><ChevronUpIcon
@@ -550,7 +553,7 @@ function cssLength(value: number | string): string {
550
553
  type="button"
551
554
  variant="ghost"
552
555
  size="icon-xs"
553
- :aria-label="t('form.move_down', {}, 'Move down')"
556
+ :aria-label="t('file_upload.move_down', { name: file.name }, 'Move :name down')"
554
557
  :disabled="index === files.length - 1"
555
558
  @click="move(index, index + 1)"
556
559
  ><ChevronDownIcon
@@ -562,7 +565,7 @@ function cssLength(value: number | string): string {
562
565
  :variant="circle ? 'secondary' : 'ghost'"
563
566
  size="icon-xs"
564
567
  :class="cn(circle && 'rounded-full shadow')"
565
- :aria-label="t('image_editor.title', {}, 'Edit image')"
568
+ :aria-label="t('file_upload.edit', { name: file.name }, 'Edit :name')"
566
569
  :title="t('image_editor.title', {}, 'Edit image')"
567
570
  data-action="edit-image"
568
571
  @click="void editExisting(file)"
@@ -579,7 +582,7 @@ function cssLength(value: number | string): string {
579
582
  :href="uploadedFileLinks(file).open"
580
583
  target="_blank"
581
584
  rel="noopener noreferrer"
582
- :aria-label="`${t('open', {}, 'Open')} ${t('opens_in_new_tab', {}, '(opens in a new tab)')}`"
585
+ :aria-label="`${t('file_upload.open', { name: file.name }, 'Open :name')} ${t('opens_in_new_tab', {}, '(opens in a new tab)')}`"
583
586
  ><ExternalLinkIcon
584
587
  /></a>
585
588
  </Button>
@@ -593,7 +596,7 @@ function cssLength(value: number | string): string {
593
596
  <a
594
597
  :href="uploadedFileLinks(file).download"
595
598
  download
596
- :aria-label="t('download', {}, 'Download')"
599
+ :aria-label="t('file_upload.download', { name: file.name }, 'Download :name')"
597
600
  ><DownloadIcon
598
601
  /></a>
599
602
  </Button>
@@ -603,7 +606,7 @@ function cssLength(value: number | string): string {
603
606
  :variant="circle ? 'secondary' : 'ghost'"
604
607
  size="icon-xs"
605
608
  :class="cn(circle && 'rounded-full shadow')"
606
- :aria-label="t('remove', {}, 'Remove')"
609
+ :aria-label="t('file_upload.remove', { name: file.name }, 'Remove :name')"
607
610
  @click="removeEntry(file)"
608
611
  ><XIcon
609
612
  /></Button>
@@ -43,7 +43,6 @@ const onInput = (event: Event) => {
43
43
  inputmode="numeric"
44
44
  autocomplete="one-time-code"
45
45
  pattern="[0-9]*"
46
- :maxlength="props.component.length"
47
46
  :model-value="value ?? ''"
48
47
  :placeholder="props.component.placeholder"
49
48
  :disabled="disabled"
@@ -54,6 +53,7 @@ const onInput = (event: Event) => {
54
53
  :aria-describedby="aria['aria-describedby']"
55
54
  :aria-required="aria['aria-required']"
56
55
  data-slot="one-time-code-input"
56
+ data-input=""
57
57
  class="max-w-56 text-center font-mono text-lg tracking-[0.5em] tabular-nums"
58
58
  v-bind="props.component.extraInputAttributes"
59
59
  @input="onInput"
@@ -62,7 +62,7 @@ const plainLabel = (label: string): string =>
62
62
  @blur="blur"
63
63
  >
64
64
  <option v-if="props.component.selectablePlaceholder" value="">
65
- {{ props.component.placeholder ?? '' }}
65
+ {{ props.component.placeholder ?? t('select_option', {}, 'Select an option') }}
66
66
  </option>
67
67
  <template
68
68
  v-for="option in props.component.options"
@@ -97,7 +97,9 @@ const plainLabel = (label: string): string =>
97
97
  :aria-readonly="readOnly || undefined"
98
98
  class="w-full"
99
99
  >
100
- <SelectValue :placeholder="props.component.placeholder ?? ''" />
100
+ <SelectValue
101
+ :placeholder="props.component.placeholder ?? t('select_option', {}, 'Select an option')"
102
+ />
101
103
  </SelectTrigger>
102
104
  <SelectContent>
103
105
  <SelectItem
@@ -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>