@jskit-ai/users-web 0.1.81 → 0.1.82

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 (36) hide show
  1. package/package.descriptor.mjs +51 -9
  2. package/package.json +21 -10
  3. package/src/client/bulkActions.js +47 -0
  4. package/src/client/components/AccountSettingsClientElement.vue +69 -24
  5. package/src/client/components/CrudAddEditScreen.vue +186 -0
  6. package/src/client/components/CrudListBulkActionSurface.vue +126 -0
  7. package/src/client/components/CrudListFilterSurface.vue +377 -0
  8. package/src/client/components/CrudListScreen.vue +434 -0
  9. package/src/client/components/CrudViewScreen.vue +186 -0
  10. package/src/client/components/ProfileClientElement.vue +19 -12
  11. package/src/client/composables/records/useAddEdit.js +23 -2
  12. package/src/client/composables/records/useCrudList.js +5 -1
  13. package/src/client/composables/records/useView.js +1 -0
  14. package/src/client/composables/runtime/operationUiHelpers.js +7 -3
  15. package/src/client/composables/runtime/useEndpointResource.js +12 -2
  16. package/src/client/composables/runtime/useUiFeedback.js +4 -2
  17. package/src/client/composables/support/resourceLoadStateHelpers.js +33 -1
  18. package/src/client/composables/useAccountSettingsRuntime.js +10 -1
  19. package/src/client/composables/useCrudAddEditScreen.js +88 -0
  20. package/src/client/composables/useCrudListBulkActions.js +147 -0
  21. package/src/client/composables/useCrudListScreen.js +107 -0
  22. package/src/client/composables/useCrudViewScreen.js +67 -0
  23. package/src/client/composables/usePagedCollection.js +6 -1
  24. package/src/client/filters.js +15 -0
  25. package/src/client/index.js +5 -0
  26. package/templates/src/components/account/settings/AccountSettingsNotificationsSection.vue +34 -8
  27. package/templates/src/components/account/settings/AccountSettingsPreferencesSection.vue +34 -8
  28. package/templates/src/components/account/settings/AccountSettingsProfileSection.vue +34 -8
  29. package/test/crudListBulkActionSurface.test.js +27 -0
  30. package/test/crudListFilterSurface.test.js +45 -0
  31. package/test/crudScreenComponents.test.js +62 -0
  32. package/test/errorIntentContract.test.js +31 -0
  33. package/test/exportsContract.test.js +11 -0
  34. package/test/resourceLoadStateHelpers.test.js +35 -1
  35. package/test/settingsPlacementContract.test.js +61 -0
  36. package/test/useCrudListBulkActions.test.js +65 -0
