|
4 | 4 | import { getBasePath } from "../../stores/router.svelte.js"; |
5 | 5 | import { sync } from "../../stores/sync.svelte.js"; |
6 | 6 | import type { TrendsGranularity } from "../../api/types.js"; |
| 7 | + import { ChartColumnIcon, ChevronDownIcon } from "../../icons.js"; |
7 | 8 | import RangePicker from "../shared/RangePicker.svelte"; |
8 | 9 | import { |
9 | 10 | resolveRange, |
|
30 | 31 |
|
31 | 32 | let activeTerm: string | null = $state(null); |
32 | 33 |
|
| 34 | + const GRANULARITIES: TrendsGranularity[] = ["day", "week", "month"]; |
| 35 | + let groupByOpen = $state(false); |
| 36 | + let groupByEl: HTMLDivElement | undefined = $state(); |
| 37 | +
|
| 38 | + function pickGranularity(g: TrendsGranularity) { |
| 39 | + groupByOpen = false; |
| 40 | + if (g !== trends.granularity) void setGranularity(g); |
| 41 | + } |
| 42 | +
|
| 43 | + function onGroupByDocClick(e: MouseEvent) { |
| 44 | + if (groupByEl && !groupByEl.contains(e.target as Node)) { |
| 45 | + groupByOpen = false; |
| 46 | + } |
| 47 | + } |
| 48 | +
|
| 49 | + function onGroupByKey(e: KeyboardEvent) { |
| 50 | + if (e.key === "Escape") groupByOpen = false; |
| 51 | + } |
| 52 | +
|
33 | 53 | function colorFor(_term: string, index: number): string { |
34 | 54 | return TREND_PALETTE[index % TREND_PALETTE.length]!; |
35 | 55 | } |
|
110 | 130 | applyQueryParams(); |
111 | 131 | writeUrl(); |
112 | 132 | trends.fetchTerms(); |
| 133 | + document.addEventListener("click", onGroupByDocClick); |
| 134 | + document.addEventListener("keydown", onGroupByKey); |
| 135 | + return () => { |
| 136 | + document.removeEventListener("click", onGroupByDocClick); |
| 137 | + document.removeEventListener("keydown", onGroupByKey); |
| 138 | + }; |
113 | 139 | }); |
114 | 140 | </script> |
115 | 141 |
|
|
134 | 160 | {earliestSession} |
135 | 161 | onSelect={applyRange} |
136 | 162 | /> |
137 | | - <div class="granularity" aria-label="Granularity"> |
138 | | - {#each ["day", "week", "month"] as value} |
139 | | - <button |
140 | | - class:active={trends.granularity === value} |
141 | | - onclick={() => setGranularity(value as TrendsGranularity)} |
142 | | - > |
143 | | - {value} |
144 | | - </button> |
145 | | - {/each} |
146 | | - </div> |
147 | | - <label class="normalize-toggle"> |
148 | | - <input |
149 | | - type="checkbox" |
150 | | - bind:checked={trends.normalized} |
151 | | - onchange={setNormalized} |
152 | | - /> |
153 | | - <span>Normalize by number of messages</span> |
154 | | - </label> |
155 | 163 | </div> |
156 | 164 |
|
157 | 165 | <div class="content-grid"> |
|
172 | 180 | </div> |
173 | 181 |
|
174 | 182 | <div class="chart-panel" aria-busy={trends.loading.terms}> |
| 183 | + <div class="chart-options"> |
| 184 | + <div class="group-by" bind:this={groupByEl}> |
| 185 | + <button |
| 186 | + class="group-trigger" |
| 187 | + onclick={() => (groupByOpen = !groupByOpen)} |
| 188 | + aria-haspopup="menu" |
| 189 | + aria-expanded={groupByOpen} |
| 190 | + > |
| 191 | + <ChartColumnIcon size="13" strokeWidth="2" aria-hidden="true" /> |
| 192 | + Group by <span class="gval">{trends.granularity}</span> |
| 193 | + <ChevronDownIcon |
| 194 | + class={groupByOpen ? "g-chev open" : "g-chev"} |
| 195 | + size="11" |
| 196 | + strokeWidth="2.2" |
| 197 | + aria-hidden="true" |
| 198 | + /> |
| 199 | + </button> |
| 200 | + {#if groupByOpen} |
| 201 | + <div class="group-menu" role="menu"> |
| 202 | + {#each GRANULARITIES as g (g)} |
| 203 | + <button |
| 204 | + class="group-item" |
| 205 | + class:active={trends.granularity === g} |
| 206 | + role="menuitemradio" |
| 207 | + aria-checked={trends.granularity === g} |
| 208 | + onclick={() => pickGranularity(g)} |
| 209 | + > |
| 210 | + {g} |
| 211 | + </button> |
| 212 | + {/each} |
| 213 | + </div> |
| 214 | + {/if} |
| 215 | + </div> |
| 216 | + <label class="normalize-toggle"> |
| 217 | + <input |
| 218 | + type="checkbox" |
| 219 | + bind:checked={trends.normalized} |
| 220 | + onchange={setNormalized} |
| 221 | + /> |
| 222 | + <span>Normalize by number of messages</span> |
| 223 | + </label> |
| 224 | + </div> |
175 | 225 | <TrendsLineChart |
176 | 226 | buckets={trends.response?.buckets ?? []} |
177 | 227 | series={trends.response?.series ?? []} |
|
259 | 309 | } |
260 | 310 |
|
261 | 311 | .head-actions, |
262 | | - .toolbar, |
263 | | - .granularity { |
| 312 | + .toolbar { |
264 | 313 | display: flex; |
265 | 314 | align-items: center; |
266 | 315 | gap: 8px; |
|
336 | 385 | font-size: 12px; |
337 | 386 | } |
338 | 387 |
|
339 | | - .granularity { |
340 | | - align-self: end; |
341 | | - height: 32px; |
342 | | - padding: 2px; |
343 | | - border: 1px solid var(--border-default); |
344 | | - border-radius: 7px; |
345 | | - background: var(--bg-surface); |
| 388 | + .chart-options { |
| 389 | + display: flex; |
| 390 | + align-items: center; |
| 391 | + justify-content: flex-end; |
| 392 | + gap: 14px; |
| 393 | + padding: 2px 2px 10px; |
| 394 | + } |
| 395 | +
|
| 396 | + .group-by { |
| 397 | + position: relative; |
346 | 398 | } |
347 | 399 |
|
348 | | - .granularity button { |
| 400 | + .group-trigger { |
349 | 401 | height: 26px; |
350 | | - min-width: 54px; |
351 | | - padding: 0 10px; |
| 402 | + padding: 0 8px; |
| 403 | + display: inline-flex; |
| 404 | + align-items: center; |
| 405 | + gap: 6px; |
352 | 406 | border: 0; |
| 407 | + border-radius: 6px; |
353 | 408 | background: transparent; |
354 | 409 | color: var(--text-muted); |
| 410 | + font-size: 12px; |
| 411 | + } |
| 412 | +
|
| 413 | + .group-trigger:hover:not(:disabled) { |
| 414 | + background: var(--bg-surface-hover); |
| 415 | + color: var(--text-secondary); |
| 416 | + } |
| 417 | +
|
| 418 | + .group-trigger .gval { |
| 419 | + color: var(--text-secondary); |
| 420 | + font-weight: 500; |
355 | 421 | text-transform: capitalize; |
| 422 | + } |
| 423 | +
|
| 424 | + :global(.g-chev) { |
| 425 | + color: var(--text-muted); |
| 426 | + transition: transform 0.15s; |
| 427 | + } |
| 428 | +
|
| 429 | + :global(.g-chev.open) { |
| 430 | + transform: rotate(180deg); |
| 431 | + } |
| 432 | +
|
| 433 | + .group-menu { |
| 434 | + position: absolute; |
| 435 | + top: calc(100% + 4px); |
| 436 | + right: 0; |
| 437 | + z-index: 20; |
| 438 | + min-width: 124px; |
| 439 | + padding: 4px; |
| 440 | + background: var(--bg-surface); |
| 441 | + border: 1px solid var(--border-muted); |
| 442 | + border-radius: 7px; |
| 443 | + box-shadow: var(--shadow-md); |
| 444 | + } |
| 445 | +
|
| 446 | + .group-item { |
| 447 | + width: 100%; |
| 448 | + height: 28px; |
| 449 | + padding: 0 9px; |
| 450 | + display: flex; |
| 451 | + align-items: center; |
| 452 | + border: 0; |
| 453 | + border-radius: 5px; |
| 454 | + background: transparent; |
| 455 | + color: var(--text-secondary); |
356 | 456 | font-size: 12px; |
| 457 | + text-align: left; |
| 458 | + text-transform: capitalize; |
357 | 459 | } |
358 | 460 |
|
359 | | - .granularity button.active { |
360 | | - background: var(--bg-hover); |
361 | | - color: var(--text-primary); |
| 461 | + .group-item:hover:not(:disabled) { |
| 462 | + background: var(--bg-surface-hover); |
| 463 | + } |
| 464 | +
|
| 465 | + .group-item.active { |
| 466 | + color: var(--accent-blue); |
| 467 | + font-weight: 500; |
362 | 468 | } |
363 | 469 |
|
364 | 470 | .normalize-toggle { |
365 | | - align-self: end; |
366 | | - height: 32px; |
367 | 471 | display: flex; |
368 | 472 | align-items: center; |
369 | | - gap: 7px; |
370 | | - color: var(--text-primary); |
| 473 | + gap: 6px; |
| 474 | + color: var(--text-muted); |
371 | 475 | font-size: 12px; |
372 | 476 | font-weight: 500; |
| 477 | + cursor: pointer; |
373 | 478 | } |
374 | 479 |
|
375 | 480 | .normalize-toggle input { |
|
0 commit comments