@twoux/ui 0.0.0-stage → 0.1.1
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.
- package/LICENSE.md +61 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE.md +64 -0
- package/README.md +211 -2
- package/SHADCN-DIFF.md +228 -0
- package/agent/AGENTS.md +358 -0
- package/agent/docs-md/accessibility.md +49 -0
- package/agent/docs-md/action-catalogue.md +81 -0
- package/agent/docs-md/actions.md +74 -0
- package/agent/docs-md/agent-activity-log.md +70 -0
- package/agent/docs-md/agent-permissions.md +82 -0
- package/agent/docs-md/agent-run.md +90 -0
- package/agent/docs-md/agents-on-behalf.md +74 -0
- package/agent/docs-md/ai-column.md +85 -0
- package/agent/docs-md/ai-origin-label.md +70 -0
- package/agent/docs-md/announcing-features.md +73 -0
- package/agent/docs-md/answer-actions.md +81 -0
- package/agent/docs-md/app-shell.md +114 -0
- package/agent/docs-md/artifact-card.md +89 -0
- package/agent/docs-md/ask-on-screen.md +72 -0
- package/agent/docs-md/assistant-page.md +100 -0
- package/agent/docs-md/assistant-panel.md +142 -0
- package/agent/docs-md/avatar.md +82 -0
- package/agent/docs-md/banner.md +106 -0
- package/agent/docs-md/board.md +163 -0
- package/agent/docs-md/breadcrumb.md +89 -0
- package/agent/docs-md/breakdown-table.md +97 -0
- package/agent/docs-md/bulk-action-bar.md +112 -0
- package/agent/docs-md/button.md +293 -0
- package/agent/docs-md/card.md +115 -0
- package/agent/docs-md/change-list.md +99 -0
- package/agent/docs-md/change-request.md +166 -0
- package/agent/docs-md/chart-area.md +87 -0
- package/agent/docs-md/chart-bar.md +100 -0
- package/agent/docs-md/chart-box-plot.md +88 -0
- package/agent/docs-md/chart-bullet.md +89 -0
- package/agent/docs-md/chart-candlestick.md +112 -0
- package/agent/docs-md/chart-card-line.md +120 -0
- package/agent/docs-md/chart-donut.md +90 -0
- package/agent/docs-md/chart-drill-down.md +72 -0
- package/agent/docs-md/chart-funnel.md +71 -0
- package/agent/docs-md/chart-heatmap.md +76 -0
- package/agent/docs-md/chart-histogram.md +81 -0
- package/agent/docs-md/chart-map.md +165 -0
- package/agent/docs-md/chart-mixed.md +88 -0
- package/agent/docs-md/chart-price.md +92 -0
- package/agent/docs-md/chart-radial-bars.md +109 -0
- package/agent/docs-md/chart-radial-lines.md +111 -0
- package/agent/docs-md/chart-sankey.md +104 -0
- package/agent/docs-md/chart-scatter.md +87 -0
- package/agent/docs-md/chart-share-bar.md +89 -0
- package/agent/docs-md/chart-treemap.md +98 -0
- package/agent/docs-md/chart-waterfall.md +93 -0
- package/agent/docs-md/checkbox.md +131 -0
- package/agent/docs-md/choosing-selection-control.md +71 -0
- package/agent/docs-md/citations-sources.md +79 -0
- package/agent/docs-md/citing-sources.md +63 -0
- package/agent/docs-md/clarify-before-acting.md +69 -0
- package/agent/docs-md/clarifying-question.md +92 -0
- package/agent/docs-md/code-block.md +174 -0
- package/agent/docs-md/colours.md +87 -0
- package/agent/docs-md/columns-menu.md +140 -0
- package/agent/docs-md/combobox.md +161 -0
- package/agent/docs-md/command-palette.md +126 -0
- package/agent/docs-md/confidence-note.md +87 -0
- package/agent/docs-md/configurable-dashboard.md +76 -0
- package/agent/docs-md/confirmation-dialog.md +93 -0
- package/agent/docs-md/connected-tools.md +80 -0
- package/agent/docs-md/connector-picker.md +127 -0
- package/agent/docs-md/consistency.md +53 -0
- package/agent/docs-md/content-density.md +61 -0
- package/agent/docs-md/conversation-history.md +78 -0
- package/agent/docs-md/create-record.md +80 -0
- package/agent/docs-md/csv-import.md +88 -0
- package/agent/docs-md/dashboard-items.md +78 -0
- package/agent/docs-md/data-export.md +61 -0
- package/agent/docs-md/data-grid.md +137 -0
- package/agent/docs-md/data-vis-colours.md +76 -0
- package/agent/docs-md/data-visualisation.md +76 -0
- package/agent/docs-md/date-picker.md +178 -0
- package/agent/docs-md/delete-record.md +75 -0
- package/agent/docs-md/dense-table.md +495 -0
- package/agent/docs-md/design-tokens.md +53 -0
- package/agent/docs-md/disabled-read-only.md +75 -0
- package/agent/docs-md/drag-and-drop.md +74 -0
- package/agent/docs-md/dropdown-menu.md +137 -0
- package/agent/docs-md/edit-record.md +73 -0
- package/agent/docs-md/empty-loading-error.md +78 -0
- package/agent/docs-md/empty-state.md +128 -0
- package/agent/docs-md/error-messages.md +75 -0
- package/agent/docs-md/error-state.md +123 -0
- package/agent/docs-md/export-menu.md +86 -0
- package/agent/docs-md/feedback-prompt.md +88 -0
- package/agent/docs-md/field-mapping.md +78 -0
- package/agent/docs-md/file-drop.md +110 -0
- package/agent/docs-md/filter-bar.md +138 -0
- package/agent/docs-md/filter-panel.md +103 -0
- package/agent/docs-md/filtering-search.md +79 -0
- package/agent/docs-md/follow-ups-starters.md +79 -0
- package/agent/docs-md/form-validation.md +95 -0
- package/agent/docs-md/generated-columns.md +65 -0
- package/agent/docs-md/generated-drafts-files.md +71 -0
- package/agent/docs-md/grouped-list.md +89 -0
- package/agent/docs-md/guide-cue.md +170 -0
- package/agent/docs-md/help-panel.md +103 -0
- package/agent/docs-md/help-system.md +74 -0
- package/agent/docs-md/iconography.md +49 -0
- package/agent/docs-md/inbox.md +108 -0
- package/agent/docs-md/inline-editing-validation.md +80 -0
- package/agent/docs-md/inline-input-in-answer.md +70 -0
- package/agent/docs-md/items-palette.md +116 -0
- package/agent/docs-md/keyboard-model.md +47 -0
- package/agent/docs-md/keyboard-navigation.md +68 -0
- package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
- package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
- package/agent/docs-md/labelling-ai-output.md +66 -0
- package/agent/docs-md/layout.md +47 -0
- package/agent/docs-md/list-table.md +254 -0
- package/agent/docs-md/loading-skeleton.md +91 -0
- package/agent/docs-md/managing-agents.md +71 -0
- package/agent/docs-md/message-composer.md +105 -0
- package/agent/docs-md/migrating.md +49 -0
- package/agent/docs-md/motion.md +93 -0
- package/agent/docs-md/navigation.md +76 -0
- package/agent/docs-md/notification-channels.md +65 -0
- package/agent/docs-md/notifications-decision.md +100 -0
- package/agent/docs-md/onboarding-step.md +114 -0
- package/agent/docs-md/order-book.md +108 -0
- package/agent/docs-md/page-header.md +96 -0
- package/agent/docs-md/pagination.md +88 -0
- package/agent/docs-md/pane-layout.md +105 -0
- package/agent/docs-md/period-comparison.md +66 -0
- package/agent/docs-md/plan-card.md +120 -0
- package/agent/docs-md/popover-rules.md +88 -0
- package/agent/docs-md/prompt-editor.md +90 -0
- package/agent/docs-md/propose-apply.md +65 -0
- package/agent/docs-md/provider-logo.md +97 -0
- package/agent/docs-md/radio.md +83 -0
- package/agent/docs-md/ranked-list.md +102 -0
- package/agent/docs-md/reasoning-progress.md +72 -0
- package/agent/docs-md/record-detail.md +71 -0
- package/agent/docs-md/regenerating-answers.md +70 -0
- package/agent/docs-md/run-status-bar.md +94 -0
- package/agent/docs-md/saved-tasks-schedules.md +63 -0
- package/agent/docs-md/saved-views.md +149 -0
- package/agent/docs-md/saving-views.md +68 -0
- package/agent/docs-md/scheduler.md +258 -0
- package/agent/docs-md/secondary-panels.md +72 -0
- package/agent/docs-md/segmented-control.md +88 -0
- package/agent/docs-md/selection-bulk-actions.md +70 -0
- package/agent/docs-md/separator.md +75 -0
- package/agent/docs-md/setup-checklist.md +92 -0
- package/agent/docs-md/shape-elevation.md +55 -0
- package/agent/docs-md/sheet-grid.md +211 -0
- package/agent/docs-md/side-panel.md +134 -0
- package/agent/docs-md/sign-in.md +114 -0
- package/agent/docs-md/spacing.md +55 -0
- package/agent/docs-md/split-button.md +100 -0
- package/agent/docs-md/stat-compare.md +82 -0
- package/agent/docs-md/static-dashboard.md +75 -0
- package/agent/docs-md/status-badge.md +140 -0
- package/agent/docs-md/step-card.md +75 -0
- package/agent/docs-md/stepper.md +92 -0
- package/agent/docs-md/suggested-changes.md +91 -0
- package/agent/docs-md/switch.md +78 -0
- package/agent/docs-md/table-settings.md +81 -0
- package/agent/docs-md/tabs.md +85 -0
- package/agent/docs-md/tag-input.md +108 -0
- package/agent/docs-md/tag.md +79 -0
- package/agent/docs-md/task-builder.md +87 -0
- package/agent/docs-md/team-instructions.md +72 -0
- package/agent/docs-md/templates-gallery.md +85 -0
- package/agent/docs-md/text-input.md +160 -0
- package/agent/docs-md/thinking-steps.md +89 -0
- package/agent/docs-md/timeline.md +189 -0
- package/agent/docs-md/timestamp.md +84 -0
- package/agent/docs-md/timestamps.md +70 -0
- package/agent/docs-md/toast.md +117 -0
- package/agent/docs-md/tooltip.md +90 -0
- package/agent/docs-md/typography.md +62 -0
- package/agent/docs-md/undo-destructive.md +66 -0
- package/agent/docs-md/unsaved-changes.md +71 -0
- package/agent/docs-md/usage-meter.md +80 -0
- package/agent/docs-md/user-feedback.md +71 -0
- package/agent/docs-md/voice-and-tone.md +60 -0
- package/agent/docs-md/why-choice.md +93 -0
- package/agent/docs-md/workspace-onboarding.md +74 -0
- package/agent/docs-md/writing.md +48 -0
- package/agent/llms-full.txt +18369 -0
- package/agent/llms.txt +198 -0
- package/agent/skills/twoux-audit/SKILL.md +49 -0
- package/agent/skills/twoux-build/SKILL.md +39 -0
- package/agent/skills/twoux-design/SKILL.md +54 -0
- package/agent/skills/twoux-write/SKILL.md +32 -0
- package/bin/check.mjs +123 -0
- package/bin/docs.mjs +24 -0
- package/bin/guard.mjs +27 -0
- package/bin/init.mjs +113 -0
- package/bin/knowledge.mjs +87 -0
- package/bin/mcp.mjs +116 -0
- package/bin/theme.mjs +168 -0
- package/bin/twoux.mjs +25 -0
- package/package.json +80 -3
- package/registry/jobs.json +1028 -0
- package/registry/palette.json +770 -0
- package/registry/rules.json +913 -0
- package/src/components/agent-review.tsx +364 -0
- package/src/components/ai-basics.tsx +212 -0
- package/src/components/ai-work.tsx +304 -0
- package/src/components/ai.tsx +227 -0
- package/src/components/alert-dialog.tsx +203 -0
- package/src/components/alert.tsx +86 -0
- package/src/components/avatar.tsx +114 -0
- package/src/components/badge.tsx +59 -0
- package/src/components/board.tsx +397 -0
- package/src/components/breadcrumb.tsx +114 -0
- package/src/components/button-group.tsx +90 -0
- package/src/components/button.tsx +109 -0
- package/src/components/calendar.tsx +247 -0
- package/src/components/card.tsx +102 -0
- package/src/components/chart-map.tsx +336 -0
- package/src/components/charts-more.tsx +1331 -0
- package/src/components/charts-radial.tsx +328 -0
- package/src/components/charts.tsx +390 -0
- package/src/components/checkbox.tsx +40 -0
- package/src/components/code.tsx +139 -0
- package/src/components/collapsible.tsx +37 -0
- package/src/components/combobox.tsx +339 -0
- package/src/components/command-extras.tsx +11 -0
- package/src/components/command.tsx +196 -0
- package/src/components/data-grid.tsx +213 -0
- package/src/components/data-table.tsx +334 -0
- package/src/components/data-views.tsx +740 -0
- package/src/components/date-picker.tsx +686 -0
- package/src/components/dense-table.tsx +207 -0
- package/src/components/dialog.tsx +162 -0
- package/src/components/dropdown-menu.tsx +264 -0
- package/src/components/empty.tsx +114 -0
- package/src/components/feedback-prompt.tsx +80 -0
- package/src/components/field.tsx +254 -0
- package/src/components/fields.tsx +112 -0
- package/src/components/flow.tsx +840 -0
- package/src/components/guide-cue.tsx +273 -0
- package/src/components/inbox.tsx +113 -0
- package/src/components/input-group.tsx +176 -0
- package/src/components/input.tsx +26 -0
- package/src/components/kbd.tsx +34 -0
- package/src/components/label.tsx +27 -0
- package/src/components/labels.tsx +176 -0
- package/src/components/motion.tsx +136 -0
- package/src/components/onboarding.tsx +454 -0
- package/src/components/order-book.tsx +185 -0
- package/src/components/pane-layout.tsx +417 -0
- package/src/components/popover.tsx +96 -0
- package/src/components/provider-logos.data.ts +49 -0
- package/src/components/radio-group.tsx +52 -0
- package/src/components/scheduler.tsx +997 -0
- package/src/components/segmented-control.tsx +28 -0
- package/src/components/select.tsx +195 -0
- package/src/components/separator.tsx +31 -0
- package/src/components/sheet.tsx +148 -0
- package/src/components/shell.tsx +426 -0
- package/src/components/shortcut-sheet.tsx +83 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sonner.tsx +107 -0
- package/src/components/spinner.tsx +13 -0
- package/src/components/split-button.tsx +58 -0
- package/src/components/stepper.tsx +78 -0
- package/src/components/switch.tsx +43 -0
- package/src/components/table.tsx +126 -0
- package/src/components/tabs.tsx +118 -0
- package/src/components/tag-input.tsx +253 -0
- package/src/components/textarea.tsx +21 -0
- package/src/components/theme.tsx +80 -0
- package/src/components/timeline.tsx +408 -0
- package/src/components/toggle-group.tsx +87 -0
- package/src/components/toggle.tsx +54 -0
- package/src/components/tooltip.tsx +65 -0
- package/src/components/workspace-actions.tsx +294 -0
- package/src/components/workspace-import.tsx +465 -0
- package/src/components/workspace.tsx +1884 -0
- package/src/index.ts +80 -0
- package/src/lib/active-indicator.tsx +53 -0
- package/src/lib/chart-kit.tsx +72 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/shortcuts.tsx +61 -0
- package/src/lib/workspace.tsx +10 -0
- package/src/shadcn/alert-dialog.tsx +196 -0
- package/src/shadcn/alert.tsx +65 -0
- package/src/shadcn/avatar.tsx +108 -0
- package/src/shadcn/badge.tsx +47 -0
- package/src/shadcn/breadcrumb.tsx +108 -0
- package/src/shadcn/button-group.tsx +83 -0
- package/src/shadcn/button.tsx +63 -0
- package/src/shadcn/calendar.tsx +220 -0
- package/src/shadcn/card.tsx +91 -0
- package/src/shadcn/checkbox.tsx +31 -0
- package/src/shadcn/collapsible.tsx +33 -0
- package/src/shadcn/command.tsx +184 -0
- package/src/shadcn/dialog.tsx +158 -0
- package/src/shadcn/dropdown-menu.tsx +256 -0
- package/src/shadcn/empty.tsx +103 -0
- package/src/shadcn/field.tsx +248 -0
- package/src/shadcn/input-group.tsx +170 -0
- package/src/shadcn/input.tsx +20 -0
- package/src/shadcn/kbd.tsx +28 -0
- package/src/shadcn/label.tsx +23 -0
- package/src/shadcn/popover.tsx +88 -0
- package/src/shadcn/radio-group.tsx +44 -0
- package/src/shadcn/select.tsx +189 -0
- package/src/shadcn/separator.tsx +27 -0
- package/src/shadcn/sheet.tsx +142 -0
- package/src/shadcn/skeleton.tsx +13 -0
- package/src/shadcn/sonner.tsx +40 -0
- package/src/shadcn/switch.tsx +34 -0
- package/src/shadcn/table.tsx +115 -0
- package/src/shadcn/tabs.tsx +90 -0
- package/src/shadcn/textarea.tsx +17 -0
- package/src/shadcn/toggle-group.tsx +83 -0
- package/src/shadcn/toggle.tsx +46 -0
- package/src/shadcn/tooltip.tsx +56 -0
- package/src/styles/palette.css +127 -0
- package/src/styles/theme.css +35 -0
- package/src/styles/twoux.css +236 -0
- package/theme.json +14 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX compositions for working with collections, built only from generated shadcn parts.
|
|
3
|
+
* FilterButton + FilterRow: the filter UX to use (button in the toolbar, applied filters on their own row). Built from
|
|
4
|
+
* FilterBar, FilterPill, FilterMenu, FilterSummary: filters as segmented chips that read as sentences; values apply on tick.
|
|
5
|
+
* ExportMenu: options, then downloads, then copies, in one menu.
|
|
6
|
+
* GroupedList, ListGroup: records under collapsible, counted group headers.
|
|
7
|
+
* SavedViews: named filter + sort + column sets as tabs, with unsaved changes, save and reset.
|
|
8
|
+
*/
|
|
9
|
+
import { Check, ChevronRight, Download, EllipsisVertical, ListFilter, Pencil, Plus, Save, Star, Trash2, X } from "lucide-react"
|
|
10
|
+
import * as React from "react"
|
|
11
|
+
import { cn } from "../lib/cn"
|
|
12
|
+
import { useShortcut } from "../lib/shortcuts"
|
|
13
|
+
import { Badge } from "./badge"
|
|
14
|
+
import { Button } from "./button"
|
|
15
|
+
import { Checkbox } from "./checkbox"
|
|
16
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible"
|
|
17
|
+
import { DateFilterPill, DateRangeChooser, type DatePreset, dateFilterFromValues, dateFilterToValues, describeDateFilter } from "./date-picker"
|
|
18
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./command"
|
|
19
|
+
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./dialog"
|
|
20
|
+
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./dropdown-menu"
|
|
21
|
+
import { Input } from "./input"
|
|
22
|
+
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "./popover"
|
|
23
|
+
import { Tabs, TabsList, TabsTrigger } from "./tabs"
|
|
24
|
+
import { Kbd } from "./kbd"
|
|
25
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
|
|
26
|
+
|
|
27
|
+
/* ---------------------------------------------------------------- filters */
|
|
28
|
+
|
|
29
|
+
export type FilterOption = { value: string; label: string; count?: number }
|
|
30
|
+
|
|
31
|
+
export function FilterBar({ className, children }: { className?: string; children: React.ReactNode }) {
|
|
32
|
+
return (
|
|
33
|
+
<div role="group" aria-label="Filters" data-slot="filter-bar" className={cn("flex flex-wrap items-center gap-control", className)}>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A filter. Dashed while empty (a suggestion), solid once set. Reads "Field operator values". */
|
|
40
|
+
export function FilterPill({
|
|
41
|
+
field,
|
|
42
|
+
operator = "is any of",
|
|
43
|
+
options,
|
|
44
|
+
selected,
|
|
45
|
+
onChange,
|
|
46
|
+
onRemove,
|
|
47
|
+
defaultOpen = false,
|
|
48
|
+
searchable,
|
|
49
|
+
valueNoun,
|
|
50
|
+
}: {
|
|
51
|
+
field: string
|
|
52
|
+
/** How three or more values are counted on the chip, e.g. { one: "seniority", many: "seniorities" }. Falls back to "3 selected". */
|
|
53
|
+
valueNoun?: { one: string; many: string }
|
|
54
|
+
operator?: string
|
|
55
|
+
options: FilterOption[]
|
|
56
|
+
selected: string[]
|
|
57
|
+
/** Called on every tick: list values apply at once. */
|
|
58
|
+
onChange: (values: string[]) => void
|
|
59
|
+
/** Removes the pill. Without it, a set pill clears its values instead. */
|
|
60
|
+
onRemove?: () => void
|
|
61
|
+
defaultOpen?: boolean
|
|
62
|
+
/** Type-ahead inside the list; on by default past 7 options. */
|
|
63
|
+
searchable?: boolean
|
|
64
|
+
}) {
|
|
65
|
+
const [open, setOpen] = React.useState(defaultOpen)
|
|
66
|
+
const set = selected.length > 0
|
|
67
|
+
const labels = options.filter((o) => selected.includes(o.value)).map((o) => o.label)
|
|
68
|
+
// One or two values read in full; three or more are counted, the full list stays in the popover and the name.
|
|
69
|
+
const summary = labels.length <= 2 ? labels.join(", ") : `${labels.length} ${valueNoun?.many ?? "selected"}`
|
|
70
|
+
const spoken = labels.length > 1 ? `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}` : labels[0]
|
|
71
|
+
const toggle = (v: string) => onChange(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v])
|
|
72
|
+
const remove = onRemove ?? (() => onChange([]))
|
|
73
|
+
const segment = "flex items-center px-2 whitespace-nowrap"
|
|
74
|
+
const focus = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
78
|
+
{/* A segmented chip: field | operator | value | remove, one unit with hairlines between the parts. */}
|
|
79
|
+
<div
|
|
80
|
+
data-slot="filter-pill"
|
|
81
|
+
data-state={set ? "set" : "empty"}
|
|
82
|
+
className={cn(
|
|
83
|
+
"inline-flex h-7 items-stretch overflow-hidden rounded-sm border text-base transition-colors duration-(--duration-fast) ease-(--ease-out)",
|
|
84
|
+
set ? "divide-x divide-line border-line-strong bg-surface text-ink shadow-xs" : "border-dashed border-line-strong text-ink-2 hover:border-input-hover hover:text-ink"
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{set ? (
|
|
88
|
+
<>
|
|
89
|
+
<span className={cn(segment, "text-ink-2")}>{field}</span>
|
|
90
|
+
<span className={cn(segment, "text-ink-3")}>{operator}</span>
|
|
91
|
+
<PopoverTrigger asChild>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
aria-label={`${field} ${operator} ${spoken}. Change values`}
|
|
95
|
+
onKeyDown={(e) => { if (e.key === "Backspace" || e.key === "Delete") { e.preventDefault(); remove() } }}
|
|
96
|
+
className={cn(segment, "max-w-56 font-medium transition-colors hover:bg-hover", focus)}
|
|
97
|
+
>
|
|
98
|
+
<span className="truncate">{summary}</span>
|
|
99
|
+
</button>
|
|
100
|
+
</PopoverTrigger>
|
|
101
|
+
<button type="button" onClick={remove} aria-label={`Remove ${field} filter`} className={cn("grid w-7 place-items-center text-ink-3 transition-colors hover:bg-hover hover:text-ink", focus)}>
|
|
102
|
+
<X className="size-3.5" aria-hidden="true" />
|
|
103
|
+
</button>
|
|
104
|
+
</>
|
|
105
|
+
) : (
|
|
106
|
+
<PopoverTrigger asChild>
|
|
107
|
+
<button type="button" className={cn("inline-flex items-center gap-1 px-2 whitespace-nowrap", focus)}>
|
|
108
|
+
<Plus className="size-3.5 text-ink-3" aria-hidden="true" />
|
|
109
|
+
{field}
|
|
110
|
+
</button>
|
|
111
|
+
</PopoverTrigger>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
<PopoverContent align="start" className="w-64 p-0">
|
|
115
|
+
<Command>
|
|
116
|
+
{(searchable ?? options.length > 7) && <CommandInput placeholder={`Search ${field.toLowerCase()}`} />}
|
|
117
|
+
<CommandList>
|
|
118
|
+
<CommandEmpty>No matches</CommandEmpty>
|
|
119
|
+
<CommandGroup heading={`${field} ${operator}`}>
|
|
120
|
+
{options.map((o) => {
|
|
121
|
+
const on = selected.includes(o.value)
|
|
122
|
+
return (
|
|
123
|
+
<CommandItem key={o.value} value={o.label} onSelect={() => toggle(o.value)} aria-checked={on}>
|
|
124
|
+
<Checkbox checked={on} tabIndex={-1} aria-hidden="true" className="pointer-events-none" />
|
|
125
|
+
<span className="truncate">{o.label}</span>
|
|
126
|
+
{o.count !== undefined && <span className="tabular ml-auto text-sm text-ink-3">{o.count}</span>}
|
|
127
|
+
</CommandItem>
|
|
128
|
+
)
|
|
129
|
+
})}
|
|
130
|
+
</CommandGroup>
|
|
131
|
+
</CommandList>
|
|
132
|
+
</Command>
|
|
133
|
+
</PopoverContent>
|
|
134
|
+
</Popover>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type FilterField = {
|
|
139
|
+
id: string
|
|
140
|
+
label: string
|
|
141
|
+
group?: "Most used" | "All fields"
|
|
142
|
+
/** With options, picking the field opens its values beside the list; ticks apply at once through onChange. */
|
|
143
|
+
options?: FilterOption[]
|
|
144
|
+
selected?: string[]
|
|
145
|
+
/** date: picking the field opens typed dates, presets and a calendar beside the list; selected holds dateFilterToValues. */
|
|
146
|
+
kind?: "date"
|
|
147
|
+
presets?: DatePreset[]
|
|
148
|
+
maxDays?: number
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The Filter (F) button: fields first, then, beside them, the chosen field's values with counts.
|
|
153
|
+
* A filter shows as a chip only once a value is applied, so nothing appears in the filter row half-made.
|
|
154
|
+
*/
|
|
155
|
+
export function FilterMenu({
|
|
156
|
+
fields,
|
|
157
|
+
onPick,
|
|
158
|
+
onChange,
|
|
159
|
+
label = "Filter",
|
|
160
|
+
variant = "button",
|
|
161
|
+
}: {
|
|
162
|
+
fields: FilterField[]
|
|
163
|
+
/** Fields without options: called when one is picked (the app adds an empty pill). */
|
|
164
|
+
onPick?: (id: string) => void
|
|
165
|
+
/** Fields with options: called on every tick in the values panel. */
|
|
166
|
+
onChange?: (id: string, values: string[]) => void
|
|
167
|
+
label?: string
|
|
168
|
+
/**
|
|
169
|
+
* button: labelled, ghost (the original).
|
|
170
|
+
* icon: a bordered icon button for a toolbar, named by label, F in its tooltip; filters then show on their own row.
|
|
171
|
+
* add: a + after applied filters, to add another one. No shortcut, so it never competes with the toolbar button.
|
|
172
|
+
*/
|
|
173
|
+
variant?: "button" | "icon" | "add"
|
|
174
|
+
}) {
|
|
175
|
+
const [open, setOpen] = React.useState(false)
|
|
176
|
+
const [sub, setSub] = React.useState<string | null>(null)
|
|
177
|
+
const groups = ["Most used", "All fields"] as const
|
|
178
|
+
const field = fields.find((f) => f.id === sub)
|
|
179
|
+
const toggle = (f: FilterField, v: string) => {
|
|
180
|
+
const cur = f.selected ?? []
|
|
181
|
+
onChange?.(f.id, cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v])
|
|
182
|
+
}
|
|
183
|
+
return (
|
|
184
|
+
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setSub(null) }}>
|
|
185
|
+
{variant !== "button" ? (
|
|
186
|
+
<Tooltip>
|
|
187
|
+
<TooltipTrigger asChild>
|
|
188
|
+
<PopoverTrigger asChild>
|
|
189
|
+
{variant === "icon" ? (
|
|
190
|
+
<Button variant="outline" size="icon-sm" shortcut="F" aria-label={label}><ListFilter /></Button>
|
|
191
|
+
) : (
|
|
192
|
+
<Button variant="ghost" size="icon-sm" aria-label="Add filter"><Plus /></Button>
|
|
193
|
+
)}
|
|
194
|
+
</PopoverTrigger>
|
|
195
|
+
</TooltipTrigger>
|
|
196
|
+
<TooltipContent>{variant === "icon" ? <>{label} <Kbd>F</Kbd></> : "Add filter"}</TooltipContent>
|
|
197
|
+
</Tooltip>
|
|
198
|
+
) : (
|
|
199
|
+
<PopoverTrigger asChild>
|
|
200
|
+
<Button variant="ghost" size="sm" shortcut="F">
|
|
201
|
+
<ListFilter />
|
|
202
|
+
{label}
|
|
203
|
+
</Button>
|
|
204
|
+
</PopoverTrigger>
|
|
205
|
+
)}
|
|
206
|
+
<PopoverContent align={variant === "icon" ? "end" : "start"} className="w-60 p-0">
|
|
207
|
+
{/* The values panel anchors to this list and opens beside it. */}
|
|
208
|
+
<Popover open={!!field} onOpenChange={(o) => { if (!o) setSub(null) }}>
|
|
209
|
+
<PopoverAnchor asChild>
|
|
210
|
+
<div>
|
|
211
|
+
<Command>
|
|
212
|
+
<CommandInput placeholder="Filter by…" />
|
|
213
|
+
<CommandList>
|
|
214
|
+
<CommandEmpty>No fields match</CommandEmpty>
|
|
215
|
+
{groups.map((g) => {
|
|
216
|
+
const items = fields.filter((f) => (f.group ?? "All fields") === g)
|
|
217
|
+
if (!items.length) return null
|
|
218
|
+
return (
|
|
219
|
+
<CommandGroup key={g} heading={g}>
|
|
220
|
+
{items.map((f) => {
|
|
221
|
+
const panel = !!f.options || f.kind === "date"
|
|
222
|
+
const date = f.kind === "date" ? describeDateFilter(dateFilterFromValues(f.selected, f.presets), f.presets) : null
|
|
223
|
+
return (
|
|
224
|
+
<CommandItem
|
|
225
|
+
key={f.id}
|
|
226
|
+
value={f.label}
|
|
227
|
+
aria-haspopup={f.kind === "date" ? "dialog" : f.options ? "listbox" : undefined}
|
|
228
|
+
aria-expanded={panel ? sub === f.id : undefined}
|
|
229
|
+
onKeyDown={(e) => { if (e.key === "ArrowRight" && panel) { e.preventDefault(); setSub(f.id) } }}
|
|
230
|
+
onSelect={() => { if (panel) setSub(f.id); else { setOpen(false); onPick?.(f.id) } }}
|
|
231
|
+
className={cn(sub === f.id && "bg-accent")}
|
|
232
|
+
>
|
|
233
|
+
<span className="flex-1 truncate">{f.label}</span>
|
|
234
|
+
{date ? <span className="max-w-24 truncate text-sm text-ink-3">{date.text}</span> : !!f.selected?.length && <span className="tabular text-sm text-ink-3">{f.selected.length}</span>}
|
|
235
|
+
{panel && <ChevronRight className="text-ink-3" />}
|
|
236
|
+
</CommandItem>
|
|
237
|
+
)
|
|
238
|
+
})}
|
|
239
|
+
</CommandGroup>
|
|
240
|
+
)
|
|
241
|
+
})}
|
|
242
|
+
</CommandList>
|
|
243
|
+
</Command>
|
|
244
|
+
</div>
|
|
245
|
+
</PopoverAnchor>
|
|
246
|
+
{field?.kind === "date" && (
|
|
247
|
+
<PopoverContent
|
|
248
|
+
side={variant === "icon" ? "left" : "right"}
|
|
249
|
+
align="start"
|
|
250
|
+
sideOffset={6}
|
|
251
|
+
role="dialog"
|
|
252
|
+
aria-label={`${field.label} dates`}
|
|
253
|
+
className="w-auto max-w-(--radix-popover-content-available-width) p-0"
|
|
254
|
+
onKeyDown={(e) => { if (e.key === "ArrowLeft" && !(e.target instanceof HTMLInputElement)) { e.preventDefault(); setSub(null) } }}
|
|
255
|
+
>
|
|
256
|
+
<DateRangeChooser
|
|
257
|
+
months={1}
|
|
258
|
+
value={dateFilterFromValues(field.selected, field.presets)}
|
|
259
|
+
presets={field.presets}
|
|
260
|
+
maxDays={field.maxDays}
|
|
261
|
+
onApply={(v) => {
|
|
262
|
+
onChange?.(field.id, dateFilterToValues(v))
|
|
263
|
+
setOpen(false)
|
|
264
|
+
setSub(null)
|
|
265
|
+
}}
|
|
266
|
+
/>
|
|
267
|
+
</PopoverContent>
|
|
268
|
+
)}
|
|
269
|
+
{field?.options && (
|
|
270
|
+
<PopoverContent
|
|
271
|
+
side={variant === "icon" ? "left" : "right"}
|
|
272
|
+
align="start"
|
|
273
|
+
sideOffset={6}
|
|
274
|
+
className="w-64 p-0"
|
|
275
|
+
onKeyDown={(e) => { if (e.key === "ArrowLeft") { e.preventDefault(); setSub(null) } }}
|
|
276
|
+
>
|
|
277
|
+
<Command>
|
|
278
|
+
{field.options.length > 7 && <CommandInput placeholder={`Search ${field.label.toLowerCase()}`} />}
|
|
279
|
+
<CommandList>
|
|
280
|
+
<CommandEmpty>No matches</CommandEmpty>
|
|
281
|
+
<CommandGroup heading={field.label}>
|
|
282
|
+
{field.options.map((o) => {
|
|
283
|
+
const on = !!field.selected?.includes(o.value)
|
|
284
|
+
return (
|
|
285
|
+
<CommandItem key={o.value} value={o.label} onSelect={() => toggle(field, o.value)} aria-checked={on}>
|
|
286
|
+
<Checkbox checked={on} tabIndex={-1} aria-hidden="true" className="pointer-events-none" />
|
|
287
|
+
<span className="truncate">{o.label}</span>
|
|
288
|
+
{o.count !== undefined && <span className="tabular ml-auto text-sm text-ink-3">{o.count}</span>}
|
|
289
|
+
</CommandItem>
|
|
290
|
+
)
|
|
291
|
+
})}
|
|
292
|
+
</CommandGroup>
|
|
293
|
+
</CommandList>
|
|
294
|
+
</Command>
|
|
295
|
+
</PopoverContent>
|
|
296
|
+
)}
|
|
297
|
+
</Popover>
|
|
298
|
+
</PopoverContent>
|
|
299
|
+
</Popover>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
/** Result count and Clear, right after the pills. */
|
|
305
|
+
export function FilterSummary({ shown, total, noun = "rows", onClear }: { shown: number; total: number; noun?: string; onClear?: () => void }) {
|
|
306
|
+
const fmt = (n: number) => n.toLocaleString("en-GB")
|
|
307
|
+
return (
|
|
308
|
+
<span className="ml-1 inline-flex items-center gap-control text-sm text-ink-2">
|
|
309
|
+
<span className="tabular" aria-live="polite">{shown === total ? `${fmt(total)} ${noun}` : `${fmt(shown)} of ${fmt(total)} ${noun}`}</span>
|
|
310
|
+
{onClear && shown !== total && (
|
|
311
|
+
<Button variant="ghost" size="xs" onClick={onClear}>Clear</Button>
|
|
312
|
+
)}
|
|
313
|
+
</span>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ---------------------------------------------------------------- filter button and row */
|
|
318
|
+
|
|
319
|
+
/** A field people can filter by, with its values, or a date field (kind: "date") filtered by a period. */
|
|
320
|
+
export type FilterFieldDef = {
|
|
321
|
+
id: string
|
|
322
|
+
label: string
|
|
323
|
+
/** The values to tick. Not used for date fields. */
|
|
324
|
+
options?: FilterOption[]
|
|
325
|
+
/**
|
|
326
|
+
* date: filtered by a period, "Applied is in Last 30 days". The field's entry in FilterValue holds
|
|
327
|
+
* dateFilterToValues (["last-30-days"] or ["2026-10-01", "2026-10-31"]); read it with dateFilterFromValues.
|
|
328
|
+
*/
|
|
329
|
+
kind?: "date"
|
|
330
|
+
/** Date fields: preset periods. Defaults to DATE_PRESETS; [] shows typed dates and the calendar only. */
|
|
331
|
+
presets?: DatePreset[]
|
|
332
|
+
/** Date fields: longest range, in days. */
|
|
333
|
+
maxDays?: number
|
|
334
|
+
operator?: string
|
|
335
|
+
/** How three or more values are counted on the chip, e.g. { one: "stage", many: "stages" }. */
|
|
336
|
+
valueNoun?: { one: string; many: string }
|
|
337
|
+
group?: "Most used" | "All fields"
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Applied filters: field id to its values, in the order they were added. A field is present only while it has values. */
|
|
341
|
+
export type FilterValue = Record<string, string[]>
|
|
342
|
+
|
|
343
|
+
/** Sets one field's values. An empty list removes the field, so a filter exists only while it filters something. */
|
|
344
|
+
export function setFilterValue(value: FilterValue, id: string, values: string[]): FilterValue {
|
|
345
|
+
const next = { ...value }
|
|
346
|
+
if (values.length) next[id] = values
|
|
347
|
+
else delete next[id]
|
|
348
|
+
return next
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const toMenuFields = (fields: FilterFieldDef[], value: FilterValue): FilterField[] =>
|
|
352
|
+
fields.map((f) => ({ id: f.id, label: f.label, group: f.group ?? "Most used", options: f.kind === "date" ? undefined : (f.options ?? []), kind: f.kind, presets: f.presets, maxDays: f.maxDays, selected: value[f.id] ?? [] }))
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The toolbar's filter button: fields, then that field's values beside them, applied on tick.
|
|
356
|
+
* Pair it with FilterRow, which shows what is applied on its own row.
|
|
357
|
+
*/
|
|
358
|
+
export function FilterButton({ fields, value, onChange, label = "Filter" }: { fields: FilterFieldDef[]; value: FilterValue; onChange: (value: FilterValue) => void; label?: string }) {
|
|
359
|
+
return <FilterMenu variant="icon" label={label} fields={toMenuFields(fields, value)} onChange={(id, v) => onChange(setFilterValue(value, id, v))} />
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The applied filters, on their own row under the toolbar: one chip per field, + for another field, then the count
|
|
364
|
+
* and Clear. Renders nothing while no filter is applied, so the row appears only when it has something to say.
|
|
365
|
+
*/
|
|
366
|
+
export function FilterRow({
|
|
367
|
+
fields,
|
|
368
|
+
value,
|
|
369
|
+
onChange,
|
|
370
|
+
shown,
|
|
371
|
+
total,
|
|
372
|
+
noun,
|
|
373
|
+
className,
|
|
374
|
+
}: {
|
|
375
|
+
fields: FilterFieldDef[]
|
|
376
|
+
value: FilterValue
|
|
377
|
+
onChange: (value: FilterValue) => void
|
|
378
|
+
/** Rows showing and in total, for "16 of 150 people" and Clear. */
|
|
379
|
+
shown?: number
|
|
380
|
+
total?: number
|
|
381
|
+
noun?: string
|
|
382
|
+
className?: string
|
|
383
|
+
}) {
|
|
384
|
+
const active = Object.keys(value).filter((id) => value[id]?.length && fields.some((f) => f.id === id))
|
|
385
|
+
if (!active.length) return null
|
|
386
|
+
const rest = fields.filter((f) => !active.includes(f.id))
|
|
387
|
+
return (
|
|
388
|
+
<div data-slot="filter-row" className={cn("flex flex-wrap items-center gap-x-3 gap-y-2", className)}>
|
|
389
|
+
<FilterBar className="min-w-0 flex-1">
|
|
390
|
+
{active.map((id) => {
|
|
391
|
+
const f = fields.find((x) => x.id === id)!
|
|
392
|
+
if (f.kind === "date")
|
|
393
|
+
return (
|
|
394
|
+
<DateFilterPill
|
|
395
|
+
key={id}
|
|
396
|
+
field={f.label}
|
|
397
|
+
value={dateFilterFromValues(value[id], f.presets)}
|
|
398
|
+
onChange={(v) => onChange(setFilterValue(value, id, dateFilterToValues(v)))}
|
|
399
|
+
onRemove={() => onChange(setFilterValue(value, id, []))}
|
|
400
|
+
presets={f.presets}
|
|
401
|
+
maxDays={f.maxDays}
|
|
402
|
+
/>
|
|
403
|
+
)
|
|
404
|
+
return (
|
|
405
|
+
<FilterPill
|
|
406
|
+
key={id}
|
|
407
|
+
field={f.label}
|
|
408
|
+
operator={f.operator ?? "is any of"}
|
|
409
|
+
options={f.options ?? []}
|
|
410
|
+
selected={value[id]}
|
|
411
|
+
onChange={(v) => onChange(setFilterValue(value, id, v))}
|
|
412
|
+
onRemove={() => onChange(setFilterValue(value, id, []))}
|
|
413
|
+
valueNoun={f.valueNoun}
|
|
414
|
+
/>
|
|
415
|
+
)
|
|
416
|
+
})}
|
|
417
|
+
{rest.length > 0 && <FilterMenu variant="add" fields={toMenuFields(rest, value)} onChange={(id, v) => onChange(setFilterValue(value, id, v))} />}
|
|
418
|
+
</FilterBar>
|
|
419
|
+
{shown !== undefined && total !== undefined && <FilterSummary shown={shown} total={total} noun={noun} onClear={() => onChange({})} />}
|
|
420
|
+
</div>
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ---------------------------------------------------------------- export */
|
|
425
|
+
|
|
426
|
+
export type ExportFormat = "CSV" | "JSON"
|
|
427
|
+
|
|
428
|
+
export function ExportMenu({
|
|
429
|
+
onlyVisible,
|
|
430
|
+
onOnlyVisibleChange,
|
|
431
|
+
includeAi,
|
|
432
|
+
onIncludeAiChange,
|
|
433
|
+
formats = ["CSV", "JSON"],
|
|
434
|
+
onDownload,
|
|
435
|
+
onCopy,
|
|
436
|
+
label = "Export",
|
|
437
|
+
shortcut = "⇧E",
|
|
438
|
+
}: {
|
|
439
|
+
/** The trigger's label. */
|
|
440
|
+
label?: string
|
|
441
|
+
/** Bound to the trigger and shown inside the menu, beside its heading (a menu button keeps its hint in the menu); false for no shortcut. */
|
|
442
|
+
shortcut?: string | false
|
|
443
|
+
onlyVisible: boolean
|
|
444
|
+
onOnlyVisibleChange: (v: boolean) => void
|
|
445
|
+
/** Show the AI columns option only when the table has AI columns. */
|
|
446
|
+
includeAi?: boolean
|
|
447
|
+
onIncludeAiChange?: (v: boolean) => void
|
|
448
|
+
/** Most used first. */
|
|
449
|
+
formats?: ExportFormat[]
|
|
450
|
+
onDownload: (format: ExportFormat) => void
|
|
451
|
+
onCopy?: (format: ExportFormat) => void
|
|
452
|
+
}) {
|
|
453
|
+
const keepOpen = (fn: () => void) => (e: Event) => { e.preventDefault(); fn() }
|
|
454
|
+
// The key works on the trigger; its hint lives in the menu, so the button is just icon and label.
|
|
455
|
+
// Radix opens a menu on pointer-down and key-down, not on click, so the shortcut's programmatic click
|
|
456
|
+
// (detail 0) opens it here. Real clicks and Enter are already handled by Radix and never reach this.
|
|
457
|
+
const [open, setOpen] = React.useState(false)
|
|
458
|
+
const trigger = React.useRef<HTMLButtonElement>(null)
|
|
459
|
+
useShortcut(shortcut || undefined, trigger)
|
|
460
|
+
return (
|
|
461
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
462
|
+
<DropdownMenuTrigger asChild>
|
|
463
|
+
<Button ref={trigger} variant="outline" size="sm" onClick={(e) => { if (e.detail === 0) setOpen(true) }} aria-keyshortcuts={shortcut ? shortcut.replace("⇧", "Shift+") : undefined}>
|
|
464
|
+
<Download />
|
|
465
|
+
{label}
|
|
466
|
+
</Button>
|
|
467
|
+
</DropdownMenuTrigger>
|
|
468
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
469
|
+
<DropdownMenuLabel className="flex items-center justify-between gap-3 text-ink">
|
|
470
|
+
{label}
|
|
471
|
+
{shortcut && <Kbd>{shortcut}</Kbd>}
|
|
472
|
+
</DropdownMenuLabel>
|
|
473
|
+
<DropdownMenuSeparator />
|
|
474
|
+
<DropdownMenuLabel>Options</DropdownMenuLabel>
|
|
475
|
+
<DropdownMenuCheckboxItem checked={onlyVisible} onSelect={keepOpen(() => onOnlyVisibleChange(!onlyVisible))}>Only visible columns</DropdownMenuCheckboxItem>
|
|
476
|
+
{onIncludeAiChange && (
|
|
477
|
+
<DropdownMenuCheckboxItem checked={!!includeAi} onSelect={keepOpen(() => onIncludeAiChange(!includeAi))}>Include AI columns</DropdownMenuCheckboxItem>
|
|
478
|
+
)}
|
|
479
|
+
<DropdownMenuSeparator />
|
|
480
|
+
<DropdownMenuLabel>Download</DropdownMenuLabel>
|
|
481
|
+
{formats.map((f) => <DropdownMenuItem key={`d${f}`} onSelect={() => onDownload(f)}>Download {f}</DropdownMenuItem>)}
|
|
482
|
+
{onCopy && (
|
|
483
|
+
<>
|
|
484
|
+
<DropdownMenuSeparator />
|
|
485
|
+
<DropdownMenuLabel>Copy</DropdownMenuLabel>
|
|
486
|
+
{formats.map((f) => <DropdownMenuItem key={`c${f}`} onSelect={() => onCopy(f)}>Copy as {f}</DropdownMenuItem>)}
|
|
487
|
+
</>
|
|
488
|
+
)}
|
|
489
|
+
</DropdownMenuContent>
|
|
490
|
+
</DropdownMenu>
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/* ---------------------------------------------------------------- grouped list */
|
|
495
|
+
|
|
496
|
+
export function GroupedList({ className, children, ...props }: React.ComponentProps<"div">) {
|
|
497
|
+
return <div data-slot="grouped-list" className={cn("overflow-hidden rounded-sm border border-line bg-surface", className)} {...props}>{children}</div>
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function ListGroup({ title, count, action, defaultOpen = true, children }: { title: string; count: number; action?: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode }) {
|
|
501
|
+
const [open, setOpen] = React.useState(defaultOpen)
|
|
502
|
+
return (
|
|
503
|
+
<Collapsible open={open} onOpenChange={setOpen} data-slot="list-group" className="border-b border-line last:border-b-0">
|
|
504
|
+
<div className="flex h-8 items-center gap-2 bg-surface-2 pr-2 pl-1">
|
|
505
|
+
<CollapsibleTrigger asChild>
|
|
506
|
+
<button type="button" className="flex h-full min-w-0 flex-1 items-center gap-control rounded-xs px-2 text-left text-sm font-medium text-ink">
|
|
507
|
+
<ChevronRight className={cn("size-3.5 shrink-0 text-ink-3 transition-transform duration-(--duration-fast) ease-(--ease-out)", open && "rotate-90")} aria-hidden="true" />
|
|
508
|
+
<span className="truncate">{title}</span>
|
|
509
|
+
<span className="tabular font-normal text-ink-3">{count}</span>
|
|
510
|
+
</button>
|
|
511
|
+
</CollapsibleTrigger>
|
|
512
|
+
{action}
|
|
513
|
+
</div>
|
|
514
|
+
<CollapsibleContent>
|
|
515
|
+
<ul className="divide-y divide-line">{children}</ul>
|
|
516
|
+
</CollapsibleContent>
|
|
517
|
+
</Collapsible>
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* ---------------------------------------------------------------- saved views */
|
|
522
|
+
|
|
523
|
+
export type SavedView = { id: string; name: string; count?: number }
|
|
524
|
+
|
|
525
|
+
/** A view action that can't run right now: focusable, announced as unavailable, with its reason under the label. */
|
|
526
|
+
function UnavailableItem({ icon, label, reason }: { icon: React.ReactNode; label: string; reason: string }) {
|
|
527
|
+
return (
|
|
528
|
+
<DropdownMenuItem aria-disabled="true" onSelect={(e) => e.preventDefault()} className="items-start text-ink-3 focus:bg-hover focus:text-ink-3">
|
|
529
|
+
{icon}
|
|
530
|
+
<span className="grid gap-0.5">
|
|
531
|
+
{label}
|
|
532
|
+
<span className="text-sm text-ink-3">{reason}</span>
|
|
533
|
+
</span>
|
|
534
|
+
</DropdownMenuItem>
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function SavedViews({
|
|
539
|
+
views,
|
|
540
|
+
value,
|
|
541
|
+
onValueChange,
|
|
542
|
+
dirty = false,
|
|
543
|
+
onReset,
|
|
544
|
+
onSave,
|
|
545
|
+
onSaveAs,
|
|
546
|
+
onRename,
|
|
547
|
+
onDelete,
|
|
548
|
+
defaultId,
|
|
549
|
+
onSetDefault,
|
|
550
|
+
className,
|
|
551
|
+
}: {
|
|
552
|
+
views: SavedView[]
|
|
553
|
+
value: string
|
|
554
|
+
onValueChange: (id: string) => void
|
|
555
|
+
/** Filters, sort or columns differ from the saved view. */
|
|
556
|
+
dirty?: boolean
|
|
557
|
+
onReset?: () => void
|
|
558
|
+
/** Update the current view. */
|
|
559
|
+
onSave?: () => void
|
|
560
|
+
/** Save the current state as a new view. */
|
|
561
|
+
onSaveAs?: (name: string) => void
|
|
562
|
+
/** Rename a view. Names are unique; the dialog checks before it calls this. */
|
|
563
|
+
onRename?: (id: string, name: string) => void
|
|
564
|
+
/** Delete a view at once. Show a toast with Undo: deleting a view is reversible, so there is no dialog. */
|
|
565
|
+
onDelete?: (id: string) => void
|
|
566
|
+
/** The view that loads when the page opens without filters in the URL. */
|
|
567
|
+
defaultId?: string
|
|
568
|
+
onSetDefault?: (id: string) => void
|
|
569
|
+
className?: string
|
|
570
|
+
}) {
|
|
571
|
+
// One name dialog for Save view and Rename view, validated on submit (never a disabled submit button).
|
|
572
|
+
const [naming, setNaming] = React.useState<null | "save" | "rename">(null)
|
|
573
|
+
const [name, setName] = React.useState("")
|
|
574
|
+
const [error, setError] = React.useState("")
|
|
575
|
+
const fieldId = React.useId()
|
|
576
|
+
// The dialog opens from a button or from a menu item that is gone once the menu closes: focus returns to the button that started it.
|
|
577
|
+
const saveRef = React.useRef<HTMLButtonElement>(null)
|
|
578
|
+
const menuRef = React.useRef<HTMLButtonElement>(null)
|
|
579
|
+
const returnTo = React.useRef<HTMLButtonElement | null>(null)
|
|
580
|
+
const current = views.find((v) => v.id === value)
|
|
581
|
+
const openNaming = (mode: "save" | "rename", from: React.RefObject<HTMLButtonElement | null>) => { returnTo.current = from.current; setName(mode === "rename" ? current?.name ?? "" : ""); setError(""); setNaming(mode) }
|
|
582
|
+
const submit = (e: React.FormEvent) => {
|
|
583
|
+
e.preventDefault()
|
|
584
|
+
const n = name.trim()
|
|
585
|
+
if (!n) return setError("Enter a name for the view.")
|
|
586
|
+
const taken = views.some((v) => v.name.toLowerCase() === n.toLowerCase() && !(naming === "rename" && v.id === value))
|
|
587
|
+
if (taken) return setError(`A view called “${n}” already exists. Choose another name.`)
|
|
588
|
+
if (naming === "rename") { if (n !== current?.name) onRename?.(value, n) }
|
|
589
|
+
else onSaveAs?.(n)
|
|
590
|
+
setNaming(null)
|
|
591
|
+
}
|
|
592
|
+
const manage = !!(onRename || onDelete || onSetDefault)
|
|
593
|
+
return (
|
|
594
|
+
// The tabs keep their natural width. When the actions don't fit beside them, they wrap onto a line above
|
|
595
|
+
// (wrap-reverse), so the tabs stay on the bottom border; tabs wider than the space scroll in their own box.
|
|
596
|
+
<div data-slot="saved-views" className={cn("flex flex-wrap-reverse items-end justify-end gap-x-3 border-b border-line", className)}>
|
|
597
|
+
<Tabs value={value} onValueChange={onValueChange} className="max-w-full min-w-0 flex-auto">
|
|
598
|
+
<TabsList aria-label="Saved views" className="max-w-full overflow-x-auto border-b-0">
|
|
599
|
+
{views.map((v) => (
|
|
600
|
+
<TabsTrigger key={v.id} value={v.id} count={v.count}>
|
|
601
|
+
{v.name}
|
|
602
|
+
{v.id === defaultId && <span className="sr-only">(default view)</span>}
|
|
603
|
+
</TabsTrigger>
|
|
604
|
+
))}
|
|
605
|
+
</TabsList>
|
|
606
|
+
</Tabs>
|
|
607
|
+
<div className="flex max-w-full flex-wrap items-center justify-end gap-control pb-2">
|
|
608
|
+
{dirty && (
|
|
609
|
+
<>
|
|
610
|
+
<Badge variant="warning" dot>Unsaved changes</Badge>
|
|
611
|
+
{onReset && <Button variant="ghost" size="sm" onClick={onReset}>Reset</Button>}
|
|
612
|
+
{onSave && <Button variant="outline" size="sm" onClick={onSave}>Update “{current?.name}”</Button>}
|
|
613
|
+
</>
|
|
614
|
+
)}
|
|
615
|
+
{onSaveAs && <Button ref={saveRef} variant="outline" size="sm" onClick={() => openNaming("save", saveRef)}>Save view</Button>}
|
|
616
|
+
{manage && current && (
|
|
617
|
+
<DropdownMenu>
|
|
618
|
+
<DropdownMenuTrigger asChild>
|
|
619
|
+
<Button ref={menuRef} variant="ghost" size="icon-sm" aria-label={`Manage view “${current.name}”`} className="data-[state=open]:bg-hover">
|
|
620
|
+
<EllipsisVertical />
|
|
621
|
+
</Button>
|
|
622
|
+
</DropdownMenuTrigger>
|
|
623
|
+
<DropdownMenuContent align="end" className="w-60">
|
|
624
|
+
<DropdownMenuLabel className="truncate">{current.name}{current.id === defaultId ? " · Default" : ""}</DropdownMenuLabel>
|
|
625
|
+
{onSave && (dirty
|
|
626
|
+
? <DropdownMenuItem onSelect={onSave}><Save />Update view</DropdownMenuItem>
|
|
627
|
+
: <UnavailableItem icon={<Save />} label="Update view" reason="No changes to save." />)}
|
|
628
|
+
{onSaveAs && <DropdownMenuItem onSelect={() => openNaming("save", menuRef)}><Plus />Save as new view</DropdownMenuItem>}
|
|
629
|
+
{onRename && <DropdownMenuItem onSelect={() => openNaming("rename", menuRef)}><Pencil />Rename view</DropdownMenuItem>}
|
|
630
|
+
{onSetDefault && (current.id === defaultId
|
|
631
|
+
? <UnavailableItem icon={<Star />} label="Set as default" reason="This is already your default view." />
|
|
632
|
+
: dirty
|
|
633
|
+
? <UnavailableItem icon={<Star />} label="Set as default" reason="Update or reset your changes first." />
|
|
634
|
+
: <DropdownMenuItem onSelect={() => onSetDefault(current.id)}><Star />Set as default</DropdownMenuItem>)}
|
|
635
|
+
{onDelete && (
|
|
636
|
+
<>
|
|
637
|
+
<DropdownMenuSeparator />
|
|
638
|
+
{views.length > 1
|
|
639
|
+
? <DropdownMenuItem variant="destructive" onSelect={() => onDelete(current.id)}><Trash2 />Delete view</DropdownMenuItem>
|
|
640
|
+
: <UnavailableItem icon={<Trash2 />} label="Delete view" reason="The only view can't be deleted." />}
|
|
641
|
+
</>
|
|
642
|
+
)}
|
|
643
|
+
</DropdownMenuContent>
|
|
644
|
+
</DropdownMenu>
|
|
645
|
+
)}
|
|
646
|
+
</div>
|
|
647
|
+
<Dialog open={naming !== null} onOpenChange={(o) => !o && setNaming(null)}>
|
|
648
|
+
<DialogContent className="sm:max-w-sm" onCloseAutoFocus={(e) => { if (returnTo.current) { e.preventDefault(); returnTo.current.focus() } }}>
|
|
649
|
+
<DialogHeader>
|
|
650
|
+
<DialogTitle>{naming === "rename" ? "Rename view" : "Save view"}</DialogTitle>
|
|
651
|
+
<DialogDescription>{naming === "rename" ? "The view keeps its filters, sort order and columns." : "Saves the current filters, sort order and columns."}</DialogDescription>
|
|
652
|
+
</DialogHeader>
|
|
653
|
+
<form noValidate className="grid gap-4" onSubmit={submit}>
|
|
654
|
+
<div className="grid gap-2">
|
|
655
|
+
<label htmlFor={fieldId} className="text-base font-medium text-ink">Name</label>
|
|
656
|
+
<Input
|
|
657
|
+
id={fieldId}
|
|
658
|
+
value={name}
|
|
659
|
+
onChange={(e) => { setName(e.target.value); if (error) setError("") }}
|
|
660
|
+
placeholder="Closing this month"
|
|
661
|
+
aria-invalid={!!error || undefined}
|
|
662
|
+
aria-describedby={error ? `${fieldId}-error` : undefined}
|
|
663
|
+
autoFocus
|
|
664
|
+
/>
|
|
665
|
+
{error && <p id={`${fieldId}-error`} className="text-sm text-bad">{error}</p>}
|
|
666
|
+
</div>
|
|
667
|
+
<DialogFooter>
|
|
668
|
+
<DialogClose asChild><Button type="button" variant="outline">Cancel</Button></DialogClose>
|
|
669
|
+
<Button type="submit">{naming === "rename" ? "Rename view" : "Save view"}</Button>
|
|
670
|
+
</DialogFooter>
|
|
671
|
+
</form>
|
|
672
|
+
</DialogContent>
|
|
673
|
+
</Dialog>
|
|
674
|
+
</div>
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/* ---------------------------------------------------------------- filter panel */
|
|
679
|
+
|
|
680
|
+
export type FilterSection = { id: string; title: string; options: FilterOption[]; defaultOpen?: boolean }
|
|
681
|
+
|
|
682
|
+
/** Many-field search: collapsible sections, live counts per option, Clear all and a clear per section. */
|
|
683
|
+
export function FilterPanel({
|
|
684
|
+
sections,
|
|
685
|
+
value,
|
|
686
|
+
onChange,
|
|
687
|
+
className,
|
|
688
|
+
}: {
|
|
689
|
+
sections: FilterSection[]
|
|
690
|
+
value: Record<string, string[]>
|
|
691
|
+
onChange: (value: Record<string, string[]>) => void
|
|
692
|
+
className?: string
|
|
693
|
+
}) {
|
|
694
|
+
const active = Object.values(value).reduce((n, v) => n + v.length, 0)
|
|
695
|
+
const set = (id: string, vals: string[]) => onChange({ ...value, [id]: vals })
|
|
696
|
+
return (
|
|
697
|
+
<div data-slot="filter-panel" className={cn("flex min-h-0 flex-col", className)}>
|
|
698
|
+
<div className="flex items-center gap-2 border-b border-line px-3 py-2">
|
|
699
|
+
<h2 className="text-base font-semibold text-ink">Filters</h2>
|
|
700
|
+
{active > 0 && <Badge variant="secondary" className="tabular">{active}</Badge>}
|
|
701
|
+
<span className="flex-1" />
|
|
702
|
+
{active > 0 && <Button variant="ghost" size="xs" onClick={() => onChange({})}>Clear all</Button>}
|
|
703
|
+
</div>
|
|
704
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
705
|
+
{sections.map((s) => {
|
|
706
|
+
const sel = value[s.id] ?? []
|
|
707
|
+
return (
|
|
708
|
+
<Collapsible key={s.id} defaultOpen={s.defaultOpen} className="group/sec border-b border-line">
|
|
709
|
+
<div className="flex items-center pr-2">
|
|
710
|
+
<CollapsibleTrigger asChild>
|
|
711
|
+
<button type="button" className="flex h-9 min-w-0 flex-1 items-center gap-control rounded-xs px-3 text-left text-base font-medium text-ink">
|
|
712
|
+
<ChevronRight className="size-3.5 shrink-0 text-ink-3 transition-transform duration-(--duration-fast) group-data-[state=open]/sec:rotate-90" aria-hidden="true" />
|
|
713
|
+
<span className="truncate">{s.title}</span>
|
|
714
|
+
{sel.length > 0 && <span className="tabular text-sm font-normal text-primary-ink">{sel.length} set</span>}
|
|
715
|
+
</button>
|
|
716
|
+
</CollapsibleTrigger>
|
|
717
|
+
{sel.length > 0 && <Button variant="ghost" size="xs" onClick={() => set(s.id, [])} aria-label={`Clear ${s.title}`}>Clear</Button>}
|
|
718
|
+
</div>
|
|
719
|
+
<CollapsibleContent>
|
|
720
|
+
<ul className="grid gap-0.5 px-2 pb-2">
|
|
721
|
+
{s.options.map((o) => {
|
|
722
|
+
const on = sel.includes(o.value)
|
|
723
|
+
const id = `fp-${s.id}-${o.value}`.replace(/\W+/g, "-")
|
|
724
|
+
return (
|
|
725
|
+
<li key={o.value} className="flex h-7 items-center gap-2 rounded-xs px-2 hover:bg-hover">
|
|
726
|
+
<Checkbox id={id} checked={on} onCheckedChange={() => set(s.id, on ? sel.filter((x) => x !== o.value) : [...sel, o.value])} />
|
|
727
|
+
<label htmlFor={id} className="min-w-0 flex-1 truncate text-base text-ink">{o.label}</label>
|
|
728
|
+
{o.count !== undefined && <span className="tabular text-sm text-ink-3">{o.count}</span>}
|
|
729
|
+
</li>
|
|
730
|
+
)
|
|
731
|
+
})}
|
|
732
|
+
</ul>
|
|
733
|
+
</CollapsibleContent>
|
|
734
|
+
</Collapsible>
|
|
735
|
+
)
|
|
736
|
+
})}
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
)
|
|
740
|
+
}
|