|
1 | 1 | <script lang="ts"> |
2 | | - import { Icon, Layout, Tag, Tooltip } from '@appwrite.io/pink-svelte'; |
3 | | - import { queries, tagFormat, tags } from './store'; |
| 2 | + import { |
| 3 | + Icon, |
| 4 | + Layout, |
| 5 | + Tooltip, |
| 6 | + CompoundTagRoot, |
| 7 | + CompoundTagChild, |
| 8 | + Typography, |
| 9 | + ActionMenu, |
| 10 | + Selector |
| 11 | + } from '@appwrite.io/pink-svelte'; |
| 12 | + import { capitalize } from '$lib/helpers/string'; |
| 13 | + import { queries, tags } from './store'; |
4 | 14 | import { IconX } from '@appwrite.io/pink-icons-svelte'; |
5 | | - import { parsedTags } from './setFilters'; |
| 15 | + import { parsedTags, type ParsedTag } from './setFilters'; |
6 | 16 | import { Button } from '$lib/elements/forms'; |
| 17 | + import type { Column } from '$lib/helpers/types'; |
| 18 | + import { writable, type Writable } from 'svelte/store'; |
| 19 | + import Menu from '$lib/components/menu/menu.svelte'; |
| 20 | + import { addFilterAndApply, buildFilterCol, type FilterData } from './quickFilters'; |
| 21 | + import QuickFilters from '$lib/components/filters/quickFilters.svelte'; |
| 22 | + import { isSmallViewport } from '$lib/stores/viewport'; |
| 23 | +
|
| 24 | + let { |
| 25 | + columns = writable([]), |
| 26 | + analyticsSource = '' |
| 27 | + }: { columns?: Writable<Column[]>; analyticsSource?: string } = $props(); |
| 28 | +
|
| 29 | + function parseTagParts(tagString: string): { text: string; operator: boolean }[] { |
| 30 | + return tagString |
| 31 | + .split(/\*\*(.*?)\*\*/) |
| 32 | + .map((part, index) => { |
| 33 | + // Even indices are outside bold (operators), odd indices are inside bold (values) |
| 34 | + if (index % 2 === 0) { |
| 35 | + return part |
| 36 | + .split(/\s+/) |
| 37 | + .filter(Boolean) |
| 38 | + .map((t) => ({ text: t, operator: true })); |
| 39 | + } else { |
| 40 | + return [{ text: part, operator: false }]; |
| 41 | + } |
| 42 | + }) |
| 43 | + .flat() |
| 44 | + .filter((p) => Boolean(p.text)); |
| 45 | + } |
| 46 | +
|
| 47 | + function getFilterFor(title: string): FilterData | null { |
| 48 | + if (!columns) return null; |
| 49 | + const col = ($columns as unknown as Column[]).find((c) => c.title === title); |
| 50 | + if (!col) return null; |
| 51 | + const filter = buildFilterCol(col); |
| 52 | + return filter ?? null; |
| 53 | + } |
| 54 | +
|
| 55 | + // Build available filter definitions from provided columns |
| 56 | + let availableFilters = $derived( |
| 57 | + ($columns as unknown as Column[] | undefined)?.length |
| 58 | + ? (($columns as unknown as Column[]) |
| 59 | + .map((c) => (c.filter !== false ? buildFilterCol(c) : null)) |
| 60 | + .filter((f) => f && f.options) as FilterData[]) |
| 61 | + : [] |
| 62 | + ); |
| 63 | +
|
| 64 | + // QuickFilters uses the same filters list |
| 65 | + let filterCols = $derived(availableFilters); |
| 66 | +
|
| 67 | + // Always-show placeholders are derived from available filters (no hardcoding) |
| 68 | + // Use reactive array so runes can track changes |
| 69 | + let hiddenPlaceholders: string[] = $state([]); |
| 70 | +
|
| 71 | + let activeTitles = $derived( |
| 72 | + ($parsedTags || []).map((t) => (t as ParsedTag).title).filter(Boolean) as string[] |
| 73 | + ); |
| 74 | +
|
| 75 | + // Compute current placeholders (major filters not already active or dismissed) |
| 76 | + let placeholders = $derived( |
| 77 | + availableFilters |
| 78 | + .filter((f) => !activeTitles.includes(f.title)) |
| 79 | + .filter((f) => !hiddenPlaceholders.includes(f.title)) |
| 80 | + ); |
7 | 81 | </script> |
8 | 82 |
|
9 | | -{#if $parsedTags?.length} |
10 | | - <Layout.Stack direction="row" gap="s" wrap="wrap" alignItems="center" inline> |
| 83 | +<Layout.Stack direction="row" gap="s" wrap="wrap" alignItems="center" inline> |
| 84 | + {#if $parsedTags?.length} |
11 | 85 | {#each $parsedTags as tag (tag.tag)} |
12 | 86 | <span> |
13 | 87 | <Tooltip |
14 | 88 | disabled={Array.isArray(tag.value) ? tag.value?.length < 3 : true} |
15 | 89 | maxWidth="600px"> |
16 | | - <Tag |
17 | | - size="s" |
18 | | - on:click={() => { |
19 | | - const t = $tags.filter((t) => t.tag.includes(tag.tag.split(' ')[0])); |
20 | | - t.forEach((t) => (t ? queries.removeFilter(t) : null)); |
21 | | - queries.apply(); |
22 | | - parsedTags.update((tags) => tags.filter((t) => t.tag !== tag.tag)); |
23 | | - }}> |
24 | | - {#key tag.tag} |
25 | | - <span use:tagFormat>{tag.tag}</span> |
26 | | - {/key} |
27 | | - <Icon icon={IconX} size="s" slot="end" /> |
28 | | - </Tag> |
| 90 | + <CompoundTagRoot size="s"> |
| 91 | + {@const parts = parseTagParts(tag.tag)} |
| 92 | + {@const property = (tag as ParsedTag).title} |
| 93 | + |
| 94 | + {#each parts as part} |
| 95 | + <CompoundTagChild> |
| 96 | + <Menu> |
| 97 | + <span> |
| 98 | + {#if part.operator} |
| 99 | + <Typography.Text color="--fgcolor-neutral-secondary" |
| 100 | + >{part.text}</Typography.Text> |
| 101 | + {:else} |
| 102 | + <Typography.Text |
| 103 | + variant="m-500" |
| 104 | + color="--fgcolor-neutral-secondary" |
| 105 | + >{part.text |
| 106 | + .split(' or ') |
| 107 | + .map((t) => capitalize(t)) |
| 108 | + .join(' or ')}</Typography.Text> |
| 109 | + {/if} |
| 110 | + </span> |
| 111 | + <svelte:fragment slot="menu"> |
| 112 | + {#if property} |
| 113 | + {@const filter = getFilterFor(property)} |
| 114 | + {#if filter} |
| 115 | + {@const isArray = filter?.array} |
| 116 | + {@const selectedArray = Array.isArray(tag.value) |
| 117 | + ? tag.value |
| 118 | + : []} |
| 119 | + {#each filter.options as option (filter.title + option.value + option.label)} |
| 120 | + <ActionMenu.Root> |
| 121 | + <ActionMenu.Item.Button |
| 122 | + on:click={() => { |
| 123 | + if (isArray) { |
| 124 | + const exists = |
| 125 | + selectedArray.includes( |
| 126 | + option.value |
| 127 | + ); |
| 128 | + const next = exists |
| 129 | + ? selectedArray.filter( |
| 130 | + (v) => |
| 131 | + v !== option.value |
| 132 | + ) |
| 133 | + : [ |
| 134 | + ...selectedArray, |
| 135 | + option.value |
| 136 | + ]; |
| 137 | + addFilterAndApply( |
| 138 | + filter.id, |
| 139 | + filter.title, |
| 140 | + filter.operator, |
| 141 | + null, |
| 142 | + next, |
| 143 | + $columns, |
| 144 | + analyticsSource |
| 145 | + ); |
| 146 | + } else { |
| 147 | + addFilterAndApply( |
| 148 | + filter.id, |
| 149 | + filter.title, |
| 150 | + filter.operator, |
| 151 | + option.value, |
| 152 | + [], |
| 153 | + $columns, |
| 154 | + analyticsSource |
| 155 | + ); |
| 156 | + } |
| 157 | + }}> |
| 158 | + <Layout.Stack direction="row" gap="s"> |
| 159 | + {#if isArray} |
| 160 | + <Selector.Checkbox |
| 161 | + checked={selectedArray.includes( |
| 162 | + option.value |
| 163 | + )} |
| 164 | + size="s" /> |
| 165 | + {/if} |
| 166 | + {capitalize(option.label)} |
| 167 | + </Layout.Stack> |
| 168 | + </ActionMenu.Item.Button> |
| 169 | + </ActionMenu.Root> |
| 170 | + {/each} |
| 171 | + {/if} |
| 172 | + {/if} |
| 173 | + </svelte:fragment> |
| 174 | + </Menu> |
| 175 | + </CompoundTagChild> |
| 176 | + {/each} |
| 177 | + <CompoundTagChild |
| 178 | + dismiss |
| 179 | + on:click={() => { |
| 180 | + const t = $tags.filter((t) => |
| 181 | + t.tag.includes((tag as ParsedTag).title) |
| 182 | + ); |
| 183 | + t.forEach((t) => (t ? queries.removeFilter(t) : null)); |
| 184 | + queries.apply(); |
| 185 | + parsedTags.update((tags) => tags.filter((t) => t.tag !== tag.tag)); |
| 186 | + }}> |
| 187 | + <Icon icon={IconX} size="s" /> |
| 188 | + </CompoundTagChild> |
| 189 | + </CompoundTagRoot> |
29 | 190 | <span slot="tooltip">{tag?.value?.toString()}</span> |
30 | 191 | </Tooltip> |
31 | 192 | </span> |
32 | 193 | {/each} |
| 194 | + {/if} |
| 195 | + |
| 196 | + <!-- Always render remaining placeholder tags alongside active tags --> |
| 197 | + {#if placeholders?.length} |
| 198 | + {#each placeholders as filter (filter.title + filter.id)} |
| 199 | + <span> |
| 200 | + <Menu> |
| 201 | + <CompoundTagRoot size="s"> |
| 202 | + <CompoundTagChild> |
| 203 | + <span>{capitalize(filter.title)}</span> |
| 204 | + </CompoundTagChild> |
| 205 | + <CompoundTagChild |
| 206 | + dismiss |
| 207 | + on:click={(e) => { |
| 208 | + e.stopPropagation(); |
| 209 | + if (!hiddenPlaceholders.includes(filter.title)) { |
| 210 | + hiddenPlaceholders = [...hiddenPlaceholders, filter.title]; |
| 211 | + } |
| 212 | + }}> |
| 213 | + <Icon icon={IconX} size="s" /> |
| 214 | + </CompoundTagChild> |
| 215 | + </CompoundTagRoot> |
| 216 | + <svelte:fragment slot="menu"> |
| 217 | + {#if filter.options} |
| 218 | + {#each filter.options as option (filter.title + option.value + option.label)} |
| 219 | + <ActionMenu.Root> |
| 220 | + <ActionMenu.Item.Button |
| 221 | + on:click={() => { |
| 222 | + addFilterAndApply( |
| 223 | + filter.id, |
| 224 | + filter.title, |
| 225 | + filter.operator, |
| 226 | + filter?.array ? null : option.value, |
| 227 | + filter?.array ? [option.value] : [], |
| 228 | + $columns, |
| 229 | + analyticsSource |
| 230 | + ); |
| 231 | + }}> |
| 232 | + {capitalize(option.label)} |
| 233 | + </ActionMenu.Item.Button> |
| 234 | + </ActionMenu.Root> |
| 235 | + {/each} |
| 236 | + {/if} |
| 237 | + </svelte:fragment> |
| 238 | + </Menu> |
| 239 | + </span> |
| 240 | + {/each} |
| 241 | + {/if} |
| 242 | + |
| 243 | + {#if $parsedTags?.length} |
33 | 244 | <Button |
34 | 245 | size="s" |
35 | 246 | text |
|
38 | 249 | queries.apply(); |
39 | 250 | parsedTags.set([]); |
40 | 251 | }}>Clear all</Button> |
41 | | - </Layout.Stack> |
42 | | -{/if} |
| 252 | + {/if} |
| 253 | + |
| 254 | + {#if filterCols?.length && !$isSmallViewport} |
| 255 | + <QuickFilters {columns} {analyticsSource} {filterCols} /> |
| 256 | + {/if} |
| 257 | +</Layout.Stack> |
0 commit comments