@ngbplatform/ui 1.3.0

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 (217) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +44 -0
  3. package/package.json +61 -0
  4. package/public/favicon.svg +6 -0
  5. package/public/silent-check-sso.html +8 -0
  6. package/src/assets/ngb_logo.svg +17 -0
  7. package/src/assets/ngb_logo_color.svg +17 -0
  8. package/src/index.ts +1023 -0
  9. package/src/ngb/README.md +27 -0
  10. package/src/ngb/accounting/NgbAccountingPeriodClosingPage.vue +1157 -0
  11. package/src/ngb/accounting/NgbChartOfAccountEditor.vue +590 -0
  12. package/src/ngb/accounting/NgbChartOfAccountsPage.vue +420 -0
  13. package/src/ngb/accounting/NgbGeneralJournalEntryEditPage.vue +796 -0
  14. package/src/ngb/accounting/NgbGeneralJournalEntryLinesEditor.vue +454 -0
  15. package/src/ngb/accounting/NgbGeneralJournalEntryListPage.vue +186 -0
  16. package/src/ngb/accounting/api.ts +79 -0
  17. package/src/ngb/accounting/generalJournalEntry.ts +127 -0
  18. package/src/ngb/accounting/generalJournalEntryApi.ts +112 -0
  19. package/src/ngb/accounting/generalJournalEntryTypes.ts +155 -0
  20. package/src/ngb/accounting/navigation.ts +83 -0
  21. package/src/ngb/accounting/periodClosing.ts +75 -0
  22. package/src/ngb/accounting/periodClosingApi.ts +60 -0
  23. package/src/ngb/accounting/periodClosingTypes.ts +73 -0
  24. package/src/ngb/accounting/types.ts +57 -0
  25. package/src/ngb/api/audit.ts +23 -0
  26. package/src/ngb/api/catalogs.ts +50 -0
  27. package/src/ngb/api/contracts.ts +285 -0
  28. package/src/ngb/api/documents.ts +170 -0
  29. package/src/ngb/api/http.ts +446 -0
  30. package/src/ngb/api/lookups.ts +14 -0
  31. package/src/ngb/api/types.ts +6 -0
  32. package/src/ngb/auth/index.ts +13 -0
  33. package/src/ngb/auth/keycloak.ts +374 -0
  34. package/src/ngb/auth/router.ts +35 -0
  35. package/src/ngb/auth/types.ts +12 -0
  36. package/src/ngb/auth/useAuthStore.ts +209 -0
  37. package/src/ngb/command-palette/NgbCommandPaletteDialog.vue +342 -0
  38. package/src/ngb/command-palette/NgbCommandPaletteEmptyState.vue +55 -0
  39. package/src/ngb/command-palette/NgbCommandPaletteFooterHints.vue +29 -0
  40. package/src/ngb/command-palette/api.ts +10 -0
  41. package/src/ngb/command-palette/config.ts +16 -0
  42. package/src/ngb/command-palette/presentation.ts +85 -0
  43. package/src/ngb/command-palette/search.ts +130 -0
  44. package/src/ngb/command-palette/staticItems.ts +243 -0
  45. package/src/ngb/command-palette/storage.ts +45 -0
  46. package/src/ngb/command-palette/store.ts +716 -0
  47. package/src/ngb/command-palette/types.ts +120 -0
  48. package/src/ngb/command-palette/useCommandPaletteHotkeys.ts +27 -0
  49. package/src/ngb/command-palette/useCommandPalettePageContext.ts +18 -0
  50. package/src/ngb/components/NgbConfirmDialog.vue +63 -0
  51. package/src/ngb/components/NgbDialog.vue +44 -0
  52. package/src/ngb/components/NgbDrawer.vue +156 -0
  53. package/src/ngb/components/NgbHeaderActionCluster.vue +140 -0
  54. package/src/ngb/components/NgbModalShell.vue +99 -0
  55. package/src/ngb/components/NgbToolbar.vue +14 -0
  56. package/src/ngb/components/forms/NgbFormLayout.vue +13 -0
  57. package/src/ngb/components/forms/NgbFormRow.vue +21 -0
  58. package/src/ngb/components/forms/NgbFormSection.vue +16 -0
  59. package/src/ngb/components/forms/NgbValidationSummary.vue +18 -0
  60. package/src/ngb/components/navigation/NgbNavigationTree.vue +68 -0
  61. package/src/ngb/components/navigation/NgbTreeNode.vue +94 -0
  62. package/src/ngb/components/register/NgbColumnChooser.vue +101 -0
  63. package/src/ngb/components/register/NgbRegisterGrid.vue +473 -0
  64. package/src/ngb/components/register/registerTypes.ts +51 -0
  65. package/src/ngb/components/register/useRegisterColumnResize.ts +99 -0
  66. package/src/ngb/components/register/useRegisterColumnState.ts +220 -0
  67. package/src/ngb/components/register/useRegisterRows.ts +209 -0
  68. package/src/ngb/components/register/useRegisterViewport.ts +56 -0
  69. package/src/ngb/editor/NgbDocumentEffectsPage.vue +512 -0
  70. package/src/ngb/editor/NgbDocumentFlowPage.vue +409 -0
  71. package/src/ngb/editor/NgbDocumentPrintPage.vue +712 -0
  72. package/src/ngb/editor/NgbEditorDiscardDialog.vue +40 -0
  73. package/src/ngb/editor/NgbEntityAuditSidebar.vue +288 -0
  74. package/src/ngb/editor/NgbEntityEditor.vue +250 -0
  75. package/src/ngb/editor/NgbEntityEditorDrawerActions.vue +88 -0
  76. package/src/ngb/editor/NgbEntityEditorHeader.vue +114 -0
  77. package/src/ngb/editor/catalogNavigation.ts +13 -0
  78. package/src/ngb/editor/config.ts +252 -0
  79. package/src/ngb/editor/documentCopyDraft.ts +245 -0
  80. package/src/ngb/editor/documentEffects.ts +43 -0
  81. package/src/ngb/editor/documentEffectsDateFormatting.ts +18 -0
  82. package/src/ngb/editor/documentNavigation.ts +115 -0
  83. package/src/ngb/editor/documentStatus.ts +45 -0
  84. package/src/ngb/editor/entityEditorErrors.ts +162 -0
  85. package/src/ngb/editor/entityEditorPersistence.ts +241 -0
  86. package/src/ngb/editor/extensions.ts +40 -0
  87. package/src/ngb/editor/types.ts +295 -0
  88. package/src/ngb/editor/useConfiguredEntityEditorDocumentActions.ts +183 -0
  89. package/src/ngb/editor/useDocumentEditorDrawerState.ts +152 -0
  90. package/src/ngb/editor/useEditorDrawerState.ts +88 -0
  91. package/src/ngb/editor/useEntityEditorBusinessContext.ts +97 -0
  92. package/src/ngb/editor/useEntityEditorCapabilities.ts +190 -0
  93. package/src/ngb/editor/useEntityEditorCommandPalette.ts +154 -0
  94. package/src/ngb/editor/useEntityEditorCommitHandlers.ts +102 -0
  95. package/src/ngb/editor/useEntityEditorHeaderActions.ts +253 -0
  96. package/src/ngb/editor/useEntityEditorLeaveGuard.ts +66 -0
  97. package/src/ngb/editor/useEntityEditorNavigationActions.ts +235 -0
  98. package/src/ngb/editor/useEntityEditorOutputs.ts +59 -0
  99. package/src/ngb/editor/useEntityEditorPageActions.ts +57 -0
  100. package/src/ngb/editor/useRouteQueryEditorDrawer.ts +173 -0
  101. package/src/ngb/env/runtimeConfig.ts +25 -0
  102. package/src/ngb/lookup/config.ts +42 -0
  103. package/src/ngb/lookup/defaultConfig.ts +136 -0
  104. package/src/ngb/lookup/navigation.ts +76 -0
  105. package/src/ngb/lookup/prefetch.ts +40 -0
  106. package/src/ngb/lookup/store.ts +261 -0
  107. package/src/ngb/metadata/NgbDocumentListFiltersDrawer.vue +78 -0
  108. package/src/ngb/metadata/NgbDocumentPeriodFilter.vue +53 -0
  109. package/src/ngb/metadata/NgbEntityForm.vue +84 -0
  110. package/src/ngb/metadata/NgbEntityFormFieldsBlock.vue +110 -0
  111. package/src/ngb/metadata/NgbEntityListPageHeader.vue +80 -0
  112. package/src/ngb/metadata/NgbFilterFieldControl.vue +104 -0
  113. package/src/ngb/metadata/NgbMetadataCatalogEditPage.vue +42 -0
  114. package/src/ngb/metadata/NgbMetadataCatalogListPage.vue +269 -0
  115. package/src/ngb/metadata/NgbMetadataDocumentEditPage.vue +68 -0
  116. package/src/ngb/metadata/NgbMetadataDocumentListPage.vue +353 -0
  117. package/src/ngb/metadata/NgbMetadataFieldRenderer.vue +124 -0
  118. package/src/ngb/metadata/NgbMetadataLookupControl.vue +92 -0
  119. package/src/ngb/metadata/NgbRecycleBinFilter.vue +53 -0
  120. package/src/ngb/metadata/NgbRegisterPageLayout.vue +143 -0
  121. package/src/ngb/metadata/config.ts +28 -0
  122. package/src/ngb/metadata/dataTypes.ts +27 -0
  123. package/src/ngb/metadata/entityForm.ts +114 -0
  124. package/src/ngb/metadata/entityModel.ts +34 -0
  125. package/src/ngb/metadata/entityValueFormatting.ts +83 -0
  126. package/src/ngb/metadata/fieldRendererState.ts +84 -0
  127. package/src/ngb/metadata/filtering.ts +153 -0
  128. package/src/ngb/metadata/lookup.ts +25 -0
  129. package/src/ngb/metadata/monthPagedListQuery.ts +108 -0
  130. package/src/ngb/metadata/normalization.ts +100 -0
  131. package/src/ngb/metadata/referenceHydration.ts +99 -0
  132. package/src/ngb/metadata/register.ts +135 -0
  133. package/src/ngb/metadata/routePages.ts +86 -0
  134. package/src/ngb/metadata/store.ts +35 -0
  135. package/src/ngb/metadata/types.ts +276 -0
  136. package/src/ngb/metadata/useMetadataListFilters.ts +306 -0
  137. package/src/ngb/metadata/useMetadataRegisterPageData.ts +246 -0
  138. package/src/ngb/metadata/useValidationFocus.ts +49 -0
  139. package/src/ngb/primitives/NgbBadge.vue +31 -0
  140. package/src/ngb/primitives/NgbButton.vue +53 -0
  141. package/src/ngb/primitives/NgbCheckbox.vue +33 -0
  142. package/src/ngb/primitives/NgbDatePicker.vue +141 -0
  143. package/src/ngb/primitives/NgbIcon.vue +545 -0
  144. package/src/ngb/primitives/NgbInput.vue +57 -0
  145. package/src/ngb/primitives/NgbLookup.vue +264 -0
  146. package/src/ngb/primitives/NgbMonthPicker.vue +132 -0
  147. package/src/ngb/primitives/NgbMultiSelect.vue +179 -0
  148. package/src/ngb/primitives/NgbPeriodPicker.vue +125 -0
  149. package/src/ngb/primitives/NgbPickerNavButton.vue +31 -0
  150. package/src/ngb/primitives/NgbPickerPopover.vue +79 -0
  151. package/src/ngb/primitives/NgbRadioGroup.vue +53 -0
  152. package/src/ngb/primitives/NgbSelect.vue +108 -0
  153. package/src/ngb/primitives/NgbStatusIcon.vue +63 -0
  154. package/src/ngb/primitives/NgbSwitch.vue +42 -0
  155. package/src/ngb/primitives/NgbTabs.vue +116 -0
  156. package/src/ngb/primitives/NgbToastHost.vue +49 -0
  157. package/src/ngb/primitives/NgbTooltip.vue +27 -0
  158. package/src/ngb/primitives/iconNames.ts +83 -0
  159. package/src/ngb/primitives/toast.ts +66 -0
  160. package/src/ngb/primitives/useAsyncComboboxQuery.ts +85 -0
  161. package/src/ngb/primitives/useFloatingDropdownPosition.ts +129 -0
  162. package/src/ngb/reporting/NgbReportComposerCollectionSection.vue +106 -0
  163. package/src/ngb/reporting/NgbReportComposerPanel.vue +1187 -0
  164. package/src/ngb/reporting/NgbReportDateRangeFilter.vue +54 -0
  165. package/src/ngb/reporting/NgbReportPage.vue +1124 -0
  166. package/src/ngb/reporting/NgbReportSheet.vue +467 -0
  167. package/src/ngb/reporting/api.ts +35 -0
  168. package/src/ngb/reporting/composer.ts +904 -0
  169. package/src/ngb/reporting/config.ts +120 -0
  170. package/src/ngb/reporting/defaultConfig.ts +15 -0
  171. package/src/ngb/reporting/lookupFilters.ts +43 -0
  172. package/src/ngb/reporting/navigation.ts +181 -0
  173. package/src/ngb/reporting/pageHelpers.ts +73 -0
  174. package/src/ngb/reporting/pageSession.ts +92 -0
  175. package/src/ngb/reporting/paging.ts +66 -0
  176. package/src/ngb/reporting/types.ts +333 -0
  177. package/src/ngb/router/backNavigation.ts +122 -0
  178. package/src/ngb/router/queryParams.ts +129 -0
  179. package/src/ngb/router/queryState.ts +121 -0
  180. package/src/ngb/router/routeAliases.ts +66 -0
  181. package/src/ngb/router/routeParams.ts +15 -0
  182. package/src/ngb/router/shareLink.ts +39 -0
  183. package/src/ngb/security/NgbAccessAuditPanel.vue +32 -0
  184. package/src/ngb/security/NgbAccessDeniedState.vue +27 -0
  185. package/src/ngb/security/NgbEffectiveAccessPanel.vue +60 -0
  186. package/src/ngb/security/NgbPermissionMatrix.vue +160 -0
  187. package/src/ngb/security/NgbRoleEditorPage.vue +319 -0
  188. package/src/ngb/security/NgbRolesPage.vue +125 -0
  189. package/src/ngb/security/NgbUserEditorPage.vue +615 -0
  190. package/src/ngb/security/NgbUsersPage.vue +125 -0
  191. package/src/ngb/security/api.ts +91 -0
  192. package/src/ngb/security/audit.ts +21 -0
  193. package/src/ngb/security/permissions.ts +65 -0
  194. package/src/ngb/security/types.ts +157 -0
  195. package/src/ngb/security/useAccessStore.ts +69 -0
  196. package/src/ngb/site/NgbDashboardAsOfToolbar.vue +100 -0
  197. package/src/ngb/site/NgbDashboardStatusBanner.vue +91 -0
  198. package/src/ngb/site/NgbLogo.vue +44 -0
  199. package/src/ngb/site/NgbPageHeader.vue +49 -0
  200. package/src/ngb/site/NgbSiteShell.vue +286 -0
  201. package/src/ngb/site/NgbSiteSidebar.vue +270 -0
  202. package/src/ngb/site/NgbTopBar.vue +425 -0
  203. package/src/ngb/site/NgbTrendChart.vue +224 -0
  204. package/src/ngb/site/dashboardData.ts +324 -0
  205. package/src/ngb/site/mainMenuStore.ts +49 -0
  206. package/src/ngb/site/types.ts +31 -0
  207. package/src/ngb/site/useDashboardPageState.ts +98 -0
  208. package/src/ngb/site/useTheme.ts +95 -0
  209. package/src/ngb/utils/clone.ts +43 -0
  210. package/src/ngb/utils/dateValues.ts +107 -0
  211. package/src/ngb/utils/errorMessage.ts +14 -0
  212. package/src/ngb/utils/guid.ts +21 -0
  213. package/src/ngb/utils/stableValue.ts +21 -0
  214. package/src/ngb/utils/storage.ts +135 -0
  215. package/src/styles/tailwind.css +165 -0
  216. package/src/vite/publicAssets.ts +104 -0
  217. package/vite-public-assets.js +68 -0
