@exxatdesignux/ui 0.2.9 → 0.2.11
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/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
- package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
- package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
- package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
- package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
- package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
- package/consumer-extras/patterns/data-views-pattern.md +12 -4
- package/package.json +4 -1
- package/src/components/ui/banner.tsx +20 -7
- package/src/components/ui/date-picker-field.tsx +3 -3
- package/src/components/ui/dropdown-menu.tsx +17 -6
- package/src/components/ui/input-group.tsx +1 -1
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/separator.tsx +2 -2
- package/src/components/ui/sidebar.tsx +31 -3
- package/src/components/ui/textarea.tsx +1 -1
- package/src/globals.css +0 -1
- package/src/index.ts +1 -0
- package/src/lib/date-filter.ts +13 -4
- package/src/lib/dropdown-menu-surface.ts +13 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
- package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
- package/template/.nvmrc +1 -1
- package/template/AGENTS.md +82 -27
- package/template/app/(app)/examples/page.tsx +2 -1
- package/template/app/(app)/help/page.tsx +6 -0
- package/template/app/(app)/layout.tsx +7 -4
- package/template/app/(app)/question-bank/find/page.tsx +12 -0
- package/template/app/(app)/question-bank/layout.tsx +46 -0
- package/template/app/(app)/question-bank/library/page.tsx +11 -0
- package/template/app/(app)/question-bank/list/page.tsx +12 -0
- package/template/app/(app)/question-bank/page.tsx +4 -3
- package/template/app/globals.css +1 -2
- package/template/components/app-sidebar.tsx +51 -13
- package/template/components/ask-leo-composer.tsx +173 -45
- package/template/components/ask-leo-sidebar.tsx +9 -1
- package/template/components/chart-area-interactive.tsx +3 -13
- package/template/components/charts-overview.tsx +33 -6
- package/template/components/collaboration-access-flow.tsx +144 -0
- package/template/components/compliance-page-header.tsx +1 -1
- package/template/components/compliance-table.tsx +2 -2
- package/template/components/dashboard-tabs.tsx +4 -3
- package/template/components/data-list-table-cells.tsx +1 -1
- package/template/components/data-list-table.tsx +1 -1
- package/template/components/data-table/index.tsx +5 -5
- package/template/components/data-table/use-table-state.ts +18 -2
- package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
- package/template/components/data-view-dashboard-charts-team.tsx +8 -5
- package/template/components/data-view-dashboard-charts.tsx +62 -227
- package/template/components/dedicated-search-recents.tsx +96 -0
- package/template/components/dedicated-search-url-composer.tsx +112 -0
- package/template/components/getting-started.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +10 -26
- package/template/components/invite-collaborators-drawer.tsx +453 -0
- package/template/components/key-metrics.tsx +54 -8
- package/template/components/nav-documents.tsx +1 -1
- package/template/components/new-placement-form.tsx +3 -3
- package/template/components/page-header.tsx +76 -59
- package/template/components/placements-board-view.tsx +3 -3
- package/template/components/placements-page-header.tsx +1 -1
- package/template/components/placements-table-columns.tsx +3 -2
- package/template/components/product-switcher.tsx +0 -1
- package/template/components/question-bank-board-view.tsx +35 -47
- package/template/components/question-bank-client.tsx +293 -81
- package/template/components/question-bank-dashboard-charts.tsx +174 -0
- package/template/components/question-bank-favorite-button.tsx +46 -0
- package/template/components/question-bank-hub-client.tsx +436 -0
- package/template/components/question-bank-list-view.tsx +26 -19
- package/template/components/question-bank-new-folder-sheet.tsx +56 -42
- package/template/components/question-bank-os-folder-view.tsx +3 -14
- package/template/components/question-bank-page-header.tsx +85 -53
- package/template/components/question-bank-panel-activator.tsx +3 -4
- package/template/components/question-bank-secondary-nav.tsx +523 -65
- package/template/components/question-bank-table.tsx +125 -343
- package/template/components/secondary-panel.tsx +130 -63
- package/template/components/settings-client.tsx +3 -1
- package/template/components/sidebar-shell.tsx +2 -0
- package/template/components/sites-all-client.tsx +1 -1
- package/template/components/sites-table.tsx +1 -1
- package/template/components/system-banner-slot.tsx +2 -1
- package/template/components/table-properties/drawer.tsx +3 -3
- package/template/components/table-properties/sort-card.tsx +1 -1
- package/template/components/team-page-header.tsx +1 -1
- package/template/components/team-table.tsx +8 -4
- package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
- package/template/components/templates/dedicated-search-results-template.tsx +19 -0
- package/template/components/templates/discovery-hub-template.tsx +273 -0
- package/template/components/templates/list-page.tsx +11 -4
- package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
- package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
- package/template/docs/card-vs-rows-pattern.md +36 -0
- package/template/docs/collaboration-access-pattern.md +114 -0
- package/template/docs/data-views-pattern.md +12 -4
- package/template/docs/drawer-vs-dialog-pattern.md +50 -0
- package/template/docs/kpi-strip-max-four-pattern.md +29 -0
- package/template/docs/kpi-trend-pattern.md +43 -0
- package/template/fontawesome-subset.manifest.json +2 -2
- package/template/hooks/use-location-hash.ts +14 -8
- package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
- package/template/lib/ask-leo-route-context.ts +24 -0
- package/template/lib/collaborator-access.ts +92 -0
- package/template/lib/command-menu-config.ts +8 -1
- package/template/lib/command-menu-search-data.ts +11 -8
- package/template/lib/data-list-display-options.ts +1 -1
- package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
- package/template/lib/date-filter.ts +1 -0
- package/template/lib/dedicated-search-recents.ts +76 -0
- package/template/lib/dedicated-search-url.ts +23 -0
- package/template/lib/discovery-hub.ts +15 -0
- package/template/lib/list-status-badges.ts +1 -21
- package/template/lib/mock/navigation.tsx +4 -2
- package/template/lib/mock/placements.ts +9 -9
- package/template/lib/mock/question-bank-folders.ts +7 -0
- package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
- package/template/lib/mock/question-bank-inspector.ts +1 -2
- package/template/lib/mock/question-bank-kpi.ts +38 -26
- package/template/lib/mock/question-bank.ts +43 -16
- package/template/lib/question-bank-dedicated-search.ts +19 -0
- package/template/lib/question-bank-hub-search.ts +90 -0
- package/template/lib/question-bank-nav.ts +322 -6
- package/template/lib/question-bank-recent-searches.ts +22 -0
- package/template/package.json +1 -2
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
PencilLine,
|
|
17
17
|
X,
|
|
18
18
|
} from "lucide-react"
|
|
19
|
-
import {
|
|
19
|
+
import { LIST_HUB_INSPECTOR_CHIP_SHELL } from "@/components/list-hub-status-badge"
|
|
20
20
|
import { Badge } from "@/components/ui/badge"
|
|
21
21
|
import { Button } from "@/components/ui/button"
|
|
22
22
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
|
@@ -35,10 +35,8 @@ import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folde
|
|
|
35
35
|
import type {
|
|
36
36
|
QuestionBankItem,
|
|
37
37
|
QuestionBankDifficulty,
|
|
38
|
-
QuestionBankStatus,
|
|
39
38
|
} from "@/lib/mock/question-bank"
|
|
40
39
|
import type { QuestionBankFolder, QuestionBankFolderColorKey } from "@/lib/mock/question-bank-folders"
|
|
41
|
-
import { QUESTION_BANK_STATUS_BADGE_CLASS, QUESTION_BANK_STATUS_ICON } from "@/lib/list-status-badges"
|
|
42
40
|
import { formatDateUS } from "@/lib/date-filter"
|
|
43
41
|
import {
|
|
44
42
|
deriveBloomLevel,
|
|
@@ -56,13 +54,6 @@ const DIFFICULTY_LABEL: Record<QuestionBankDifficulty, string> = {
|
|
|
56
54
|
hard: "Hard",
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
/** Inspector header label — “Saved” for published matches reviewer checklist language. */
|
|
60
|
-
const INSPECTOR_STATUS_LABEL: Record<QuestionBankStatus, string> = {
|
|
61
|
-
published: "Saved",
|
|
62
|
-
draft: "Draft",
|
|
63
|
-
in_review: "In review",
|
|
64
|
-
}
|
|
65
|
-
|
|
66
57
|
// ============================================================================
|
|
67
58
|
// TreeItem — recursive folder/question renderer using Collapsible
|
|
68
59
|
// ============================================================================
|
|
@@ -181,7 +172,12 @@ function TreeItem({
|
|
|
181
172
|
isSelected ? "fill-current opacity-80" : "text-muted-foreground",
|
|
182
173
|
)}
|
|
183
174
|
/>
|
|
184
|
-
<span className="
|
|
175
|
+
<span className="min-w-0 flex-1">
|
|
176
|
+
<span className="block truncate leading-tight">{question.stem}</span>
|
|
177
|
+
<span className="block truncate font-mono text-[11px] text-muted-foreground">
|
|
178
|
+
{question.questionId}
|
|
179
|
+
</span>
|
|
180
|
+
</span>
|
|
185
181
|
</button>
|
|
186
182
|
</div>
|
|
187
183
|
)
|
|
@@ -268,12 +264,7 @@ function DetailsPanel({ selectedItemId, folders, questions, onClearSelection }:
|
|
|
268
264
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-card">
|
|
269
265
|
<header className="shrink-0 border-b border-border/60 bg-muted/10 px-4 pb-4 pt-3">
|
|
270
266
|
<div className="flex items-start justify-between gap-3">
|
|
271
|
-
<
|
|
272
|
-
surface="detail"
|
|
273
|
-
label={INSPECTOR_STATUS_LABEL[question.status]}
|
|
274
|
-
tintClassName={QUESTION_BANK_STATUS_BADGE_CLASS[question.status]}
|
|
275
|
-
icon={QUESTION_BANK_STATUS_ICON[question.status]}
|
|
276
|
-
/>
|
|
267
|
+
<p className="font-mono text-xs text-muted-foreground">{question.questionId}</p>
|
|
277
268
|
{onClearSelection ? (
|
|
278
269
|
<Tip label="Close details" side="bottom">
|
|
279
270
|
<Button
|
|
@@ -299,16 +290,9 @@ function DetailsPanel({ selectedItemId, folders, questions, onClearSelection }:
|
|
|
299
290
|
</Button>
|
|
300
291
|
</span>
|
|
301
292
|
</Tip>
|
|
302
|
-
<Tip
|
|
303
|
-
label={
|
|
304
|
-
question.status === "draft"
|
|
305
|
-
? "Already a draft."
|
|
306
|
-
: "Revert connects when your assessments API is wired."
|
|
307
|
-
}
|
|
308
|
-
side="bottom"
|
|
309
|
-
>
|
|
293
|
+
<Tip label="Revert connects when your assessments API is wired." side="bottom">
|
|
310
294
|
<span className="inline-flex">
|
|
311
|
-
<Button type="button" variant="outline" size="sm" className="gap-1.5" disabled
|
|
295
|
+
<Button type="button" variant="outline" size="sm" className="gap-1.5" disabled>
|
|
312
296
|
<Hourglass className="size-3.5" aria-hidden />
|
|
313
297
|
Revert to draft
|
|
314
298
|
</Button>
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useForm } from "react-hook-form"
|
|
5
|
+
import { z } from "zod"
|
|
6
|
+
import { zodResolver } from "@hookform/resolvers/zod"
|
|
7
|
+
|
|
8
|
+
import { devLog } from "@/lib/dev-log"
|
|
9
|
+
import {
|
|
10
|
+
COLLABORATOR_ACCESS_ICON_LIGHT,
|
|
11
|
+
COLLABORATOR_ACCESS_LABELS,
|
|
12
|
+
INVITE_COLLABORATOR_ACCESS_OPTIONS,
|
|
13
|
+
ROSTER_COLLABORATOR_ACCESS_OPTIONS,
|
|
14
|
+
canRemoveCollaboratorFromRoster,
|
|
15
|
+
canSetCollaboratorAccessRole,
|
|
16
|
+
collaboratorRemoveBlockedReason,
|
|
17
|
+
type CollaboratorAccessRole,
|
|
18
|
+
type InviteCollaboratorAccessRole,
|
|
19
|
+
} from "@/lib/collaborator-access"
|
|
20
|
+
import { initialsFromDisplayName } from "@/lib/initials-from-name"
|
|
21
|
+
import { cn } from "@/lib/utils"
|
|
22
|
+
import type { PageHeaderCollaborator } from "@/components/page-header"
|
|
23
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
24
|
+
import { Badge } from "@/components/ui/badge"
|
|
25
|
+
import { Button } from "@/components/ui/button"
|
|
26
|
+
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
|
|
27
|
+
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
|
|
28
|
+
import { Tip } from "@/components/ui/tip"
|
|
29
|
+
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
30
|
+
import { Shortcut } from "@/components/ui/dropdown-menu"
|
|
31
|
+
import {
|
|
32
|
+
Popover,
|
|
33
|
+
PopoverContent,
|
|
34
|
+
PopoverTrigger,
|
|
35
|
+
} from "@/components/ui/popover"
|
|
36
|
+
import {
|
|
37
|
+
Sheet,
|
|
38
|
+
SheetContent,
|
|
39
|
+
SheetTitle,
|
|
40
|
+
} from "@/components/ui/sheet"
|
|
41
|
+
import {
|
|
42
|
+
Dialog,
|
|
43
|
+
DialogContent,
|
|
44
|
+
DialogDescription,
|
|
45
|
+
DialogFooter,
|
|
46
|
+
DialogHeader,
|
|
47
|
+
DialogTitle,
|
|
48
|
+
} from "@/components/ui/dialog"
|
|
49
|
+
import {
|
|
50
|
+
Form,
|
|
51
|
+
FormControl,
|
|
52
|
+
FormField,
|
|
53
|
+
FormMessage,
|
|
54
|
+
} from "@/components/ui/form"
|
|
55
|
+
|
|
56
|
+
const inviteSchema = z.object({
|
|
57
|
+
email: z.string().min(1, "Email is required").email("Enter a valid email address"),
|
|
58
|
+
access: z.enum(["editor", "commenter", "viewer"]),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
type InviteForm = z.infer<typeof inviteSchema>
|
|
62
|
+
|
|
63
|
+
export type InviteCollaboratorFormValues = InviteForm
|
|
64
|
+
|
|
65
|
+
export interface InviteCollaboratorsDrawerProps {
|
|
66
|
+
open: boolean
|
|
67
|
+
onOpenChange: (open: boolean) => void
|
|
68
|
+
collaborators: PageHeaderCollaborator[]
|
|
69
|
+
resourceLabel?: string
|
|
70
|
+
onInvite?: (values: InviteCollaboratorFormValues) => void
|
|
71
|
+
onCollaboratorAccessChange?: (id: string, access: CollaboratorAccessRole) => void
|
|
72
|
+
onCollaboratorRemove?: (id: string) => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function collaboratorAccessLabel(access: PageHeaderCollaborator["access"]) {
|
|
76
|
+
if (!access) return "Viewer"
|
|
77
|
+
return COLLABORATOR_ACCESS_LABELS[access]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isOverlaySelectorSheetTarget(target: EventTarget | null) {
|
|
81
|
+
return (
|
|
82
|
+
target instanceof Element
|
|
83
|
+
&& target.closest(
|
|
84
|
+
'[data-slot="popover-content"], [data-slot="popover-trigger"], [data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-trigger"]',
|
|
85
|
+
) != null
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function CollaboratorAccessChipSelector({
|
|
90
|
+
value,
|
|
91
|
+
onValueChange,
|
|
92
|
+
options,
|
|
93
|
+
ariaLabel,
|
|
94
|
+
triggerId,
|
|
95
|
+
triggerClassName,
|
|
96
|
+
isOptionDisabled,
|
|
97
|
+
}: {
|
|
98
|
+
value: string
|
|
99
|
+
onValueChange: (value: string) => void
|
|
100
|
+
options: readonly { value: string; label: string }[]
|
|
101
|
+
ariaLabel: string
|
|
102
|
+
triggerId?: string
|
|
103
|
+
triggerClassName?: string
|
|
104
|
+
isOptionDisabled?: (value: string) => boolean
|
|
105
|
+
}) {
|
|
106
|
+
const [open, setOpen] = React.useState(false)
|
|
107
|
+
const selected = options.find(option => option.value === value)
|
|
108
|
+
const selectedLabel = selected?.label ?? value
|
|
109
|
+
const selectedIcon = COLLABORATOR_ACCESS_ICON_LIGHT[value as CollaboratorAccessRole]
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
|
113
|
+
<PopoverTrigger asChild>
|
|
114
|
+
<Badge
|
|
115
|
+
asChild
|
|
116
|
+
variant="secondary"
|
|
117
|
+
className={cn("font-normal hover:bg-secondary/80", triggerClassName)}
|
|
118
|
+
>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
id={triggerId}
|
|
122
|
+
data-slot="popover-trigger"
|
|
123
|
+
aria-label={ariaLabel}
|
|
124
|
+
aria-haspopup="listbox"
|
|
125
|
+
aria-expanded={open}
|
|
126
|
+
>
|
|
127
|
+
<i className={cn("fa-light", selectedIcon)} aria-hidden="true" />
|
|
128
|
+
{selectedLabel}
|
|
129
|
+
<i
|
|
130
|
+
className="fa-light fa-chevron-down text-muted-foreground"
|
|
131
|
+
data-icon="inline-end"
|
|
132
|
+
aria-hidden="true"
|
|
133
|
+
/>
|
|
134
|
+
</button>
|
|
135
|
+
</Badge>
|
|
136
|
+
</PopoverTrigger>
|
|
137
|
+
<PopoverContent align="end" sideOffset={4} className="w-44 p-1">
|
|
138
|
+
<div role="listbox" aria-label={ariaLabel} className="flex flex-col gap-0.5">
|
|
139
|
+
{options.map(option => {
|
|
140
|
+
const isSelected = option.value === value
|
|
141
|
+
const isDisabled = isOptionDisabled?.(option.value) ?? false
|
|
142
|
+
const optionIcon = COLLABORATOR_ACCESS_ICON_LIGHT[option.value as CollaboratorAccessRole]
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<button
|
|
146
|
+
key={option.value}
|
|
147
|
+
type="button"
|
|
148
|
+
role="option"
|
|
149
|
+
aria-selected={isSelected}
|
|
150
|
+
disabled={isDisabled}
|
|
151
|
+
onClick={() => {
|
|
152
|
+
if (isDisabled || isSelected) return
|
|
153
|
+
onValueChange(option.value)
|
|
154
|
+
setOpen(false)
|
|
155
|
+
}}
|
|
156
|
+
className={cn(
|
|
157
|
+
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",
|
|
158
|
+
isSelected
|
|
159
|
+
? "bg-accent text-accent-foreground"
|
|
160
|
+
: "text-foreground hover:bg-interactive-hover",
|
|
161
|
+
isDisabled && "cursor-not-allowed opacity-50",
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
<i className={cn("fa-light", optionIcon, "text-muted-foreground")} aria-hidden="true" />
|
|
165
|
+
<span className="min-w-0 flex-1">{option.label}</span>
|
|
166
|
+
{isSelected ? (
|
|
167
|
+
<i className="fa-light fa-check text-muted-foreground" aria-hidden="true" />
|
|
168
|
+
) : null}
|
|
169
|
+
</button>
|
|
170
|
+
)
|
|
171
|
+
})}
|
|
172
|
+
</div>
|
|
173
|
+
</PopoverContent>
|
|
174
|
+
</Popover>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function InviteCollaboratorsDrawer({
|
|
179
|
+
open,
|
|
180
|
+
onOpenChange,
|
|
181
|
+
collaborators,
|
|
182
|
+
resourceLabel = "this library",
|
|
183
|
+
onInvite,
|
|
184
|
+
onCollaboratorAccessChange,
|
|
185
|
+
onCollaboratorRemove,
|
|
186
|
+
}: InviteCollaboratorsDrawerProps) {
|
|
187
|
+
const form = useForm<InviteForm>({
|
|
188
|
+
resolver: zodResolver(inviteSchema),
|
|
189
|
+
defaultValues: {
|
|
190
|
+
email: "",
|
|
191
|
+
access: "editor",
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
const inviteAccess = form.watch("access")
|
|
195
|
+
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
196
|
+
const [removeTarget, setRemoveTarget] = React.useState<PageHeaderCollaborator | null>(null)
|
|
197
|
+
|
|
198
|
+
React.useEffect(() => {
|
|
199
|
+
if (!open) {
|
|
200
|
+
form.reset()
|
|
201
|
+
setIsSubmitting(false)
|
|
202
|
+
setRemoveTarget(null)
|
|
203
|
+
}
|
|
204
|
+
}, [open, form])
|
|
205
|
+
|
|
206
|
+
function confirmRemove() {
|
|
207
|
+
if (!removeTarget) return
|
|
208
|
+
onCollaboratorRemove?.(removeTarget.id)
|
|
209
|
+
setRemoveTarget(null)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function onSubmit(values: InviteForm) {
|
|
213
|
+
setIsSubmitting(true)
|
|
214
|
+
await new Promise(resolve => window.setTimeout(resolve, 600))
|
|
215
|
+
devLog("Invite collaborator:", values)
|
|
216
|
+
onInvite?.(values)
|
|
217
|
+
setIsSubmitting(false)
|
|
218
|
+
onOpenChange(false)
|
|
219
|
+
form.reset()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
224
|
+
<SheetContent
|
|
225
|
+
data-slot="invite-collaborators-drawer"
|
|
226
|
+
side="right"
|
|
227
|
+
showCloseButton={false}
|
|
228
|
+
showOverlay={false}
|
|
229
|
+
className="w-80 sm:max-w-80 p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl"
|
|
230
|
+
style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100vh - 1rem)" }}
|
|
231
|
+
onPointerDownOutside={event => {
|
|
232
|
+
if (isOverlaySelectorSheetTarget(event.target)) {
|
|
233
|
+
event.preventDefault()
|
|
234
|
+
}
|
|
235
|
+
}}
|
|
236
|
+
onInteractOutside={event => {
|
|
237
|
+
if (isOverlaySelectorSheetTarget(event.target)) {
|
|
238
|
+
event.preventDefault()
|
|
239
|
+
}
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<div className="flex items-center justify-between gap-3 px-4 pt-5 pb-3">
|
|
243
|
+
<SheetTitle className="text-base font-semibold leading-tight">Collaborators</SheetTitle>
|
|
244
|
+
<Tip label="Close" side="bottom">
|
|
245
|
+
<Button
|
|
246
|
+
type="button"
|
|
247
|
+
variant="ghost"
|
|
248
|
+
size="icon-sm"
|
|
249
|
+
aria-label="Close"
|
|
250
|
+
onClick={() => onOpenChange(false)}
|
|
251
|
+
>
|
|
252
|
+
<i className="fa-light fa-xmark" aria-hidden="true" />
|
|
253
|
+
</Button>
|
|
254
|
+
</Tip>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<p className="px-4 pb-3 text-sm text-muted-foreground -mt-1">
|
|
258
|
+
Manage who can access {resourceLabel}.
|
|
259
|
+
</p>
|
|
260
|
+
|
|
261
|
+
<div className="flex flex-1 flex-col gap-6 overflow-y-auto px-4 pb-4">
|
|
262
|
+
<Form {...form}>
|
|
263
|
+
<form
|
|
264
|
+
id="invite-collaborators-form"
|
|
265
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
266
|
+
>
|
|
267
|
+
<FieldGroup>
|
|
268
|
+
<FormField
|
|
269
|
+
control={form.control}
|
|
270
|
+
name="email"
|
|
271
|
+
render={({ field }) => (
|
|
272
|
+
<Field data-invalid={!!form.formState.errors.email}>
|
|
273
|
+
<FieldLabel htmlFor="invite-collaborator-email">Invite by email</FieldLabel>
|
|
274
|
+
<InputGroup>
|
|
275
|
+
<FormControl>
|
|
276
|
+
<InputGroupInput
|
|
277
|
+
{...field}
|
|
278
|
+
id="invite-collaborator-email"
|
|
279
|
+
type="email"
|
|
280
|
+
inputMode="email"
|
|
281
|
+
autoComplete="email"
|
|
282
|
+
placeholder="name@example.com"
|
|
283
|
+
aria-required="true"
|
|
284
|
+
aria-invalid={!!form.formState.errors.email}
|
|
285
|
+
/>
|
|
286
|
+
</FormControl>
|
|
287
|
+
<InputGroupAddon align="inline-end">
|
|
288
|
+
<CollaboratorAccessChipSelector
|
|
289
|
+
value={inviteAccess}
|
|
290
|
+
onValueChange={value =>
|
|
291
|
+
form.setValue("access", value as InviteCollaboratorAccessRole, {
|
|
292
|
+
shouldDirty: true,
|
|
293
|
+
shouldValidate: true,
|
|
294
|
+
})}
|
|
295
|
+
options={INVITE_COLLABORATOR_ACCESS_OPTIONS}
|
|
296
|
+
ariaLabel="Access level"
|
|
297
|
+
triggerId="invite-collaborator-access"
|
|
298
|
+
/>
|
|
299
|
+
</InputGroupAddon>
|
|
300
|
+
</InputGroup>
|
|
301
|
+
<FieldDescription>name@example.com</FieldDescription>
|
|
302
|
+
<FormMessage />
|
|
303
|
+
</Field>
|
|
304
|
+
)}
|
|
305
|
+
/>
|
|
306
|
+
</FieldGroup>
|
|
307
|
+
<Button
|
|
308
|
+
type="submit"
|
|
309
|
+
className="w-full"
|
|
310
|
+
disabled={isSubmitting}
|
|
311
|
+
>
|
|
312
|
+
{isSubmitting ? (
|
|
313
|
+
<>
|
|
314
|
+
<i className="fa-light fa-spinner-third fa-spin" aria-hidden="true" />
|
|
315
|
+
Sending…
|
|
316
|
+
</>
|
|
317
|
+
) : (
|
|
318
|
+
<>
|
|
319
|
+
<i className="fa-light fa-user-plus" aria-hidden="true" />
|
|
320
|
+
Send invite
|
|
321
|
+
<KbdGroup className="ml-1.5"><Kbd variant="bare">⏎</Kbd></KbdGroup>
|
|
322
|
+
</>
|
|
323
|
+
)}
|
|
324
|
+
</Button>
|
|
325
|
+
</form>
|
|
326
|
+
</Form>
|
|
327
|
+
|
|
328
|
+
<section aria-labelledby="invite-collaborators-list-heading" className="flex flex-col gap-3">
|
|
329
|
+
<h2
|
|
330
|
+
id="invite-collaborators-list-heading"
|
|
331
|
+
className="text-sm font-medium leading-none"
|
|
332
|
+
>
|
|
333
|
+
People with access
|
|
334
|
+
</h2>
|
|
335
|
+
<ul className="rounded-lg border border-border divide-y divide-border">
|
|
336
|
+
{collaborators.map(person => {
|
|
337
|
+
const access = person.access ?? "viewer"
|
|
338
|
+
const removeBlocked = onCollaboratorRemove
|
|
339
|
+
? collaboratorRemoveBlockedReason(person, collaborators)
|
|
340
|
+
: undefined
|
|
341
|
+
const canRemove = onCollaboratorRemove && !removeBlocked
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<li
|
|
345
|
+
key={person.id}
|
|
346
|
+
className="flex items-start gap-3 px-3 py-2.5"
|
|
347
|
+
>
|
|
348
|
+
<Avatar size="sm" shape="circle" className="mt-0.5 shrink-0">
|
|
349
|
+
{person.imageUrl ? (
|
|
350
|
+
<AvatarImage src={person.imageUrl} alt="" referrerPolicy="no-referrer" />
|
|
351
|
+
) : null}
|
|
352
|
+
<AvatarFallback className="text-xs font-semibold">
|
|
353
|
+
{(person.initials ?? initialsFromDisplayName(person.name)).toUpperCase()}
|
|
354
|
+
</AvatarFallback>
|
|
355
|
+
</Avatar>
|
|
356
|
+
<div className="min-w-0 flex-1">
|
|
357
|
+
<p className="truncate text-sm font-medium text-foreground">{person.name}</p>
|
|
358
|
+
{person.email ? (
|
|
359
|
+
<p className="truncate text-xs text-muted-foreground">{person.email}</p>
|
|
360
|
+
) : null}
|
|
361
|
+
{person.roles && person.roles.length > 0 ? (
|
|
362
|
+
<div className="mt-1.5 flex flex-wrap gap-1">
|
|
363
|
+
{person.roles.map(role => (
|
|
364
|
+
<Badge key={role} variant="outline" className="font-normal">
|
|
365
|
+
{role}
|
|
366
|
+
</Badge>
|
|
367
|
+
))}
|
|
368
|
+
</div>
|
|
369
|
+
) : null}
|
|
370
|
+
</div>
|
|
371
|
+
<div className="flex shrink-0 items-center gap-1 self-start pt-0.5">
|
|
372
|
+
{access === "owner" || !onCollaboratorAccessChange ? (
|
|
373
|
+
<Badge variant="secondary" className="shrink-0 font-normal">
|
|
374
|
+
<i
|
|
375
|
+
className={cn("fa-light", COLLABORATOR_ACCESS_ICON_LIGHT[access])}
|
|
376
|
+
aria-hidden="true"
|
|
377
|
+
/>
|
|
378
|
+
{collaboratorAccessLabel(person.access)}
|
|
379
|
+
</Badge>
|
|
380
|
+
) : (
|
|
381
|
+
<CollaboratorAccessChipSelector
|
|
382
|
+
value={access}
|
|
383
|
+
onValueChange={value =>
|
|
384
|
+
onCollaboratorAccessChange(person.id, value as CollaboratorAccessRole)}
|
|
385
|
+
options={ROSTER_COLLABORATOR_ACCESS_OPTIONS}
|
|
386
|
+
ariaLabel={`Access for ${person.name}`}
|
|
387
|
+
triggerId={`collaborator-access-${person.id}`}
|
|
388
|
+
isOptionDisabled={value =>
|
|
389
|
+
value !== access
|
|
390
|
+
&& !canSetCollaboratorAccessRole(person, collaborators, value as CollaboratorAccessRole)}
|
|
391
|
+
/>
|
|
392
|
+
)}
|
|
393
|
+
{onCollaboratorRemove ? (
|
|
394
|
+
<Tip
|
|
395
|
+
side="bottom"
|
|
396
|
+
label={removeBlocked ?? "Remove access"}
|
|
397
|
+
>
|
|
398
|
+
<Button
|
|
399
|
+
type="button"
|
|
400
|
+
variant="ghost"
|
|
401
|
+
size="icon-sm"
|
|
402
|
+
className="shrink-0"
|
|
403
|
+
aria-label={`Remove access for ${person.name}`}
|
|
404
|
+
disabled={!canRemove}
|
|
405
|
+
onClick={() => setRemoveTarget(person)}
|
|
406
|
+
>
|
|
407
|
+
<i className="fa-light fa-xmark" aria-hidden="true" />
|
|
408
|
+
</Button>
|
|
409
|
+
</Tip>
|
|
410
|
+
) : null}
|
|
411
|
+
</div>
|
|
412
|
+
</li>
|
|
413
|
+
)
|
|
414
|
+
})}
|
|
415
|
+
</ul>
|
|
416
|
+
</section>
|
|
417
|
+
</div>
|
|
418
|
+
|
|
419
|
+
<Shortcut keys="Enter" disabled={isSubmitting} onInvoke={() => form.handleSubmit(onSubmit)()} />
|
|
420
|
+
|
|
421
|
+
</SheetContent>
|
|
422
|
+
|
|
423
|
+
<Dialog open={removeTarget != null} onOpenChange={open => { if (!open) setRemoveTarget(null) }}>
|
|
424
|
+
<DialogContent className="max-w-sm">
|
|
425
|
+
<DialogHeader>
|
|
426
|
+
<DialogTitle>Remove access</DialogTitle>
|
|
427
|
+
<DialogDescription>
|
|
428
|
+
{removeTarget
|
|
429
|
+
? `${removeTarget.name} will lose access to ${resourceLabel}.`
|
|
430
|
+
: null}
|
|
431
|
+
</DialogDescription>
|
|
432
|
+
</DialogHeader>
|
|
433
|
+
<DialogFooter className="gap-2 sm:gap-2">
|
|
434
|
+
<Button type="button" variant="outline" onClick={() => setRemoveTarget(null)}>
|
|
435
|
+
Cancel
|
|
436
|
+
<KbdGroup className="ml-1.5"><Kbd variant="bare">Esc</Kbd></KbdGroup>
|
|
437
|
+
</Button>
|
|
438
|
+
<Button
|
|
439
|
+
type="button"
|
|
440
|
+
variant="destructive"
|
|
441
|
+
onClick={confirmRemove}
|
|
442
|
+
disabled={!removeTarget || !canRemoveCollaboratorFromRoster(removeTarget, collaborators)}
|
|
443
|
+
>
|
|
444
|
+
Remove
|
|
445
|
+
<KbdGroup className="ml-1.5"><Kbd variant="bare">⏎</Kbd></KbdGroup>
|
|
446
|
+
</Button>
|
|
447
|
+
</DialogFooter>
|
|
448
|
+
</DialogContent>
|
|
449
|
+
</Dialog>
|
|
450
|
+
<Shortcut keys="Enter" disabled={!removeTarget} onInvoke={confirmRemove} />
|
|
451
|
+
</Sheet>
|
|
452
|
+
)
|
|
453
|
+
}
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* AA checklist:
|
|
11
11
|
* ✓ Trend text never relies on colour alone — icon + label (WCAG 1.4.1)
|
|
12
|
-
* ✓
|
|
12
|
+
* ✓ `trend` matches signed change; `trendPolarity` flips sentiment when “up” is bad (see `docs/kpi-trend-pattern.md`)
|
|
13
|
+
* ✓ Trend icons have aria-hidden; chip `aria-label` uses `metricTrendAriaQualifier` (1.1.1)
|
|
13
14
|
* ✓ Select has accessible label via aria-label (4.1.2)
|
|
14
15
|
* ✓ Insight action button has descriptive text (4.1.2)
|
|
15
16
|
* ✓ Decorative dividers are aria-hidden (1.1.1)
|
|
@@ -66,6 +67,44 @@ function InsightAskLeoTooltip({
|
|
|
66
67
|
|
|
67
68
|
/* ── Types ────────────────────────────────────────────────────────────────── */
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Whether an **up** arrow should read as “good news” for tinting and assistive text.
|
|
72
|
+
* - **`higher_is_better`** (default) — revenue, pass rate, approved count: up = favorable.
|
|
73
|
+
* - **`lower_is_better`** — defects, overdue, **low PBI / quality flags**: more flags + up arrow = unfavorable.
|
|
74
|
+
* - **`informational`** — volume or mix only; keep arrows **muted** (direction without value judgment).
|
|
75
|
+
*/
|
|
76
|
+
export type MetricTrendPolarity = "higher_is_better" | "lower_is_better" | "informational"
|
|
77
|
+
|
|
78
|
+
export type MetricTrendTone = "positive" | "negative" | "muted"
|
|
79
|
+
|
|
80
|
+
/** Maps `trend` + polarity to semantic tone for colours (arrow direction still follows `trend`). */
|
|
81
|
+
export function metricTrendTone(
|
|
82
|
+
trend: "up" | "down" | "neutral",
|
|
83
|
+
polarity: MetricTrendPolarity = "higher_is_better",
|
|
84
|
+
): MetricTrendTone {
|
|
85
|
+
if (trend === "neutral") return "muted"
|
|
86
|
+
if (polarity === "informational") return "muted"
|
|
87
|
+
if (polarity === "higher_is_better") {
|
|
88
|
+
return trend === "up" ? "positive" : "negative"
|
|
89
|
+
}
|
|
90
|
+
return trend === "up" ? "negative" : "positive"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Short clause for `aria-label` on the trend chip (paired with the delta string). */
|
|
94
|
+
export function metricTrendAriaQualifier(
|
|
95
|
+
trend: "up" | "down" | "neutral",
|
|
96
|
+
polarity: MetricTrendPolarity = "higher_is_better",
|
|
97
|
+
): string {
|
|
98
|
+
if (trend === "neutral") return "no net change"
|
|
99
|
+
if (polarity === "informational") {
|
|
100
|
+
return trend === "up" ? "increased" : "decreased"
|
|
101
|
+
}
|
|
102
|
+
if (polarity === "higher_is_better") {
|
|
103
|
+
return trend === "up" ? "increased, favorable" : "decreased, unfavorable"
|
|
104
|
+
}
|
|
105
|
+
return trend === "up" ? "increased, unfavorable" : "decreased, favorable"
|
|
106
|
+
}
|
|
107
|
+
|
|
69
108
|
export interface MetricItem {
|
|
70
109
|
/** Unique identifier for React keying */
|
|
71
110
|
id: string
|
|
@@ -75,8 +114,13 @@ export interface MetricItem {
|
|
|
75
114
|
value: string | number
|
|
76
115
|
/** Change delta — e.g. "+5", "-3", "+12" */
|
|
77
116
|
delta: string | number
|
|
78
|
-
/** Visual
|
|
117
|
+
/** Visual trend direction (arrow follows the signed change in the underlying metric). */
|
|
79
118
|
trend: "up" | "down" | "neutral"
|
|
119
|
+
/**
|
|
120
|
+
* How to **tint** the trend chip. Omit = **`higher_is_better`** (legacy behaviour).
|
|
121
|
+
* Arrows always match `trend`; sentiment colours flip for **`lower_is_better`**.
|
|
122
|
+
*/
|
|
123
|
+
trendPolarity?: MetricTrendPolarity
|
|
80
124
|
/** Makes the cell a link */
|
|
81
125
|
href?: string
|
|
82
126
|
/** Makes the cell a button */
|
|
@@ -185,11 +229,12 @@ const DEFAULT_PERIODS: PeriodOption[] = [
|
|
|
185
229
|
/* ── Sub-components ───────────────────────────────────────────────────────── */
|
|
186
230
|
|
|
187
231
|
/** Single KPI cell inside the metrics grid */
|
|
188
|
-
function MetricCell({
|
|
232
|
+
const MetricCell = React.memo(function MetricCell({
|
|
189
233
|
label,
|
|
190
234
|
value,
|
|
191
235
|
delta,
|
|
192
236
|
trend,
|
|
237
|
+
trendPolarity = "higher_is_better",
|
|
193
238
|
href,
|
|
194
239
|
onClick,
|
|
195
240
|
metricVariant = "default",
|
|
@@ -198,6 +243,7 @@ function MetricCell({
|
|
|
198
243
|
}: Omit<MetricItem, "id"> & { dense?: boolean; edgeGutter?: boolean }) {
|
|
199
244
|
const isUp = trend === "up"
|
|
200
245
|
const isDown = trend === "down"
|
|
246
|
+
const tone = metricTrendTone(trend, trendPolarity)
|
|
201
247
|
const isInteractive = !!(href || onClick)
|
|
202
248
|
const isHero = metricVariant === "hero"
|
|
203
249
|
|
|
@@ -248,11 +294,11 @@ function MetricCell({
|
|
|
248
294
|
className={cn(
|
|
249
295
|
"inline-flex items-center gap-1 font-medium leading-none",
|
|
250
296
|
dense ? "text-xs sm:text-xs" : "text-xs sm:text-sm",
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
297
|
+
tone === "positive" && "text-chart-2",
|
|
298
|
+
tone === "negative" && "text-destructive",
|
|
299
|
+
tone === "muted" && "text-muted-foreground",
|
|
254
300
|
)}
|
|
255
|
-
aria-label={`${
|
|
301
|
+
aria-label={`${metricTrendAriaQualifier(trend, trendPolarity)} ${delta}`}
|
|
256
302
|
>
|
|
257
303
|
{isUp && <i className="fa-light fa-arrow-trend-up text-[0.8rem]" aria-hidden="true" />}
|
|
258
304
|
{isDown && <i className="fa-light fa-arrow-trend-down text-[0.8rem]" aria-hidden="true" />}
|
|
@@ -292,7 +338,7 @@ function MetricCell({
|
|
|
292
338
|
}
|
|
293
339
|
|
|
294
340
|
return <div className={sharedClass}>{inner}</div>
|
|
295
|
-
}
|
|
341
|
+
})
|
|
296
342
|
|
|
297
343
|
/** Body line for rail: `description`, else optional `statement` */
|
|
298
344
|
function insightRailBody(insight: MetricInsight): string {
|
|
@@ -33,7 +33,7 @@ import { z } from "zod"
|
|
|
33
33
|
|
|
34
34
|
import { cn } from "@/lib/utils"
|
|
35
35
|
import { devLog } from "@/lib/dev-log"
|
|
36
|
-
import {
|
|
36
|
+
import { formatDateFromDate } from "@/lib/date-filter"
|
|
37
37
|
|
|
38
38
|
import {
|
|
39
39
|
Form,
|
|
@@ -883,8 +883,8 @@ function Step5({
|
|
|
883
883
|
</ReviewSection>
|
|
884
884
|
|
|
885
885
|
<ReviewSection title="Schedule" icon="fa-calendar-days" onEdit={() => goToStep(3)}>
|
|
886
|
-
<ReviewRow label="Start Date" value={data.startDate ?
|
|
887
|
-
<ReviewRow label="End Date" value={data.endDate ?
|
|
886
|
+
<ReviewRow label="Start Date" value={data.startDate ? formatDateFromDate(data.startDate) : undefined} />
|
|
887
|
+
<ReviewRow label="End Date" value={data.endDate ? formatDateFromDate(data.endDate) : undefined} />
|
|
888
888
|
<ReviewRow label="Duration" value={data.duration} />
|
|
889
889
|
<ReviewRow label="Hours / Week" value={data.hoursPerWeek ? `${data.hoursPerWeek} hrs` : undefined} />
|
|
890
890
|
<ReviewRow label="Shift" value={data.shift} />
|