@@ -3,7 +3,7 @@ import { HOME_COG_OUTLET } from "./src/shared/toolsOutletContracts.js";
3
3
  export default Object.freeze({
4
4
  packageVersion: 1,
5
5
  packageId: "@jskit-ai/users-web",
6
- version: "0.1.81",
6
+ version: "0.1.82",
7
7
  kind: "runtime",
8
8
  description: "Users web module: account/profile UI plus shared users web widgets.",
9
9
  dependsOn: [
@@ -50,6 +50,34 @@ export default Object.freeze({
50
50
  subpath: "./client/components/AccountSettingsClientElement",
51
51
  summary: "Exports the package-owned account settings host that renders placement-backed account sections."
52
52
  },
53
+ {
54
+ subpath: "./client/components/CrudAddEditScreen",
55
+ summary: "Exports the package-owned CRUD add/edit screen shell used by generated form pages."
56
+ },
57
+ {
58
+ subpath: "./client/components/CrudListBulkActionSurface",
59
+ summary: "Exports the adaptive CRUD list bulk-action surface for generated list pages."
60
+ },
61
+ {
62
+ subpath: "./client/components/CrudListFilterSurface",
63
+ summary: "Exports the adaptive CRUD list filter surface for generated list pages."
64
+ },
65
+ {
66
+ subpath: "./client/components/CrudListScreen",
67
+ summary: "Exports the package-owned CRUD list screen shell used by generated list pages."
68
+ },
69
+ {
70
+ subpath: "./client/components/CrudViewScreen",
71
+ summary: "Exports the package-owned CRUD view screen shell used by generated detail pages."
72
+ },
73
+ {
74
+ subpath: "./client/bulkActions",
75
+ summary: "Exports client-side CRUD list bulk-action definition helpers."
76
+ },
77
+ {
78
+ subpath: "./client/filters",
79
+ summary: "Exports client-side CRUD list filter definition helpers."
80
+ },
53
81
  {
54
82
  subpath: "./client/components/ProfileClientElement",
55
83
  summary: "Exports profile settings client element scaffold component."
@@ -78,10 +106,26 @@ export default Object.freeze({
78
106
  subpath: "./client/composables/useCrudListFilterLookups",
79
107
  summary: "Exports lookup-backed CRUD list filter helper for remote autocomplete filters."
80
108
  },
109
+ {
110
+ subpath: "./client/composables/useCrudAddEditScreen",
111
+ summary: "Exports the package-owned add/edit screen runtime for generated form pages."
112
+ },
113
+ {
114
+ subpath: "./client/composables/useCrudListBulkActions",
115
+ summary: "Exports selected-record state and execution runtime for generated CRUD list bulk actions."
116
+ },
117
+ {
118
+ subpath: "./client/composables/useCrudListScreen",
119
+ summary: "Exports the package-owned list screen runtime for generated list pages."
120
+ },
81
121
  {
82
122
  subpath: "./client/composables/useView",
83
123
  summary: "Exports read/view operation composable."
84
124
  },
125
+ {
126
+ subpath: "./client/composables/useCrudViewScreen",
127
+ summary: "Exports the package-owned view screen runtime for generated detail pages."
128
+ },
85
129
  {
86
130
  subpath: "./client/composables/usePagedCollection",
87
131
  summary: "Exports paged collection query composable."
@@ -233,15 +277,13 @@ export default Object.freeze({
233
277
  mutations: {
234
278
  dependencies: {
235
279
  runtime: {
236
- "@tanstack/vue-query": "5.92.12",
237
280
  "@mdi/js": "^7.4.47",
238
- "@jskit-ai/http-runtime": "0.1.65",
239
- "@jskit-ai/realtime": "0.1.65",
240
- "@jskit-ai/kernel": "0.1.66",
241
- "@jskit-ai/shell-web": "0.1.65",
242
- "@jskit-ai/uploads-image-web": "0.1.44",
243
- "@jskit-ai/users-core": "0.1.76",
244
- vuetify: "^4.0.0"
281
+ "@jskit-ai/http-runtime": "0.1.66",
282
+ "@jskit-ai/realtime": "0.1.66",
283
+ "@jskit-ai/kernel": "0.1.67",
284
+ "@jskit-ai/shell-web": "0.1.66",
285
+ "@jskit-ai/uploads-image-web": "0.1.45",
286
+ "@jskit-ai/users-core": "0.1.77"
245
287
  },
246
288
  dev: {}
247
289
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.81",
3
+ "version": "0.1.82",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -8,22 +8,33 @@
8
8
  "exports": {
9
9
  "./client": "./src/client/index.js",
10
10
  "./client/components/AccountSettingsClientElement": "./src/client/components/AccountSettingsClientElement.vue",
11
+ "./client/components/CrudAddEditScreen": "./src/client/components/CrudAddEditScreen.vue",
12
+ "./client/components/CrudListBulkActionSurface": "./src/client/components/CrudListBulkActionSurface.vue",
13
+ "./client/components/CrudListFilterSurface": "./src/client/components/CrudListFilterSurface.vue",
14
+ "./client/components/CrudListScreen": "./src/client/components/CrudListScreen.vue",
15
+ "./client/components/CrudViewScreen": "./src/client/components/CrudViewScreen.vue",
11
16
  "./client/account-settings/sections": "./src/client/account-settings/sections.js",
17
+ "./client/bulkActions": "./src/client/bulkActions.js",
18
+ "./client/filters": "./src/client/filters.js",
12
19
  "./client/composables/useAddEdit": "./src/client/composables/records/useAddEdit.js",
13
20
  "./client/composables/useAccess": "./src/client/composables/useAccess.js",
14
21
  "./client/composables/useCommand": "./src/client/composables/useCommand.js",
15
22
  "./client/composables/useCrudAddEdit": "./src/client/composables/records/useCrudAddEdit.js",
23
+ "./client/composables/useCrudAddEditScreen": "./src/client/composables/useCrudAddEditScreen.js",
24
+ "./client/composables/useCrudListBulkActions": "./src/client/composables/useCrudListBulkActions.js",
16
25
  "./client/composables/crudLookupFieldRuntime": "./src/client/composables/crud/crudLookupFieldRuntime.js",
17
26
  "./client/composables/useCrudListFilterLookups": "./src/client/composables/useCrudListFilterLookups.js",
18
27
  "./client/composables/useCrudListFilters": "./src/client/composables/useCrudListFilters.js",
19
28
  "./client/composables/useEndpointResource": "./src/client/composables/runtime/useEndpointResource.js",
20
29
  "./client/composables/useList": "./src/client/composables/records/useList.js",
21
30
  "./client/composables/useCrudList": "./src/client/composables/records/useCrudList.js",
31
+ "./client/composables/useCrudListScreen": "./src/client/composables/useCrudListScreen.js",
22
32
  "./client/composables/useCrudListParentTitle": "./src/client/composables/useCrudListParentTitle.js",
23
33
  "./client/composables/useRealtimeQueryInvalidation": "./src/client/composables/useRealtimeQueryInvalidation.js",
24
34
  "./client/composables/useSurfaceRouteContext": "./src/client/composables/useSurfaceRouteContext.js",
25
35
  "./client/composables/useView": "./src/client/composables/records/useView.js",
26
36
  "./client/composables/useCrudView": "./src/client/composables/records/useCrudView.js",
37
+ "./client/composables/useCrudViewScreen": "./src/client/composables/useCrudViewScreen.js",
27
38
  "./client/composables/usePagedCollection": "./src/client/composables/usePagedCollection.js",
28
39
  "./client/composables/usePaths": "./src/client/composables/usePaths.js",
29
40
  "./client/composables/runtime/useUiFeedback": "./src/client/composables/runtime/useUiFeedback.js",
@@ -32,18 +43,18 @@
32
43
  "./client/support/contractGuards": "./src/client/support/contractGuards.js"
33
44
  },
34
45
  "dependencies": {
35
- "@tanstack/vue-query": "5.92.12",
36
46
  "@mdi/js": "^7.4.47",
37
- "@jskit-ai/http-runtime": "0.1.65",
38
- "@jskit-ai/kernel": "0.1.66",
39
- "@jskit-ai/realtime": "0.1.65",
40
- "@jskit-ai/shell-web": "0.1.65",
41
- "@jskit-ai/uploads-image-web": "0.1.44",
42
- "@jskit-ai/users-core": "0.1.76",
43
- "vuetify": "^4.0.0"
47
+ "@jskit-ai/http-runtime": "0.1.66",
48
+ "@jskit-ai/kernel": "0.1.67",
49
+ "@jskit-ai/realtime": "0.1.66",
50
+ "@jskit-ai/shell-web": "0.1.66",
51
+ "@jskit-ai/uploads-image-web": "0.1.45",
52
+ "@jskit-ai/users-core": "0.1.77"
44
53
  },
45
54
  "peerDependencies": {
55
+ "@tanstack/vue-query": "^5.90.5",
46
56
  "vue": "^3.5.13",
47
- "vue-router": "^5.0.4"
57
+ "vue-router": "^5.0.4",
58
+ "vuetify": "^4.0.0"
48
59
  }
49
60
  }
@@ -0,0 +1,47 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+
3
+ function normalizeCrudListBulkAction(rawAction = {}, index = 0) {
4
+ if (!rawAction || typeof rawAction !== "object" || Array.isArray(rawAction)) {
5
+ return null;
6
+ }
7
+
8
+ const key = normalizeText(rawAction.key || rawAction.id || `action-${index + 1}`);
9
+ const label = normalizeText(rawAction.label || rawAction.title);
10
+ if (!key || !label) {
11
+ return null;
12
+ }
13
+
14
+ return Object.freeze({
15
+ key,
16
+ label,
17
+ icon: normalizeText(rawAction.icon),
18
+ color: normalizeText(rawAction.color, { fallback: "primary" }),
19
+ variant: normalizeText(rawAction.variant, { fallback: "tonal" }),
20
+ confirmLabel: normalizeText(rawAction.confirmLabel),
21
+ run: typeof rawAction.run === "function" ? rawAction.run : null,
22
+ disabled: rawAction.disabled
23
+ });
24
+ }
25
+
26
+ function defineCrudListBulkActions(actions = []) {
27
+ if (!Array.isArray(actions)) {
28
+ throw new TypeError("defineCrudListBulkActions requires an array.");
29
+ }
30
+
31
+ const normalizedActions = [];
32
+ const seenKeys = new Set();
33
+
34
+ actions.forEach((rawAction, index) => {
35
+ const action = normalizeCrudListBulkAction(rawAction, index);
36
+ if (!action || seenKeys.has(action.key)) {
37
+ return;
38
+ }
39
+
40
+ seenKeys.add(action.key);
41
+ normalizedActions.push(action);
42
+ });
43
+
44
+ return Object.freeze(normalizedActions);
45
+ }
46
+
47
+ export { defineCrudListBulkActions };
@@ -56,28 +56,41 @@ const activeTab = computed({
56
56
 
57
57
  <template>
58
58
  <section class="settings-view py-2 py-md-4">
59
- <v-card class="panel-card" rounded="lg" elevation="1" border>
60
- <v-card-item>
61
- <v-card-title class="panel-title">Account settings</v-card-title>
62
- <v-card-subtitle>Global profile, preferences, notifications, and account controls.</v-card-subtitle>
63
- <template #append>
64
- <v-btn
65
- variant="text"
66
- color="secondary"
67
- :to="runtime.backNavigationTarget.value.sameOrigin ? runtime.backNavigationTarget.value.href : undefined"
68
- :href="runtime.backNavigationTarget.value.sameOrigin ? undefined : runtime.backNavigationTarget.value.href"
69
- >
70
- Back
71
- </v-btn>
72
- </template>
73
- </v-card-item>
74
- <v-divider />
59
+ <v-sheet class="settings-panel" rounded="lg" border>
60
+ <header class="settings-panel__header">
61
+ <div>
62
+ <h1 class="settings-panel__title">Account settings</h1>
63
+ <p class="text-body-2 text-medium-emphasis mb-0">
64
+ Global profile, preferences, notifications, and account controls.
65
+ </p>
66
+ </div>
67
+ <v-btn
68
+ variant="text"
69
+ color="secondary"
70
+ :to="runtime.backNavigationTarget.value.sameOrigin ? runtime.backNavigationTarget.value.href : undefined"
71
+ :href="runtime.backNavigationTarget.value.sameOrigin ? undefined : runtime.backNavigationTarget.value.href"
72
+ >
73
+ Back
74
+ </v-btn>
75
+ </header>
75
76
 
76
- <v-card-text class="pt-4">
77
+ <div class="settings-panel__body">
77
78
  <template v-if="runtime.loadingSettings.value">
78
79
  <v-skeleton-loader type="text@2, list-item-two-line@4" class="mb-4" />
79
80
  <v-skeleton-loader type="text@2, paragraph, button" />
80
81
  </template>
82
+ <div v-else-if="runtime.settingsLoadError.value" class="settings-panel__state">
83
+ <h2 class="text-h6 mb-2">Unable to load account settings</h2>
84
+ <p class="text-body-2 text-medium-emphasis mb-4">{{ runtime.settingsLoadError.value }}</p>
85
+ <v-btn
86
+ color="primary"
87
+ variant="tonal"
88
+ :loading="runtime.refreshingSettings.value"
89
+ @click="runtime.refreshSettings"
90
+ >
91
+ Retry
92
+ </v-btn>
93
+ </div>
81
94
  <template v-else-if="sections.length < 1">
82
95
  <p class="text-body-2 text-medium-emphasis mb-0">No account settings sections are registered.</p>
83
96
  </template>
@@ -109,20 +122,42 @@ const activeTab = computed({
109
122
  </v-col>
110
123
  </v-row>
111
124
  </template>
112
- </v-card-text>
113
- </v-card>
125
+ </div>
126
+ </v-sheet>
114
127
  </section>
115
128
  </template>
116
129
 
117
130
  <style scoped>
118
- .panel-card {
131
+ .settings-panel {
119
132
  background-color: rgb(var(--v-theme-surface));
133
+ overflow: hidden;
134
+ }
135
+
136
+ .settings-panel__header {
137
+ align-items: flex-start;
138
+ display: flex;
139
+ gap: 1rem;
140
+ justify-content: space-between;
141
+ padding: 1rem 1rem 0;
120
142
  }
121
143
 
122
- .panel-title {
123
- font-size: 1rem;
124
- font-weight: 600;
125
- letter-spacing: 0.01em;
144
+ .settings-panel__title {
145
+ font-size: clamp(1.35rem, 2vw, 1.85rem);
146
+ font-weight: 650;
147
+ letter-spacing: -0.02em;
148
+ line-height: 1.15;
149
+ margin: 0 0 0.35rem;
150
+ }
151
+
152
+ .settings-panel__body {
153
+ padding: 1rem;
154
+ }
155
+
156
+ .settings-panel__state {
157
+ margin-inline: auto;
158
+ max-width: 30rem;
159
+ padding: 2rem 1rem;
160
+ text-align: center;
126
161
  }
127
162
 
128
163
  .settings-section-list {
@@ -139,4 +174,14 @@ const activeTab = computed({
139
174
  :deep(.settings-sections-window .v-window-x-reverse-transition-leave-active) {
140
175
  transition: none !important;
141
176
  }
177
+
178
+ @media (max-width: 960px) {
179
+ .settings-panel__header {
180
+ flex-direction: column;
181
+ }
182
+
183
+ .settings-panel__header :deep(.v-btn) {
184
+ min-height: 48px;
185
+ }
186
+ }
142
187
  </style>
@@ -0,0 +1,186 @@
1
+ <script setup>
2
+ import { computed, unref } from "vue";
3
+
4
+ const props = defineProps({
5
+ screen: {
6
+ type: Object,
7
+ required: true
8
+ },
9
+ resolveLookupItems: {
10
+ type: Function,
11
+ default: null
12
+ },
13
+ resolveLookupLoading: {
14
+ type: Function,
15
+ default: null
16
+ },
17
+ resolveLookupSearch: {
18
+ type: Function,
19
+ default: null
20
+ },
21
+ setLookupSearch: {
22
+ type: Function,
23
+ default: null
24
+ }
25
+ });
26
+
27
+ const formRuntime = computed(() => props.screen?.formRuntime || {});
28
+ const addEdit = computed(() => props.screen?.addEdit || formRuntime.value?.addEdit || {});
29
+ const formState = computed(() => props.screen?.formState || formRuntime.value?.form || {});
30
+ const mode = computed(() => String(unref(props.screen?.mode) || "new").trim() || "new");
31
+ const title = computed(() => String(unref(props.screen?.title) || "").trim());
32
+ const subtitle = computed(() => String(unref(props.screen?.subtitle) || "").trim());
33
+ const saveLabel = computed(() => String(unref(props.screen?.saveLabel) || "Save").trim() || "Save");
34
+ const cancelTo = computed(() => unref(props.screen?.cancelTo) || "");
35
+
36
+ function resolveFieldErrors(fieldKey) {
37
+ if (typeof props.screen?.resolveFieldErrors === "function") {
38
+ return props.screen.resolveFieldErrors(fieldKey);
39
+ }
40
+ if (typeof formRuntime.value?.resolveFieldErrors === "function") {
41
+ return formRuntime.value.resolveFieldErrors(fieldKey);
42
+ }
43
+ return [];
44
+ }
45
+
46
+ function resolveCancelTo(target = cancelTo.value) {
47
+ if (typeof props.screen?.resolveCancelTo === "function") {
48
+ return props.screen.resolveCancelTo(target);
49
+ }
50
+ return target || "";
51
+ }
52
+ </script>
53
+
54
+ <template>
55
+ <section class="generated-ui-screen generated-ui-screen--operator ui-generator-add-edit-form d-flex flex-column ga-4">
56
+ <header class="ui-generator-add-edit-form__header">
57
+ <div class="ui-generator-add-edit-form__copy">
58
+ <h1 class="ui-generator-add-edit-form__title">{{ title }}</h1>
59
+ <p v-if="subtitle" class="text-body-2 text-medium-emphasis mb-0">{{ subtitle }}</p>
60
+ </div>
61
+ <div class="ui-generator-add-edit-form__actions">
62
+ <v-btn v-if="cancelTo" color="primary" variant="outlined" :to="resolveCancelTo(cancelTo)">Cancel</v-btn>
63
+ <v-btn
64
+ color="primary"
65
+ variant="flat"
66
+ :loading="addEdit.isSaving"
67
+ :disabled="addEdit.isSubmitDisabled"
68
+ @click="addEdit.submit"
69
+ >
70
+ {{ saveLabel }}
71
+ </v-btn>
72
+ </div>
73
+ </header>
74
+
75
+ <v-sheet rounded="lg" border class="ui-generator-add-edit-form__panel">
76
+ <div v-if="addEdit.loadError" class="ui-generator-add-edit-form__state">
77
+ <h2 class="text-h6 mb-2">Unable to load form</h2>
78
+ <p class="text-body-2 text-medium-emphasis mb-4">
79
+ {{ addEdit.loadError }}
80
+ </p>
81
+ <v-btn
82
+ v-if="addEdit.canRetryLoad"
83
+ color="primary"
84
+ variant="tonal"
85
+ :loading="addEdit.isFetching"
86
+ @click="addEdit.refresh"
87
+ >
88
+ Retry
89
+ </v-btn>
90
+ </div>
91
+ <template v-else-if="formRuntime.showFormSkeleton">
92
+ <div class="pa-4">
93
+ <v-skeleton-loader type="heading, text@2, article" />
94
+ </div>
95
+ </template>
96
+ <v-form v-else class="pa-4" @submit.prevent="addEdit.submit" novalidate>
97
+ <v-progress-linear v-if="addEdit.isRefetching" indeterminate class="mb-4" />
98
+ <v-row class="ui-generator-add-edit-form__fields">
99
+ <slot
100
+ name="fields"
101
+ :mode="mode"
102
+ :form-runtime="formRuntime"
103
+ :form-state="formState"
104
+ :add-edit="addEdit"
105
+ :resolve-field-errors="resolveFieldErrors"
106
+ :resolve-lookup-items="resolveLookupItems"
107
+ :resolve-lookup-loading="resolveLookupLoading"
108
+ :resolve-lookup-search="resolveLookupSearch"
109
+ :set-lookup-search="setLookupSearch"
110
+ />
111
+ </v-row>
112
+ </v-form>
113
+ </v-sheet>
114
+ </section>
115
+ </template>
116
+
117
+ <style scoped>
118
+ .generated-ui-screen {
119
+ --generated-ui-screen-title-size: clamp(1.35rem, 2vw, 1.85rem);
120
+ --generated-ui-screen-state-padding: 2.5rem 1.25rem;
121
+ }
122
+
123
+ .generated-ui-screen--operator {
124
+ --generated-ui-screen-state-padding: 2rem 1rem;
125
+ }
126
+
127
+ .ui-generator-add-edit-form__header {
128
+ align-items: flex-start;
129
+ display: flex;
130
+ gap: 1rem;
131
+ justify-content: space-between;
132
+ }
133
+
134
+ .ui-generator-add-edit-form__copy {
135
+ min-width: 0;
136
+ }
137
+
138
+ .ui-generator-add-edit-form__title {
139
+ font-size: var(--generated-ui-screen-title-size);
140
+ font-weight: 650;
141
+ letter-spacing: -0.02em;
142
+ line-height: 1.15;
143
+ margin: 0 0 0.35rem;
144
+ }
145
+
146
+ .ui-generator-add-edit-form__actions {
147
+ display: flex;
148
+ flex-wrap: wrap;
149
+ gap: 0.5rem;
150
+ justify-content: flex-end;
151
+ }
152
+
153
+ .ui-generator-add-edit-form__panel {
154
+ overflow: hidden;
155
+ }
156
+
157
+ .ui-generator-add-edit-form__state {
158
+ margin-inline: auto;
159
+ max-width: 30rem;
160
+ padding: var(--generated-ui-screen-state-padding);
161
+ text-align: center;
162
+ }
163
+
164
+ .ui-generator-add-edit-form__fields :deep(.v-col) {
165
+ min-width: 0;
166
+ }
167
+
168
+ @media (max-width: 960px) {
169
+ .ui-generator-add-edit-form__header {
170
+ flex-direction: column;
171
+ }
172
+
173
+ .ui-generator-add-edit-form__actions {
174
+ width: 100%;
175
+ }
176
+
177
+ .ui-generator-add-edit-form__actions :deep(.v-btn) {
178
+ min-height: 48px;
179
+ flex: 1 1 10rem;
180
+ }
181
+
182
+ .ui-generator-add-edit-form__state :deep(.v-btn) {
183
+ min-height: 48px;
184
+ }
185
+ }
186
+ </style>
@@ -0,0 +1,126 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import { useDisplay } from "vuetify";
4
+
5
+ const props = defineProps({
6
+ runtime: {
7
+ type: Object,
8
+ default: null
9
+ },
10
+ title: {
11
+ type: String,
12
+ default: "Selection"
13
+ }
14
+ });
15
+
16
+ const display = useDisplay();
17
+ const isCompactLayout = computed(() => {
18
+ const displayName = String(display?.name?.value || "").trim().toLowerCase();
19
+ return displayName === "xs" || displayName === "sm";
20
+ });
21
+ const actions = computed(() => Array.isArray(props.runtime?.actions) ? props.runtime.actions : []);
22
+ const selectedCount = computed(() => Number(props.runtime?.selectedCount?.value || 0));
23
+ const shouldRender = computed(() =>
24
+ Boolean(props.runtime?.hasActions?.value) &&
25
+ Boolean(props.runtime?.hasSelection?.value) &&
26
+ selectedCount.value > 0
27
+ );
28
+
29
+ function clearSelection() {
30
+ props.runtime?.clearSelection?.();
31
+ }
32
+
33
+ function execute(action = {}) {
34
+ props.runtime?.execute?.(action);
35
+ }
36
+
37
+ function isActionDisabled(action = {}) {
38
+ return Boolean(props.runtime?.isActionDisabled?.(action));
39
+ }
40
+
41
+ function isActionExecuting(action = {}) {
42
+ return Boolean(props.runtime?.isActionExecuting?.(action));
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <section v-if="shouldRender" class="crud-list-bulk-action-surface">
48
+ <div class="crud-list-bulk-action-surface__summary">
49
+ <div class="crud-list-bulk-action-surface__copy">
50
+ <span class="text-overline text-medium-emphasis">{{ title }}</span>
51
+ <strong>{{ selectedCount }} selected</strong>
52
+ </div>
53
+ <v-btn size="small" variant="text" @click="clearSelection">Clear</v-btn>
54
+ </div>
55
+
56
+ <div v-if="!isCompactLayout" class="crud-list-bulk-action-surface__actions">
57
+ <v-btn
58
+ v-for="action in actions"
59
+ :key="action.key"
60
+ :color="action.color"
61
+ :variant="action.variant"
62
+ :prepend-icon="action.icon || undefined"
63
+ :disabled="isActionDisabled(action)"
64
+ :loading="isActionExecuting(action)"
65
+ @click="execute(action)"
66
+ >
67
+ {{ action.label }}
68
+ </v-btn>
69
+ </div>
70
+
71
+ <v-menu v-else location="bottom end">
72
+ <template #activator="{ props: menuProps }">
73
+ <v-btn v-bind="menuProps" color="primary" variant="tonal">Bulk actions</v-btn>
74
+ </template>
75
+ <v-list density="compact" min-width="180">
76
+ <v-list-item
77
+ v-for="action in actions"
78
+ :key="action.key"
79
+ :title="action.label"
80
+ :prepend-icon="action.icon || undefined"
81
+ :disabled="isActionDisabled(action)"
82
+ @click="execute(action)"
83
+ />
84
+ </v-list>
85
+ </v-menu>
86
+ </section>
87
+ </template>
88
+
89
+ <style scoped>
90
+ .crud-list-bulk-action-surface {
91
+ align-items: center;
92
+ background: rgba(var(--v-theme-primary), 0.08);
93
+ border-block: 1px solid rgba(var(--v-theme-primary), 0.18);
94
+ display: flex;
95
+ gap: 0.75rem;
96
+ justify-content: space-between;
97
+ min-width: 0;
98
+ padding: 0.75rem 1rem;
99
+ }
100
+
101
+ .crud-list-bulk-action-surface__summary,
102
+ .crud-list-bulk-action-surface__actions {
103
+ align-items: center;
104
+ display: flex;
105
+ flex-wrap: wrap;
106
+ gap: 0.5rem;
107
+ min-width: 0;
108
+ }
109
+
110
+ .crud-list-bulk-action-surface__copy {
111
+ display: grid;
112
+ gap: 0.1rem;
113
+ min-width: 0;
114
+ }
115
+
116
+ .crud-list-bulk-action-surface :deep(.v-btn) {
117
+ min-height: 48px;
118
+ }
119
+
120
+ @media (max-width: 640px) {
121
+ .crud-list-bulk-action-surface {
122
+ align-items: stretch;
123
+ flex-direction: column;
124
+ }
125
+ }
126
+ </style>