@globalbrain/sefirot 4.34.0 → 4.35.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 (55) hide show
  1. package/config/nuxt.js +53 -2
  2. package/config/vite.js +43 -5
  3. package/lib/blocks/lens/FieldContext.ts +5 -0
  4. package/lib/blocks/lens/FieldData.ts +140 -0
  5. package/lib/blocks/lens/FieldRegistry.ts +23 -0
  6. package/lib/blocks/lens/FileDownloader.ts +1 -0
  7. package/lib/blocks/lens/FilterOperator.ts +33 -0
  8. package/lib/blocks/lens/LensQuery.ts +10 -0
  9. package/lib/blocks/lens/LensResult.ts +20 -0
  10. package/lib/blocks/lens/ResourceFetcher.ts +3 -0
  11. package/lib/blocks/lens/Rule.ts +12 -0
  12. package/lib/blocks/lens/components/LensCatalog.vue +490 -0
  13. package/lib/blocks/lens/components/LensCatalogControl.vue +220 -0
  14. package/lib/blocks/lens/components/LensCatalogFooter.vue +46 -0
  15. package/lib/blocks/lens/components/LensCatalogStateFilter.vue +171 -0
  16. package/lib/blocks/lens/components/LensCatalogStateFilterCondition.vue +86 -0
  17. package/lib/blocks/lens/components/LensCatalogStateFilterGroup.vue +102 -0
  18. package/lib/blocks/lens/components/LensCatalogStateSort.vue +159 -0
  19. package/lib/blocks/lens/components/LensFormFilter.vue +169 -0
  20. package/lib/blocks/lens/components/LensFormFilterCondition.vue +205 -0
  21. package/lib/blocks/lens/components/LensFormFilterGroup.vue +175 -0
  22. package/lib/blocks/lens/components/LensFormOverride.vue +45 -0
  23. package/lib/blocks/lens/components/LensFormOverrideBase.vue +204 -0
  24. package/lib/blocks/lens/components/LensFormView.vue +347 -0
  25. package/lib/blocks/lens/components/LensTable.vue +154 -0
  26. package/lib/blocks/lens/composables/FieldFactory.ts +27 -0
  27. package/lib/blocks/lens/composables/FieldRegistry.ts +16 -0
  28. package/lib/blocks/lens/composables/FileDownloader.ts +10 -0
  29. package/lib/blocks/lens/composables/ResourceFetcher.ts +30 -0
  30. package/lib/blocks/lens/composables/SetupLens.ts +55 -0
  31. package/lib/blocks/lens/fields/ContentField.ts +34 -0
  32. package/lib/blocks/lens/fields/DateField.ts +66 -0
  33. package/lib/blocks/lens/fields/DatetimeField.ts +35 -0
  34. package/lib/blocks/lens/fields/Field.ts +244 -0
  35. package/lib/blocks/lens/fields/FileUploadField.ts +63 -0
  36. package/lib/blocks/lens/fields/IdField.ts +34 -0
  37. package/lib/blocks/lens/fields/LinkField.ts +53 -0
  38. package/lib/blocks/lens/fields/NumberField.ts +32 -0
  39. package/lib/blocks/lens/fields/RelatedManyField.ts +62 -0
  40. package/lib/blocks/lens/fields/SelectField.ts +198 -0
  41. package/lib/blocks/lens/fields/SlackMessageField.ts +34 -0
  42. package/lib/blocks/lens/fields/TextField.ts +46 -0
  43. package/lib/blocks/lens/fields/TextareaField.ts +49 -0
  44. package/lib/blocks/lens/filter-inputs/FilterInput.ts +72 -0
  45. package/lib/blocks/lens/filter-inputs/NumberFilterInput.ts +26 -0
  46. package/lib/blocks/lens/filter-inputs/SelectFilterInput.ts +76 -0
  47. package/lib/blocks/lens/filter-inputs/TextFilterInput.ts +26 -0
  48. package/lib/blocks/lens/validation/RuleMapper.ts +22 -0
  49. package/lib/components/SInputTextarea.vue +28 -10
  50. package/lib/components/STable.vue +230 -61
  51. package/lib/components/STableCell.vue +2 -2
  52. package/lib/composables/TableAnimation.ts +180 -0
  53. package/lib/support/Scroll.ts +263 -0
  54. package/lib/support/Utils.ts +1 -1
  55. package/package.json +7 -15
