@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.
- package/CHANGELOG.md +17 -0
- package/dist/components/actions/ActionButton.js.map +1 -1
- package/dist/components/actions/ActionButton.vue.d.ts.map +1 -1
- package/dist/components/actions/ActionButton.vue_vue_type_script_setup_true_lang.js +160 -159
- package/dist/components/actions/ActionButton.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/actions/ActionModal.js.map +1 -1
- package/dist/components/actions/ActionModal.vue.d.ts.map +1 -1
- package/dist/components/actions/ActionModal.vue_vue_type_script_setup_true_lang.js +214 -214
- package/dist/components/actions/ActionModal.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/cms/CmsLinkButton.vue.d.ts +1 -1
- package/dist/components/fields/ColorPickerField.js.map +1 -1
- package/dist/components/fields/ColorPickerField.vue.d.ts.map +1 -1
- package/dist/components/fields/ColorPickerField.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/components/fields/ColorPickerField.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/ComboboxSelect.js.map +1 -1
- package/dist/components/fields/ComboboxSelect.vue.d.ts.map +1 -1
- package/dist/components/fields/ComboboxSelect.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/components/fields/ComboboxSelect.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/FileUploadField.js.map +1 -1
- package/dist/components/fields/FileUploadField.vue.d.ts.map +1 -1
- package/dist/components/fields/FileUploadField.vue_vue_type_script_setup_true_lang.js +183 -183
- package/dist/components/fields/FileUploadField.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/OneTimeCodeInputField.js.map +1 -1
- package/dist/components/fields/OneTimeCodeInputField.vue_vue_type_script_setup_true_lang.js +1 -2
- package/dist/components/fields/OneTimeCodeInputField.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/SelectField.js.map +1 -1
- package/dist/components/fields/SelectField.vue.d.ts.map +1 -1
- package/dist/components/fields/SelectField.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/components/fields/SelectField.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/TableColumnHeaders.js.map +1 -1
- package/dist/components/fields/TableColumnHeaders.vue.d.ts.map +1 -1
- package/dist/components/fields/TableColumnHeaders.vue_vue_type_script_setup_true_lang.js +6 -3
- package/dist/components/fields/TableColumnHeaders.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/file-manager/FileManagerView.js.map +1 -1
- package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/form/FormBlock.js.map +1 -1
- package/dist/components/form/FormBlock.vue.d.ts.map +1 -1
- package/dist/components/form/FormBlock.vue_vue_type_script_setup_true_lang.js +81 -77
- package/dist/components/form/FormBlock.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/form/form-context.js +25 -25
- package/dist/components/form/form-context.js.map +1 -1
- package/dist/components/layout/FusedGroupComponent.js.map +1 -1
- package/dist/components/layout/FusedGroupComponent.vue.d.ts.map +1 -1
- package/dist/components/layout/FusedGroupComponent.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/components/layout/FusedGroupComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/layout/TabsComponent.js.map +1 -1
- package/dist/components/layout/TabsComponent.vue.d.ts.map +1 -1
- package/dist/components/layout/TabsComponent.vue_vue_type_script_setup_true_lang.js +76 -55
- package/dist/components/layout/TabsComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/notifications/NotificationsMenu.js.map +1 -1
- package/dist/components/notifications/NotificationsMenu.vue.d.ts.map +1 -1
- package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js +44 -42
- package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/panel/GlobalSearch.js.map +1 -1
- package/dist/components/panel/GlobalSearch.vue.d.ts.map +1 -1
- package/dist/components/panel/GlobalSearch.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/components/panel/GlobalSearch.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/panel/ShortcutKeys.js.map +1 -1
- package/dist/components/panel/ShortcutKeys.vue.d.ts.map +1 -1
- package/dist/components/panel/ShortcutKeys.vue_vue_type_script_setup_true_lang.js +4 -1
- package/dist/components/panel/ShortcutKeys.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/panel/TenantMenu.js.map +1 -1
- package/dist/components/panel/TenantMenu.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/components/panel/TenantMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/table/GroupHeaderRow.js.map +1 -1
- package/dist/components/table/GroupHeaderRow.vue.d.ts.map +1 -1
- package/dist/components/table/GroupHeaderRow.vue_vue_type_script_setup_true_lang.js +55 -34
- package/dist/components/table/GroupHeaderRow.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/table/SelectCell.js.map +1 -1
- package/dist/components/table/SelectCell.vue.d.ts.map +1 -1
- package/dist/components/table/SelectCell.vue_vue_type_script_setup_true_lang.js +24 -19
- package/dist/components/table/SelectCell.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/table/TextInputCell.js.map +1 -1
- package/dist/components/table/TextInputCell.vue.d.ts.map +1 -1
- package/dist/components/table/TextInputCell.vue_vue_type_script_setup_true_lang.js +38 -33
- package/dist/components/table/TextInputCell.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components.css +347 -0
- package/dist/eject-manifest.json +2 -2
- package/dist/lib/Icon.js.map +1 -1
- package/dist/lib/Icon.vue.d.ts.map +1 -1
- package/dist/lib/Icon.vue_vue_type_script_setup_true_lang.js +18 -19
- package/dist/lib/Icon.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/lib/hydration.d.ts +9 -0
- package/dist/lib/hydration.d.ts.map +1 -0
- package/dist/lib/hydration.js +17 -0
- package/dist/lib/hydration.js.map +1 -0
- package/dist/lib/icons.d.ts +3 -5
- package/dist/lib/icons.d.ts.map +1 -1
- package/dist/lib/icons.js +19 -22
- package/dist/lib/icons.js.map +1 -1
- package/dist/lib/lazy.d.ts +1 -1
- package/dist/lib/lazy.d.ts.map +1 -1
- package/dist/lib/lazy.js +12 -11
- package/dist/lib/lazy.js.map +1 -1
- package/dist/standalone/AlfakitToaster.js.map +1 -1
- package/dist/standalone/AlfakitToaster.vue.d.ts.map +1 -1
- package/dist/standalone/AlfakitToaster.vue_vue_type_script_setup_true_lang.js +23 -15
- package/dist/standalone/AlfakitToaster.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/standalone.css +47 -0
- package/dist/starter-kit/widgets/SearchTrigger.js.map +1 -1
- package/dist/starter-kit/widgets/SearchTrigger.vue.d.ts.map +1 -1
- package/dist/starter-kit/widgets/SearchTrigger.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/starter-kit/widgets/SearchTrigger.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/starter-kit/widgets/TenantChooser.js.map +1 -1
- package/dist/starter-kit/widgets/TenantChooser.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/starter-kit/widgets/TenantChooser.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/themes.css +2 -334
- package/package.json +3 -3
- package/src/components/actions/ActionButton.vue +14 -6
- package/src/components/actions/ActionModal.vue +3 -1
- package/src/components/fields/ColorPickerField.vue +1 -0
- package/src/components/fields/ComboboxSelect.vue +1 -1
- package/src/components/fields/FileUploadField.vue +14 -11
- package/src/components/fields/OneTimeCodeInputField.vue +1 -1
- package/src/components/fields/SelectField.vue +4 -2
- package/src/components/fields/TableColumnHeaders.vue +6 -1
- package/src/components/file-manager/FileManagerView.vue +1 -1
- package/src/components/form/FormBlock.vue +24 -0
- package/src/components/form/form-context.ts +2 -2
- package/src/components/layout/FusedGroupComponent.vue +1 -0
- package/src/components/layout/TabsComponent.vue +61 -5
- package/src/components/notifications/NotificationsMenu.vue +11 -0
- package/src/components/panel/GlobalSearch.vue +1 -0
- package/src/components/panel/ShortcutKeys.vue +1 -0
- package/src/components/panel/TenantMenu.vue +2 -2
- package/src/components/table/GroupHeaderRow.vue +27 -0
- package/src/components/table/SelectCell.vue +5 -2
- package/src/components/table/TextInputCell.vue +11 -5
- package/src/lib/Icon.vue +6 -5
- package/src/lib/hydration.ts +35 -0
- package/src/lib/icons.ts +6 -11
- package/src/lib/lazy.ts +5 -6
- package/src/standalone/AlfakitToaster.vue +12 -2
- package/src/starter-kit/widgets/SearchTrigger.vue +1 -0
- 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
|
|
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-
|
|
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 ??
|
|
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 {
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { TabsSchema } from '@alfakit/core'
|
|
3
|
-
import {
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
|
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(() => {
|
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
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,
|
|
3
|
+
import { computed, type Component, type HTMLAttributes } from 'vue'
|
|
4
4
|
import { useOptionalRegistry } from '../context'
|
|
5
|
-
import {
|
|
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
|
|
26
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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 =
|
|
31
|
+
const mounted = useAppMounted()
|
|
31
32
|
|
|
32
|
-
|
|
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(() =>
|
|
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>
|