@@ -0,0 +1,264 @@
1
+ <template>
2
+ <div class="w-full">
3
+ <label v-if="label" class="block text-xs font-semibold text-ngb-muted mb-1">{{ label }}</label>
4
+
5
+ <Combobox
6
+ :modelValue="modelValue"
7
+ @update:modelValue="onSelect"
8
+ :disabled="comboboxDisabled"
9
+ by="id"
10
+ >
11
+ <div class="relative" :title="tooltipText || undefined">
12
+ <ComboboxInput
13
+ ref="inputRef"
14
+ :class="[inputClass, comboboxDisabled ? 'opacity-60 cursor-not-allowed' : '']"
15
+ :displayValue="displayValue"
16
+ :placeholder="placeholder"
17
+ :disabled="comboboxDisabled"
18
+ :readonly="readonly"
19
+ :title="tooltipText || undefined"
20
+ @input="onInput(($event.target as HTMLInputElement).value)"
21
+ @mousedown.capture="updatePosition"
22
+ @focus.capture="updatePosition"
23
+ @keydown.arrow-down.capture="updatePosition"
24
+ @keydown.enter.capture="updatePosition"
25
+ />
26
+
27
+ <div class="absolute inset-y-0 right-0 flex items-center pr-1">
28
+ <button
29
+ v-if="showOpen"
30
+ type="button"
31
+ :class="actionButtonClass"
32
+ :disabled="disabled || !modelValue"
33
+ aria-label="Open"
34
+ title="Open"
35
+ @mousedown.prevent.stop
36
+ @click.prevent.stop="emit('open')"
37
+ >
38
+ <NgbIcon name="open-in-new" :size="actionIconSize" />
39
+ </button>
40
+
41
+ <button
42
+ v-if="showClear"
43
+ type="button"
44
+ :class="actionButtonClass"
45
+ :disabled="disabled || !modelValue"
46
+ aria-label="Clear"
47
+ title="Clear"
48
+ @mousedown.prevent.stop
49
+ @click.prevent.stop="clearSelection"
50
+ >
51
+ <NgbIcon name="x" :size="actionIconSize" />
52
+ </button>
53
+
54
+ <ComboboxButton
55
+ :class="[dropdownButtonClass, comboboxDisabled ? 'pointer-events-none' : '']"
56
+ aria-label="Show options"
57
+ @mousedown.capture="updatePosition"
58
+ >
59
+
60
+ </ComboboxButton>
61
+ </div>
62
+
63
+ <!--
64
+ Teleport dropdown to body to avoid clipping inside tables/scroll regions.
65
+ Position is computed from the input's viewport rect.
66
+ -->
67
+ <Teleport to="body">
68
+ <ComboboxOptions
69
+ v-if="filteredItems.length > 0"
70
+ ref="optionsRef"
71
+ :style="floatingStyle"
72
+ class="fixed z-[1000] max-h-72 overflow-auto rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card shadow-card p-1 focus:outline-none"
73
+ >
74
+ <ComboboxOption
75
+ v-for="item in filteredItems"
76
+ :key="item.id"
77
+ :value="item"
78
+ v-slot="{ active, selected }"
79
+ >
80
+ <div
81
+ class="px-3 py-2 rounded-[var(--ngb-radius)] text-sm flex items-start justify-between gap-3"
82
+ :class="active ? 'bg-[var(--ngb-row-hover)]' : ''"
83
+ :title="optionTooltip(item) || undefined"
84
+ >
85
+ <div class="min-w-0">
86
+ <div class="truncate" :class="selected ? 'font-semibold' : ''">{{ item.label }}</div>
87
+ <div v-if="item.meta" class="text-xs text-ngb-muted truncate mt-0.5">{{ item.meta }}</div>
88
+ </div>
89
+ <div v-if="selected" class="text-ngb-blue shrink-0">✓</div>
90
+ </div>
91
+ </ComboboxOption>
92
+ </ComboboxOptions>
93
+
94
+ <div
95
+ v-else-if="query && !comboboxDisabled"
96
+ ref="emptyRef"
97
+ :style="floatingStyle"
98
+ class="fixed z-[1000] rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card shadow-card p-3 text-sm text-ngb-muted"
99
+ >
100
+ <span v-if="isSearching">Searching…</span>
101
+ <span v-else>No results</span>
102
+ </div>
103
+ </Teleport>
104
+ </div>
105
+ </Combobox>
106
+
107
+ <div v-if="hint" class="mt-1 text-xs text-ngb-muted">{{ hint }}</div>
108
+ </div>
109
+ </template>
110
+
111
+ <script setup lang="ts">
112
+ import { computed, onBeforeUnmount, ref, watch, type ComponentPublicInstance } from 'vue'
113
+ import { Combobox, ComboboxInput, ComboboxButton, ComboboxOptions, ComboboxOption } from '@headlessui/vue'
114
+ import NgbIcon from './NgbIcon.vue'
115
+ import { useFloatingDropdownPosition } from './useFloatingDropdownPosition'
116
+ import { useAsyncComboboxQuery } from './useAsyncComboboxQuery'
117
+
118
+ export type LookupItem = { id: string; label: string; meta?: string }
119
+ type LookupVariant = 'default' | 'grid' | 'compact'
120
+ type FloatingTarget = HTMLElement | ComponentPublicInstance | null
121
+
122
+ const props = withDefaults(defineProps<{
123
+ modelValue: LookupItem | null
124
+ items: LookupItem[]
125
+ label?: string
126
+ hint?: string
127
+ placeholder?: string
128
+ disabled?: boolean
129
+ readonly?: boolean
130
+ showOpen?: boolean
131
+ showClear?: boolean
132
+ // Visual variant.
133
+ // - default: standard input (bordered)
134
+ // - grid: borderless cell-style input (used inside line grids)
135
+ // - compact: compact bordered input (used in page headers/toolbars)
136
+ variant?: LookupVariant
137
+ }>(), {
138
+ label: '',
139
+ hint: '',
140
+ placeholder: '',
141
+ disabled: false,
142
+ readonly: false,
143
+ showOpen: false,
144
+ showClear: false,
145
+ variant: 'default',
146
+ })
147
+
148
+ const emit = defineEmits<{
149
+ (e: 'update:modelValue', v: LookupItem | null): void
150
+ (e: 'query', v: string): void
151
+ (e: 'open'): void
152
+ }>()
153
+
154
+ const comboboxDisabled = computed(() => !!props.disabled || !!props.readonly)
155
+
156
+ const actionCount = computed(() => 1 + (props.showOpen ? 1 : 0) + (props.showClear ? 1 : 0))
157
+
158
+ function inputPaddingClass(count: number, variant: LookupVariant): string {
159
+ if (variant === 'grid') {
160
+ if (count >= 3) return 'pr-20'
161
+ if (count === 2) return 'pr-14'
162
+ return 'pr-9'
163
+ }
164
+
165
+ if (variant === 'compact') {
166
+ if (count >= 3) return 'pr-20'
167
+ if (count === 2) return 'pr-14'
168
+ return 'pr-8'
169
+ }
170
+
171
+ if (count >= 3) return 'pr-24'
172
+ if (count === 2) return 'pr-16'
173
+ return 'pr-10'
174
+ }
175
+
176
+ const inputClass = computed(() => {
177
+ if (props.variant === 'grid') {
178
+ return `h-8 w-full rounded-none border border-transparent bg-transparent px-2 text-sm text-ngb-text placeholder:text-ngb-muted/70 ngb-focus ${inputPaddingClass(actionCount.value, props.variant)}`
179
+ }
180
+
181
+ if (props.variant === 'compact') {
182
+ return `h-[26px] w-full rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card px-3 text-xs text-ngb-text placeholder:text-ngb-muted/70 shadow-card ngb-focus ${inputPaddingClass(actionCount.value, props.variant)}`
183
+ }
184
+
185
+ return `h-9 w-full rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card px-3 text-sm text-ngb-text placeholder:text-ngb-muted/70 ngb-focus ${inputPaddingClass(actionCount.value, props.variant)}`
186
+ })
187
+
188
+ const actionButtonClass = computed(() => {
189
+ const base = 'inline-flex items-center justify-center text-ngb-muted transition-colors hover:text-ngb-text disabled:cursor-not-allowed disabled:opacity-40'
190
+ if (props.variant === 'grid') return `${base} h-8 w-6`
191
+ if (props.variant === 'compact') return `${base} h-[26px] w-6 text-[11px]`
192
+ return `${base} h-9 w-8`
193
+ })
194
+
195
+ const dropdownButtonClass = computed(() => {
196
+ if (props.variant === 'grid') return `${actionButtonClass.value} text-[11px]`
197
+ if (props.variant === 'compact') return `${actionButtonClass.value} text-[10px]`
198
+ return actionButtonClass.value
199
+ })
200
+
201
+ const actionIconSize = computed(() => (props.variant === 'compact' ? 13 : 14))
202
+ const tooltipText = computed(() => {
203
+ const label = String(props.modelValue?.label ?? '').trim()
204
+ return label.length > 0 ? label : ''
205
+ })
206
+
207
+ const {
208
+ query,
209
+ isSearching,
210
+ clearPendingState,
211
+ onInput,
212
+ resetQueryState,
213
+ } = useAsyncComboboxQuery({
214
+ disabled: comboboxDisabled,
215
+ items: computed(() => props.items),
216
+ emitQuery: (value) => emit('query', value),
217
+ })
218
+
219
+ const filteredItems = computed(() => {
220
+ const q = query.value.trim().toLowerCase()
221
+ if (!q) return props.items.slice(0, 50)
222
+ return props.items
223
+ .filter((item) => `${item.label} ${item.meta ?? ''}`.toLowerCase().includes(q))
224
+ .slice(0, 50)
225
+ })
226
+
227
+ function displayValue(item: LookupItem | null) {
228
+ return item?.label ?? ''
229
+ }
230
+
231
+ function optionTooltip(item: LookupItem): string {
232
+ const label = String(item.label ?? '').trim()
233
+ const meta = String(item.meta ?? '').trim()
234
+ if (!meta) return label
235
+ if (!label) return meta
236
+ return `${label} - ${meta}`
237
+ }
238
+
239
+ onBeforeUnmount(() => clearPendingState())
240
+
241
+ function onSelect(v: LookupItem | null) {
242
+ emit('update:modelValue', v)
243
+ resetQueryState()
244
+ }
245
+
246
+ function clearSelection() {
247
+ if (!props.modelValue) return
248
+ emit('update:modelValue', null)
249
+ resetQueryState({ emitEmptyQuery: true })
250
+ }
251
+
252
+ const inputRef = ref<FloatingTarget>(null)
253
+ const optionsRef = ref<FloatingTarget>(null)
254
+ const emptyRef = ref<FloatingTarget>(null)
255
+
256
+ const { floatingStyle, updatePosition } = useFloatingDropdownPosition(inputRef, [optionsRef, emptyRef])
257
+
258
+ watch(
259
+ () => [filteredItems.value.length, query.value, isSearching.value] as const,
260
+ () => {
261
+ updatePosition()
262
+ },
263
+ )
264
+ </script>
@@ -0,0 +1,132 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import NgbPickerPopover from './NgbPickerPopover.vue'
4
+ import NgbPickerNavButton from './NgbPickerNavButton.vue'
5
+ import {
6
+ currentMonthValue,
7
+ formatMonthValue,
8
+ normalizeMonthValue,
9
+ parseMonthValue,
10
+ toMonthValue,
11
+ } from '../utils/dateValues'
12
+
13
+ const props = defineProps<{
14
+ modelValue: string | null | undefined
15
+ placeholder?: string
16
+ disabled?: boolean
17
+ readonly?: boolean
18
+ grouped?: boolean
19
+ displayFormat?: 'short' | 'long'
20
+ }>()
21
+
22
+ const emit = defineEmits<{
23
+ (e: 'update:modelValue', v: string | null): void
24
+ }>()
25
+
26
+ function toDisplay(v: string | null | undefined, format: 'short' | 'long'): string {
27
+ if (!v) return ''
28
+ return formatMonthValue(v, { month: format, year: 'numeric' }) ?? String(v)
29
+ }
30
+
31
+ const months = [
32
+ { value: 1, label: 'Jan' },
33
+ { value: 2, label: 'Feb' },
34
+ { value: 3, label: 'Mar' },
35
+ { value: 4, label: 'Apr' },
36
+ { value: 5, label: 'May' },
37
+ { value: 6, label: 'Jun' },
38
+ { value: 7, label: 'Jul' },
39
+ { value: 8, label: 'Aug' },
40
+ { value: 9, label: 'Sep' },
41
+ { value: 10, label: 'Oct' },
42
+ { value: 11, label: 'Nov' },
43
+ { value: 12, label: 'Dec' },
44
+ ] as const
45
+
46
+ const selected = computed(() => parseMonthValue(props.modelValue ?? null))
47
+ const viewYear = ref<number>(selected.value?.year ?? parseMonthValue(currentMonthValue())?.year ?? new Date().getFullYear())
48
+
49
+ watch(
50
+ () => props.modelValue,
51
+ (v) => {
52
+ const ym = parseMonthValue(normalizeMonthValue(v) ?? null)
53
+ if (!ym) return
54
+ viewYear.value = ym.year
55
+ }
56
+ )
57
+
58
+ const displayValue = computed(() => toDisplay(props.modelValue ?? null, props.displayFormat ?? 'long'))
59
+
60
+ function prevYear() {
61
+ viewYear.value -= 1
62
+ }
63
+
64
+ function nextYear() {
65
+ viewYear.value += 1
66
+ }
67
+
68
+ function pick(month: number, close: () => void) {
69
+ emit('update:modelValue', toMonthValue(viewYear.value, month))
70
+ close()
71
+ }
72
+
73
+ function clear(close: () => void) {
74
+ emit('update:modelValue', null)
75
+ close()
76
+ }
77
+
78
+ function setCurrent(close: () => void) {
79
+ emit('update:modelValue', currentMonthValue())
80
+ close()
81
+ }
82
+
83
+ function isSelected(month: number): boolean {
84
+ return selected.value?.year === viewYear.value && selected.value?.month === month
85
+ }
86
+
87
+ function isCurrent(month: number): boolean {
88
+ const ym = parseMonthValue(currentMonthValue())
89
+ return ym?.year === viewYear.value && ym.month === month
90
+ }
91
+ </script>
92
+
93
+ <template>
94
+ <NgbPickerPopover
95
+ :display-value="displayValue"
96
+ :placeholder="placeholder ?? 'Select month'"
97
+ :disabled="disabled"
98
+ :readonly="readonly"
99
+ :grouped="grouped"
100
+ >
101
+ <template #header>
102
+ <NgbPickerNavButton direction="prev" @click="prevYear" />
103
+ <div class="text-sm font-semibold text-ngb-text">{{ viewYear }}</div>
104
+ <NgbPickerNavButton direction="next" @click="nextYear" />
105
+ </template>
106
+
107
+ <template #default="{ close }">
108
+ <div class="grid grid-cols-4 gap-2">
109
+ <button
110
+ v-for="month in months"
111
+ :key="month.value"
112
+ type="button"
113
+ class="h-10 rounded-md border text-sm transition-colors"
114
+ :class="[
115
+ isSelected(month.value)
116
+ ? 'border-ngb-primary/50 bg-ngb-primary/20 text-ngb-text'
117
+ : 'border-ngb-border bg-transparent text-ngb-text hover:bg-ngb-muted/10',
118
+ isCurrent(month.value) ? 'ring-1 ring-ngb-primary/40' : ''
119
+ ]"
120
+ @click="pick(month.value, close)"
121
+ >
122
+ {{ month.label }}
123
+ </button>
124
+ </div>
125
+ </template>
126
+
127
+ <template #footer="{ close }">
128
+ <button type="button" class="text-ngb-primary hover:underline" @click="clear(close)">Clear</button>
129
+ <button type="button" class="text-ngb-primary hover:underline" @click="setCurrent(close)">This month</button>
130
+ </template>
131
+ </NgbPickerPopover>
132
+ </template>
@@ -0,0 +1,179 @@
1
+ <template>
2
+ <div class="w-full">
3
+ <label v-if="label" class="block text-xs font-semibold text-ngb-muted mb-1">{{ label }}</label>
4
+
5
+ <Combobox
6
+ multiple
7
+ :modelValue="modelValue"
8
+ @update:modelValue="onSelect"
9
+ :disabled="disabled"
10
+ by="id"
11
+ >
12
+ <div ref="anchorRef" class="relative">
13
+ <div
14
+ class="min-h-9 w-full rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card px-2 py-1 flex flex-wrap items-center gap-1 ngb-focus"
15
+ :class="disabled ? 'opacity-60 cursor-not-allowed' : ''"
16
+ >
17
+ <span
18
+ v-for="item in modelValue"
19
+ :key="item.id"
20
+ class="inline-flex items-center gap-1 rounded-full border border-ngb-border bg-[var(--ngb-row-hover)] px-2 py-0.5 text-[11px] font-semibold"
21
+ :title="multiSelectItemTooltip(item) || undefined"
22
+ >
23
+ <span class="truncate max-w-[180px]">{{ item.label }}</span>
24
+ <button
25
+ v-if="!disabled"
26
+ type="button"
27
+ class="text-ngb-muted hover:text-ngb-text"
28
+ aria-label="Remove"
29
+ @click.stop="remove(item)"
30
+ >
31
+ ×
32
+ </button>
33
+ </span>
34
+
35
+ <ComboboxInput
36
+ class="h-7 flex-1 min-w-[120px] bg-transparent px-2 text-sm text-ngb-text placeholder:text-ngb-muted/70 outline-none"
37
+ :displayValue="() => ''"
38
+ :placeholder="modelValue.length === 0 ? placeholder : ''"
39
+ @input="onQueryInput(($event.target as HTMLInputElement).value)"
40
+ @mousedown.capture="updatePosition"
41
+ @focus.capture="updatePosition"
42
+ @keydown.arrow-down.capture="updatePosition"
43
+ @keydown.enter.capture="updatePosition"
44
+ @keydown.backspace.capture="onBackspace"
45
+ />
46
+ </div>
47
+
48
+ <Teleport to="body">
49
+ <ComboboxOptions
50
+ v-if="filteredItems.length > 0"
51
+ ref="optionsRef"
52
+ :style="floatingStyle"
53
+ class="fixed z-[1000] max-h-72 overflow-auto rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card shadow-card p-1 focus:outline-none"
54
+ >
55
+ <ComboboxOption
56
+ v-for="item in filteredItems"
57
+ :key="item.id"
58
+ :value="item"
59
+ v-slot="{ active, selected }"
60
+ >
61
+ <div
62
+ class="px-3 py-2 rounded-[var(--ngb-radius)] text-sm flex items-center justify-between gap-3"
63
+ :class="active ? 'bg-[var(--ngb-row-hover)]' : ''"
64
+ :title="multiSelectItemTooltip(item) || undefined"
65
+ >
66
+ <div class="min-w-0">
67
+ <div class="truncate" :class="selected ? 'font-semibold' : ''">{{ item.label }}</div>
68
+ <div v-if="item.meta" class="text-xs text-ngb-muted truncate mt-0.5">{{ item.meta }}</div>
69
+ </div>
70
+ <div v-if="selected" class="text-ngb-blue shrink-0">✓</div>
71
+ </div>
72
+ </ComboboxOption>
73
+ </ComboboxOptions>
74
+
75
+ <div
76
+ v-else-if="query.trim() && !disabled"
77
+ ref="emptyRef"
78
+ :style="floatingStyle"
79
+ class="fixed z-[1000] rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card shadow-card p-3 text-sm text-ngb-muted"
80
+ >
81
+ <span v-if="isSearching">Searching…</span>
82
+ <span v-else>No results</span>
83
+ </div>
84
+ </Teleport>
85
+ </div>
86
+ </Combobox>
87
+
88
+ <div v-if="hint" class="mt-1 text-xs text-ngb-muted">{{ hint }}</div>
89
+ </div>
90
+ </template>
91
+
92
+ <script setup lang="ts">
93
+ import { computed, onBeforeUnmount, ref, watch, type ComponentPublicInstance } from 'vue'
94
+ import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from '@headlessui/vue'
95
+
96
+ import { useFloatingDropdownPosition } from './useFloatingDropdownPosition'
97
+ import { useAsyncComboboxQuery } from './useAsyncComboboxQuery'
98
+
99
+ export type MultiSelectItem = { id: string; label: string; meta?: string }
100
+ type FloatingTarget = HTMLElement | ComponentPublicInstance | null
101
+
102
+ const props = defineProps<{
103
+ modelValue: MultiSelectItem[]
104
+ items: MultiSelectItem[]
105
+ label?: string
106
+ hint?: string
107
+ placeholder?: string
108
+ disabled?: boolean
109
+ }>()
110
+
111
+ const emit = defineEmits<{
112
+ (e: 'update:modelValue', v: MultiSelectItem[]): void
113
+ (e: 'query', v: string): void
114
+ }>()
115
+
116
+ const {
117
+ query,
118
+ isSearching,
119
+ resetQueryState,
120
+ onInput,
121
+ } = useAsyncComboboxQuery({
122
+ disabled: computed(() => !!props.disabled),
123
+ items: computed(() => props.items),
124
+ emitQuery: (value) => emit('query', value),
125
+ })
126
+
127
+ const filteredItems = computed(() => {
128
+ const normalizedQuery = query.value.trim().toLowerCase()
129
+ const selectedIds = new Set(props.modelValue.map((item) => item.id))
130
+ const source = props.items.filter((item) => !selectedIds.has(item.id))
131
+ if (!normalizedQuery) return source.slice(0, 50)
132
+
133
+ return source
134
+ .filter((item) => `${item.label} ${item.meta ?? ''}`.toLowerCase().includes(normalizedQuery))
135
+ .slice(0, 50)
136
+ })
137
+ const anchorRef = ref<FloatingTarget>(null)
138
+ const optionsRef = ref<FloatingTarget>(null)
139
+ const emptyRef = ref<FloatingTarget>(null)
140
+
141
+ const { floatingStyle, updatePosition } = useFloatingDropdownPosition(anchorRef, [optionsRef, emptyRef])
142
+
143
+ function remove(item: MultiSelectItem) {
144
+ emit('update:modelValue', props.modelValue.filter((entry) => entry.id !== item.id))
145
+ }
146
+
147
+ function multiSelectItemTooltip(item: MultiSelectItem): string {
148
+ const label = String(item.label ?? '').trim()
149
+ const meta = String(item.meta ?? '').trim()
150
+ if (!meta) return label
151
+ if (!label) return meta
152
+ return `${label} - ${meta}`
153
+ }
154
+
155
+ function onQueryInput(value: string) {
156
+ onInput(value)
157
+ updatePosition()
158
+ }
159
+
160
+ function onSelect(value: MultiSelectItem[]) {
161
+ emit('update:modelValue', value)
162
+ resetQueryState({ emitEmptyQuery: true })
163
+ }
164
+
165
+ function onBackspace(event: KeyboardEvent) {
166
+ if (query.value.trim().length > 0 || props.modelValue.length === 0) return
167
+ event.stopPropagation()
168
+ remove(props.modelValue[props.modelValue.length - 1]!)
169
+ }
170
+
171
+ watch(
172
+ () => filteredItems.value.length,
173
+ () => {
174
+ updatePosition()
175
+ },
176
+ )
177
+
178
+ onBeforeUnmount(() => resetQueryState())
179
+ </script>
@@ -0,0 +1,125 @@
1
+ <template>
2
+ <div class="w-full">
3
+ <label v-if="label" class="block text-xs font-semibold text-ngb-muted mb-1">{{ label }}</label>
4
+
5
+ <div class="flex items-center gap-3 flex-wrap">
6
+ <NgbTabs v-model="kind" :tabs="kindTabs" />
7
+
8
+ <div class="flex items-center gap-2">
9
+ <span class="text-xs font-semibold text-ngb-muted">Year</span>
10
+ <div class="w-[112px]">
11
+ <NgbSelect
12
+ :model-value="year"
13
+ :options="yearOptions"
14
+ @update:model-value="onYear(String($event ?? ''))"
15
+ />
16
+ </div>
17
+ </div>
18
+
19
+ <div class="flex items-center gap-2">
20
+ <span class="text-xs font-semibold text-ngb-muted">{{ periodLabel }}</span>
21
+ <div class="w-[112px]">
22
+ <NgbSelect
23
+ :model-value="period"
24
+ :options="periodOptions"
25
+ @update:model-value="onPeriod(String($event ?? ''))"
26
+ />
27
+ </div>
28
+ </div>
29
+ </div>
30
+
31
+ <div v-if="hint" class="mt-1 text-xs text-ngb-muted">{{ hint }}</div>
32
+ </div>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { computed } from 'vue'
37
+
38
+ import NgbSelect from './NgbSelect.vue'
39
+ import NgbTabs from './NgbTabs.vue'
40
+
41
+ export type PeriodKind = 'month' | 'quarter' | 'year'
42
+ export type PeriodValue = {
43
+ kind: PeriodKind
44
+ year: number
45
+ period: number
46
+ }
47
+
48
+ const props = defineProps<{
49
+ modelValue: PeriodValue
50
+ label?: string
51
+ hint?: string
52
+ minYear?: number
53
+ maxYear?: number
54
+ }>()
55
+
56
+ const emit = defineEmits<{
57
+ (e: 'update:modelValue', value: PeriodValue): void
58
+ }>()
59
+
60
+ const kindTabs = [
61
+ { key: 'month', label: 'Month' },
62
+ { key: 'quarter', label: 'Quarter' },
63
+ { key: 'year', label: 'Year' },
64
+ ]
65
+
66
+ const years = computed(() => {
67
+ const now = new Date()
68
+ const min = props.minYear ?? (now.getUTCFullYear() - 5)
69
+ const max = props.maxYear ?? (now.getUTCFullYear() + 1)
70
+ const values: number[] = []
71
+
72
+ for (let year = max; year >= min; year -= 1) values.push(year)
73
+ return values
74
+ })
75
+
76
+ const yearOptions = computed(() => years.value.map((year) => ({ value: year, label: String(year) })))
77
+
78
+ const kind = computed({
79
+ get: () => props.modelValue.kind,
80
+ set: (value: string) => {
81
+ const nextKind = value as PeriodKind
82
+ emit('update:modelValue', {
83
+ ...props.modelValue,
84
+ kind: nextKind,
85
+ period: 1,
86
+ })
87
+ },
88
+ })
89
+
90
+ const year = computed(() => props.modelValue.year)
91
+ const period = computed(() => props.modelValue.period)
92
+
93
+ const periodLabel = computed(() => {
94
+ if (props.modelValue.kind === 'month') return 'Month'
95
+ if (props.modelValue.kind === 'quarter') return 'Quarter'
96
+ return 'Period'
97
+ })
98
+
99
+ const periodOptions = computed(() => {
100
+ if (props.modelValue.kind === 'month') {
101
+ return Array.from({ length: 12 }, (_, index) => ({
102
+ value: index + 1,
103
+ label: String(index + 1).padStart(2, '0'),
104
+ }))
105
+ }
106
+
107
+ if (props.modelValue.kind === 'quarter') {
108
+ return [1, 2, 3, 4].map((quarter) => ({ value: quarter, label: `Q${quarter}` }))
109
+ }
110
+
111
+ return [{ value: 1, label: 'FY' }]
112
+ })
113
+
114
+ function onYear(value: string) {
115
+ const nextYear = Number(value)
116
+ if (!Number.isFinite(nextYear)) return
117
+ emit('update:modelValue', { ...props.modelValue, year: nextYear })
118
+ }
119
+
120
+ function onPeriod(value: string) {
121
+ const nextPeriod = Number(value)
122
+ if (!Number.isFinite(nextPeriod)) return
123
+ emit('update:modelValue', { ...props.modelValue, period: nextPeriod })
124
+ }
125
+ </script>