@@ -0,0 +1,490 @@
1
+ <script setup lang="ts">
2
+ import { useDebounceFn, useElementSize } from '@vueuse/core'
3
+ import { computed, ref, watch } from 'vue'
4
+ import { useQuery } from '../../../composables/Api'
5
+ import { usePower } from '../../../composables/Power'
6
+ import { type FieldData } from '../FieldData'
7
+ import { type LensQuery, type LensQuerySort } from '../LensQuery'
8
+ import { type LensResult } from '../LensResult'
9
+ import LensCatalogControl, { type FilterPresets } from './LensCatalogControl.vue'
10
+ import LensCatalogFooter from './LensCatalogFooter.vue'
11
+ import LensCatalogStateFilter from './LensCatalogStateFilter.vue'
12
+ import LensCatalogStateSort from './LensCatalogStateSort.vue'
13
+ import LensFormFilter from './LensFormFilter.vue'
14
+ import LensFormView from './LensFormView.vue'
15
+ import LensTable from './LensTable.vue'
16
+
17
+ export interface Props {
18
+ // API endpoint that returns lens result.
19
+ endpoint: string
20
+
21
+ // Search target of the entity.
22
+ entity?: string
23
+
24
+ // Fields to be selected initially.
25
+ select?: string[]
26
+
27
+ // Fields that can be selected by the users. If not provided,
28
+ // all fields in `select` prop will be considered selectable.
29
+ selectable?: string[]
30
+
31
+ // Keys to be searched when users input search query.
32
+ queryKeys?: string[]
33
+
34
+ // Placeholder text for the query search input.
35
+ queryPh?: string
36
+
37
+ // Initial filters to be applied on load. This filters are only applied
38
+ // on the initial load and users can modify them later. Thus, note making
39
+ // this prop reactive will not make sense.
40
+ filters?: any[]
41
+
42
+ // Fixed filters that are always applied and cannot be modified by users.
43
+ fixedFilters?: any[]
44
+
45
+ // Preset of filters shown to the users as "Quick filters".
46
+ filterPresets?: FilterPresets[]
47
+
48
+ // Initial sort to be applied on load.
49
+ sort?: LensQuerySort[]
50
+
51
+ // Override settings for fields.
52
+ overrides?: Record<string, Partial<FieldData>>
53
+
54
+ // Whether to show advanced filters.
55
+ canFilter?: boolean
56
+
57
+ // Whether to show advanced sorting options.
58
+ canSort?: boolean
59
+
60
+ // Whether to hide the condition blocks.
61
+ hideConditions?: boolean
62
+
63
+ // Field name to be used as index field for selection.
64
+ indexField?: string
65
+
66
+ // Fields that are clickable to emit `cell-clicked` event when clicked.
67
+ clickableFields?: string[]
68
+
69
+ // Whether to show border around the catalog.
70
+ border?: boolean
71
+
72
+ // Padding to add around various blocks in the catalog. This padding only
73
+ // applies to the X axis (left and right). The default is `16px`.
74
+ padding?: string
75
+
76
+ // Height of the catalog. When set, it will set max height of the table
77
+ // thereby enabling vertical scroll within the table.
78
+ //
79
+ // Available values:
80
+ // - `fill`: Fill the entire visible page.
81
+ // - Any valid CSS height value (e.g. `480px`, `calc(100vh - 48px)`, etc.)
82
+ height?: string
83
+
84
+ // Height offset to be subtracted when `height` is set to `fill`. This is
85
+ // useful when you have other fixed height elements on the page such as
86
+ // extra control headers, nav bars, etc. on top of the lens catalog.
87
+ heightOffset?: string
88
+
89
+ // Whether to show empty state when there is no data. When this is enabled,
90
+ // the catalog will hide entire catalog component and renders the given
91
+ // `#empty-state` slot instead.
92
+ showEmptyState?: boolean
93
+ }
94
+
95
+ const props = withDefaults(defineProps<Props>(), {
96
+ canFilter: true,
97
+ canSort: true
98
+ })
99
+
100
+ const selected = defineModel<number[]>('selected')
101
+ const hideConditions = defineModel<boolean>('hideConditions', { default: false })
102
+
103
+ const emit = defineEmits<{
104
+ 'select-updated': [select: string[]]
105
+ 'selectable-updated': [selectable: string[]]
106
+ 'filters-updated': [filters: any[]]
107
+ 'sort-updated': [sort: LensQuerySort[]]
108
+ 'overrides-updated': [overrides: Record<string, Partial<FieldData>>]
109
+ 'cell-clicked': [value: any, record: any]
110
+ }>()
111
+
112
+ const filterDialog = usePower()
113
+ const viewDialog = usePower()
114
+
115
+ const conditionBlocksEl = ref<HTMLElement | null>(null)
116
+
117
+ // Whether the catalog has loaded initial results at least once. This
118
+ // determines whether to show empty state or not. Because empty state
119
+ // should only be shown when there is no data from the beginning, not
120
+ // after some data has been loaded once and then resulted in no data
121
+ // due to user filtering.
122
+ const hasInitialResults = ref(false)
123
+
124
+ let prevFetchInput: LensQuery | null = null
125
+ let prevFetchResult: LensResult | null = null
126
+
127
+ const _select = ref(props.select ?? [])
128
+ const _selectable = ref(props.selectable ?? props.select ?? [])
129
+
130
+ const query = ref<string | null>(null)
131
+
132
+ const queryFilter = computed(() => {
133
+ if (!props.queryKeys || props.queryKeys.length === 0 || !query.value) {
134
+ return []
135
+ }
136
+ return ['$or', props.queryKeys.map((key) => {
137
+ return [key, 'like', `%${query.value}%`]
138
+ })]
139
+ })
140
+
141
+ const _filters = ref(props.filters ?? [])
142
+
143
+ const _sort = ref<LensQuerySort[]>([])
144
+ const defaultSort = ref(props.sort ?? [])
145
+
146
+ const _overrides = ref(props.overrides ?? {})
147
+
148
+ const page = ref(1)
149
+ const perPage = ref(100)
150
+
151
+ const { data: result, execute: refresh, loading } = useQuery(async (http) => {
152
+ const input = {
153
+ entity: props.entity ?? '__no_entity__',
154
+ select: _select.value,
155
+ filters: createInputFilters(queryFilter.value, _filters.value),
156
+ sort: _sort.value.length > 0 ? _sort.value : defaultSort.value ?? [],
157
+ page: page.value,
158
+ perPage: perPage.value
159
+ }
160
+
161
+ if (prevFetchInput && JSON.stringify(prevFetchInput) === JSON.stringify(input)) {
162
+ return prevFetchResult!
163
+ }
164
+
165
+ const res = await http.post<LensResult>(props.endpoint, input)
166
+
167
+ prevFetchInput = input
168
+ prevFetchResult = res
169
+
170
+ return res
171
+ })
172
+
173
+ const doRefresh = useDebounceFn(refresh, 50)
174
+
175
+ const _showEmptyState = computed(() => {
176
+ return props.showEmptyState && !hasInitialResults.value && result.value?.data.length === 0
177
+ })
178
+
179
+ const hasConditions = computed(() => {
180
+ return _filters.value.length > 0 || _sort.value.length > 0
181
+ })
182
+
183
+ const borderWidth = computed(() => {
184
+ return props.border ? 'var(--lens-catalog-border-width, 1px)' : '0'
185
+ })
186
+
187
+ const paddings = computed(() => ({
188
+ '--lens-catalog-control-padding': `0 ${props.padding ?? '16px'}`,
189
+ '--lens-catalog-filters-block-padding': `12px ${props.padding ?? '16px'}`,
190
+ '--lens-catalog-sorts-block-padding': `12px ${props.padding ?? '16px'}`,
191
+ '--lens-catalog-footer-padding': `0 ${props.padding ?? '16px'}`,
192
+ '--table-padding-left': `calc(${props.padding ?? '16px'} - 16px)`
193
+ }))
194
+
195
+ const conditionBlocksSize = useElementSize(conditionBlocksEl)
196
+
197
+ const headerHeight = 'var(--lens-catalog-global-height-offset)'
198
+ const controlHeight = '48px - 1px'
199
+ const conditionBlocksHeight = computed(() => conditionBlocksSize.height.value > 0 ? `${conditionBlocksSize.height.value}px - 1px` : '0px')
200
+ const columnsHeight = '40px - 1px'
201
+ const footerHeight = '56px - 1px'
202
+
203
+ const containerMinHeight = computed(() => {
204
+ if (!props.height) {
205
+ return undefined
206
+ }
207
+ if (props.height === 'fill') {
208
+ return { minHeight: `calc(100vh - ${headerHeight} - ${props.heightOffset ?? '0px'})` }
209
+ }
210
+ return { height: props.height, minHeight: props.height, maxHeight: props.height }
211
+ })
212
+
213
+ const containerStyles = computed(() => {
214
+ return {
215
+ borderWidth: borderWidth.value,
216
+ ...paddings.value,
217
+ ...(containerMinHeight.value ?? {})
218
+ }
219
+ })
220
+
221
+ const tableMaxHeight = computed(() => {
222
+ if (!props.height) {
223
+ return undefined
224
+ }
225
+ if (props.height === 'fill') {
226
+ return `--table-max-height: calc(100vh - ${headerHeight} - ${controlHeight} - ${conditionBlocksHeight.value} - ${columnsHeight} - ${footerHeight} - ${props.heightOffset ?? '0px'})`
227
+ }
228
+ return `--table-max-height: calc(${props.height} - ${controlHeight} - ${conditionBlocksHeight.value} - ${columnsHeight} - ${footerHeight})`
229
+ })
230
+
231
+ // Initial setup when the result is loaded for the first time.
232
+ watch(result, (res) => {
233
+ if (!hasInitialResults.value && res!.data.length > 0) {
234
+ hasInitialResults.value = true
235
+ }
236
+ if (_select.value.length === 0) {
237
+ _select.value = res!.query.select
238
+ }
239
+ if (_selectable.value.length === 0) {
240
+ _selectable.value = res!.query.select
241
+ }
242
+ }, { once: true })
243
+
244
+ // Create lens filters option by combining query (free search) filters,
245
+ // user selected filters, and fixed filters.
246
+ function createInputFilters(queryFilters: any[], filters: any[]) {
247
+ return [
248
+ ...(props.fixedFilters ?? []),
249
+ queryFilters,
250
+ ...filters
251
+ ].filter((f) => f?.length > 0)
252
+ }
253
+
254
+ function onQuery(value: string | null) {
255
+ query.value = value
256
+ page.value = 1
257
+ doRefresh()
258
+ }
259
+
260
+ function onFiltersUpdated(filters: any[]) {
261
+ _filters.value = filters
262
+ page.value = 1
263
+ doRefresh()
264
+ filterDialog.off()
265
+ emit('filters-updated', _filters.value)
266
+ }
267
+
268
+ function onInlineFilterUpdated(filter: any[]) {
269
+ const sameFilterIndex = _filters.value.findIndex((f) => f[0] === filter[0])
270
+ sameFilterIndex === -1 ? applyNewFilter(filter) : replaceFilter(sameFilterIndex, filter)
271
+ page.value = 1
272
+ doRefresh()
273
+ emit('filters-updated', _filters.value)
274
+ }
275
+
276
+ function applyNewFilter(filter: any[]) {
277
+ if (filter[2].length > 0) {
278
+ _filters.value.push(filter)
279
+ }
280
+ }
281
+
282
+ function replaceFilter(index: number, filter: any[]) {
283
+ if (filter[2].length === 0) {
284
+ _filters.value.splice(index, 1)
285
+ return
286
+ }
287
+ _filters.value[index] = filter
288
+ }
289
+
290
+ function onResetFilters() {
291
+ _filters.value = []
292
+ query.value = null
293
+ page.value = 1
294
+ doRefresh()
295
+ emit('filters-updated', _filters.value)
296
+ }
297
+
298
+ function onSortUpdated(sort: LensQuerySort) {
299
+ _sort.value = [sort]
300
+ page.value = 1
301
+ doRefresh()
302
+ emit('sort-updated', _sort.value)
303
+ }
304
+
305
+ function onResetSorts() {
306
+ _sort.value = []
307
+ page.value = 1
308
+ doRefresh()
309
+ emit('sort-updated', _sort.value)
310
+ }
311
+
312
+ function onViewUpdated(newSelect: string[], newSelectable: string[], overrides: Record<string, Partial<FieldData>>) {
313
+ _select.value = newSelect
314
+ _selectable.value = newSelectable
315
+ _overrides.value = overrides
316
+ doRefresh()
317
+ viewDialog.off()
318
+ emit('select-updated', _select.value)
319
+ emit('selectable-updated', _selectable.value)
320
+ emit('overrides-updated', _overrides.value)
321
+ }
322
+
323
+ function onUpdateSelected(value: number[]) {
324
+ selected.value = value
325
+ }
326
+
327
+ function onResetSelection() {
328
+ selected.value = []
329
+ }
330
+
331
+ function onPrev() {
332
+ page.value--
333
+ doRefresh()
334
+ }
335
+
336
+ function onNext() {
337
+ page.value++
338
+ doRefresh()
339
+ }
340
+
341
+ defineExpose({
342
+ /**
343
+ * Mutates the fetched records directly. This exposed method can be used to
344
+ * do in-place updates of records from the parent component. However, it's
345
+ * very hacky and should be avoided if possible. We are doing it like this
346
+ * because we couldn't come up with better alternative at the moment.
347
+ */
348
+ updateRecords(fn: (records: Record<string, any>[]) => void): void {
349
+ if (result.value?.data) {
350
+ fn(result.value.data)
351
+ }
352
+ }
353
+ })
354
+ </script>
355
+
356
+ <template>
357
+ <SCard class="LensCatalog" :class="{ 'show-empty-state': _showEmptyState }" :style="containerStyles">
358
+ <template v-if="_showEmptyState">
359
+ <slot name="empty-state" />
360
+ </template>
361
+
362
+ <div v-else class="container">
363
+ <LensCatalogControl
364
+ v-if="result"
365
+ :query
366
+ :query-ph
367
+ :filter-presets
368
+ :selected
369
+ :show-query="!!(queryKeys && queryKeys.length > 0)"
370
+ :show-filters="canFilter"
371
+ :show-sort="canSort"
372
+ :is-condition-active="!hideConditions"
373
+ :is-condition-disabled="!hasConditions"
374
+ @search="onQuery"
375
+ @filter="filterDialog.on()"
376
+ @filter-preset-selected="onFiltersUpdated"
377
+ @view="viewDialog.on()"
378
+ @reset-selection="onResetSelection"
379
+ @toggle-conditions="hideConditions = !hideConditions"
380
+ >
381
+ <template v-if="$slots['controls-sub-left']" #sub-left>
382
+ <slot name="controls-sub-left" />
383
+ </template>
384
+ <template v-if="$slots['controls-sub-right']" #sub-right>
385
+ <slot name="controls-sub-right" />
386
+ </template>
387
+ </LensCatalogControl>
388
+ <div v-else class="control-skeleton" />
389
+ <div v-if="!hideConditions && result && (_filters.length > 0 || _sort.length > 0)" ref="conditionBlocksEl" class="condition-blocks">
390
+ <LensCatalogStateFilter
391
+ v-if="_filters.length > 0"
392
+ :filters="_filters"
393
+ :fields="result.fields"
394
+ @reset="onResetFilters"
395
+ />
396
+ <LensCatalogStateSort
397
+ v-if="_sort.length > 0"
398
+ :fields="result.fields"
399
+ :sorts="_sort"
400
+ @reset="onResetSorts"
401
+ />
402
+ </div>
403
+ <SCardBlock class="list" :style="tableMaxHeight">
404
+ <LensTable
405
+ :result
406
+ :loading
407
+ :overrides="_overrides"
408
+ :index-field
409
+ :selected
410
+ :clickable-fields
411
+ @filter-updated="onInlineFilterUpdated"
412
+ @sort-updated="onSortUpdated"
413
+ @update:selected="onUpdateSelected"
414
+ @cell-clicked="(v, r) => emit('cell-clicked', v, r)"
415
+ />
416
+ <LensCatalogFooter
417
+ v-if="result && result?.pagination.total > 0"
418
+ :result
419
+ :loading
420
+ @prev="onPrev"
421
+ @next="onNext"
422
+ />
423
+ </SCardBlock>
424
+ </div>
425
+
426
+ <SModal :open="filterDialog.state.value" @close="filterDialog.off">
427
+ <LensFormFilter
428
+ v-if="result?.fields"
429
+ :fields="result.fields"
430
+ :filters="_filters"
431
+ :filterable="_selectable"
432
+ @cancel="filterDialog.off"
433
+ @apply="onFiltersUpdated"
434
+ />
435
+ </SModal>
436
+
437
+ <SModal :open="viewDialog.state.value" @close="viewDialog.off">
438
+ <LensFormView
439
+ v-if="result?.fields"
440
+ :select="_select"
441
+ :selectable="_selectable"
442
+ :fields="result.fields"
443
+ :overrides="_overrides"
444
+ @cancel="viewDialog.off"
445
+ @apply="onViewUpdated"
446
+ />
447
+ </SModal>
448
+ </SCard>
449
+ </template>
450
+
451
+ <style scoped lang="postcss">
452
+ .LensCatalog {
453
+ --c-bg-elv-2: var(--c-bg-1);
454
+ --c-bg-elv-3: var(--c-bg-1);
455
+ --c-bg-elv-4: var(--c-bg-2);
456
+
457
+ flex-grow: 1;
458
+ }
459
+
460
+ .LensCatalog.show-empty-state {
461
+ border-style: dashed;
462
+ background-color: var(--c-bg-1);
463
+ }
464
+
465
+ .container {
466
+ display: flex;
467
+ flex-direction: column;
468
+ gap: 1px;
469
+ min-height: 100%;
470
+ }
471
+
472
+ .list {
473
+ display: flex;
474
+ flex-direction: column;
475
+ flex-grow: 1;
476
+ border-radius: 0 0 5px 5px;
477
+ }
478
+
479
+ .control-skeleton {
480
+ height: 48px;
481
+ background-color: var(--c-bg-1);
482
+ }
483
+
484
+ .condition-blocks {
485
+ display: flex;
486
+ flex-direction: column;
487
+ gap: 1px;
488
+ background-color: var(--c-gutter);
489
+ }
490
+ </style>
@@ -0,0 +1,220 @@
1
+ <script setup lang="ts">
2
+ import IconBook from '~icons/ph/book'
3
+ import IconBookOpenText from '~icons/ph/book-open-text'
4
+ import IconFunnelSimple from '~icons/ph/funnel-simple'
5
+ import IconLightning from '~icons/ph/lightning'
6
+ import IconMagnifyingGlass from '~icons/ph/magnifying-glass'
7
+ import IconSlidersHorizontal from '~icons/ph/sliders-horizontal'
8
+ import IconX from '~icons/ph/x'
9
+ import { computed } from 'vue'
10
+ import { type ActionList } from '../../../components/SActionList.vue'
11
+ import SActionMenu from '../../../components/SActionMenu.vue'
12
+ import SButton from '../../../components/SButton.vue'
13
+ import SInputText from '../../../components/SInputText.vue'
14
+ import { type DropdownSectionMenu } from '../../../composables/Dropdown'
15
+ import { useTrans } from '../../../composables/Lang'
16
+
17
+ export interface Props {
18
+ queryPh?: string
19
+ filterPresets?: FilterPresets[]
20
+ selected?: number[]
21
+ showQuery?: boolean
22
+ showFilters?: boolean
23
+ isConditionActive?: boolean
24
+ isConditionDisabled?: boolean
25
+ }
26
+
27
+ export interface FilterPresets {
28
+ label: string
29
+ filters: any[]
30
+ }
31
+
32
+ const props = defineProps<Props>()
33
+
34
+ const query = defineModel<string | null>('query', { required: true })
35
+
36
+ const emit = defineEmits<{
37
+ 'search': [query: string | null]
38
+ 'filter': []
39
+ 'filter-preset-selected': [filters: any[]]
40
+ 'view': []
41
+ 'reset-selection': []
42
+ 'toggle-conditions': []
43
+ }>()
44
+
45
+ const { t } = useTrans({
46
+ en: {
47
+ i_query_ph: 'Search records',
48
+ a_filter_preset: 'Quick filters',
49
+ a_filter: 'Filters',
50
+ a_view: 'View',
51
+ a_conditions: 'View current conditions',
52
+ selected_text: (c: number) => `${c} selected`
53
+ },
54
+ ja: {
55
+ i_query_ph: 'レコードを検索',
56
+ a_filter_preset: 'クイックフィルター',
57
+ a_conditions: '現在の検索条件を表示',
58
+ a_filter: '詳細検索',
59
+ a_view: '表示',
60
+ selected_text: (c: number) => `${c}件選択中`
61
+ }
62
+ })
63
+
64
+ const presetOptions = computed<DropdownSectionMenu[]>(() => {
65
+ return [
66
+ {
67
+ type: 'menu',
68
+ options: createFilterPresetOptions()
69
+ }
70
+ ]
71
+ })
72
+
73
+ function createFilterPresetOptions(): ActionList {
74
+ return props.filterPresets?.map((preset) => {
75
+ return {
76
+ label: preset.label,
77
+ onClick: () => { emit('filter-preset-selected', preset.filters) }
78
+ }
79
+ }) || []
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <SCardBlock class="LensCatalogControl">
85
+ <template v-if="!selected || selected.length === 0">
86
+ <div class="main">
87
+ <SInputText
88
+ v-if="showQuery"
89
+ class="s-w-320"
90
+ size="sm"
91
+ :unit-before="IconMagnifyingGlass"
92
+ :placeholder="queryPh ?? t.i_query_ph"
93
+ :model-value="query"
94
+ @enter="$emit('search', $event)"
95
+ />
96
+ <SActionMenu
97
+ v-if="filterPresets?.length"
98
+ type="outline"
99
+ size="sm"
100
+ :icon="IconLightning"
101
+ :label="t.a_filter_preset"
102
+ :options="presetOptions"
103
+ />
104
+ <SButton
105
+ v-if="showFilters"
106
+ type="outline"
107
+ size="sm"
108
+ :icon="IconFunnelSimple"
109
+ :label="t.a_filter"
110
+ @click="$emit('filter')"
111
+ />
112
+ <SButton
113
+ type="outline"
114
+ size="sm"
115
+ :icon="IconSlidersHorizontal"
116
+ :label="t.a_view"
117
+ @click="$emit('view')"
118
+ />
119
+ </div>
120
+ <div class="sub">
121
+ <slot name="sub-left" />
122
+ <SButton
123
+ type="outline"
124
+ size="sm"
125
+ :icon="isConditionActive ? IconBookOpenText : IconBook"
126
+ :disabled="isConditionDisabled"
127
+ :tooltip="t.a_conditions"
128
+ @click="$emit('toggle-conditions')"
129
+ />
130
+ <slot name="sub-right" />
131
+ </div>
132
+ </template>
133
+ <template v-else>
134
+ <div class="selected">
135
+ <div class="selected-box">
136
+ <div class="selected-text">
137
+ {{ t.selected_text(selected.length) }}
138
+ </div>
139
+ <button class="selected-reset" @click="$emit('reset-selection')">
140
+ <IconX class="selected-reset-icon" />
141
+ </button>
142
+ </div>
143
+ </div>
144
+ </template>
145
+ </SCardBlock>
146
+ </template>
147
+
148
+ <style scoped lang="postcss">
149
+ .LensCatalogControl {
150
+ display: flex;
151
+ align-items: center;
152
+ border-radius: 5px 5px 0 0;
153
+ padding: var(--lens-catalog-control-padding, 0 12px);
154
+ height: 48px;
155
+ }
156
+
157
+ .main {
158
+ display: flex;
159
+ align-items: center;
160
+ flex-grow: 1;
161
+ gap: 8px;
162
+ }
163
+
164
+ .selected {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 8px;
168
+ }
169
+
170
+ .selected-box {
171
+ display: flex;
172
+ align-items: center;
173
+ border: 1px dashed var(--c-divider);
174
+ border-radius: 6px;
175
+ height: 32px;
176
+ overflow: hidden;
177
+ }
178
+
179
+ .selected-text {
180
+ border-right: 1px dashed var(--c-divider);
181
+ padding: 0 10px;
182
+ font-size: 12px;
183
+ font-feature-settings: "tnum";
184
+ }
185
+
186
+ .selected-reset {
187
+ display: flex;
188
+ justify-content: center;
189
+ align-items: center;
190
+ width: 32px;
191
+ height: 32px;
192
+ transition: background-color 0.25s;
193
+
194
+ &:hover {
195
+ background-color: var(--c-bg-2);
196
+ }
197
+
198
+ &:active {
199
+ background-color: var(--c-bg-3);
200
+ transition: background-color 0.1s;
201
+ }
202
+ }
203
+
204
+ .selected-reset-icon {
205
+ width: 14px;
206
+ height: 14px;
207
+ }
208
+
209
+ .selected-divider {
210
+ width: 1px;
211
+ height: 24px;
212
+ background-color: var(--c-divider);
213
+ }
214
+
215
+ .sub {
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 8px;
219
+ }
220
+ </style>