@xenide-io/the-old-ui-theme 0.9.18 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{Chip-BzuNgJIa.d.mts → Chip-Dw2g2t8D.d.mts} +18 -4
- package/dist/{Chip-BzuNgJIa.d.ts → Chip-Dw2g2t8D.d.ts} +18 -4
- package/dist/{chunk-6SVJAU5K.mjs → chunk-RIPGN6FG.mjs} +1333 -526
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +490 -119
- package/dist/index.mjs +11 -3
- package/dist/suite-analytics--Mf1RZbw.d.mts +29 -0
- package/dist/suite-analytics--Mf1RZbw.d.ts +29 -0
- package/dist/suite.d.mts +36 -4
- package/dist/suite.d.ts +36 -4
- package/dist/suite.js +4617 -4001
- package/dist/suite.mjs +1456 -868
- package/dist/tailwind-preset.js +10 -1
- package/dist/tailwind-preset.mjs +10 -1
- package/dist/ui.d.mts +33 -4
- package/dist/ui.d.ts +33 -4
- package/dist/ui.js +1364 -571
- package/dist/ui.mjs +15 -1
- package/docs/macos-identity-audit.md +279 -0
- package/package.json +1 -1
- package/src/app/globals.css +390 -44
- package/src/components/ui/Accordion.tsx +4 -7
- package/src/components/ui/Avatar.tsx +5 -5
- package/src/components/ui/BrandSpinner.tsx +256 -0
- package/src/components/ui/Calendar.tsx +7 -7
- package/src/components/ui/CodeBlock.tsx +14 -11
- package/src/components/ui/CollapsibleSection.tsx +4 -4
- package/src/components/ui/CommandPalette.tsx +7 -10
- package/src/components/ui/EmptyState.tsx +2 -2
- package/src/components/ui/Input.tsx +1 -1
- package/src/components/ui/Marketing.tsx +144 -0
- package/src/components/ui/MarketingIllustrations.tsx +287 -0
- package/src/components/ui/Panel.tsx +1 -1
- package/src/components/ui/SegmentedControl.tsx +59 -13
- package/src/components/ui/SettingsLayout.tsx +4 -0
- package/src/components/ui/ShowcaseWrapper.tsx +3 -3
- package/src/components/ui/Table.tsx +45 -10
- package/src/components/ui/Typography.tsx +4 -4
- package/src/components/ui/avatar-stat.test.tsx +14 -1
- package/src/components/ui/brand-spinner.test.tsx +62 -0
- package/src/components/ui/index.ts +19 -11
- package/src/components/ui/interaction-primitives.test.tsx +12 -6
- package/src/components/ui/marketing.test.tsx +39 -0
- package/src/components/ui/table.test.tsx +47 -0
- package/src/styles/suite-skin.css +471 -74
- package/src/styles/themes.css +45 -45
- package/src/styles/typography.css +25 -1
- package/src/suite/components/ai-draft-editor.tsx +184 -0
- package/src/suite/components/ai-panel-workflows.test.tsx +179 -0
- package/src/suite/components/ai-panel.tsx +201 -31
- package/src/suite/components/app-switcher.tsx +22 -9
- package/src/suite/components/command-palette-host.tsx +20 -2
- package/src/suite/components/suite-app-layout.tsx +7 -4
- package/src/suite/components/suite-bottom-nav.tsx +1 -1
- package/src/suite/components/suite-clients-projects-directory.tsx +6 -6
- package/src/suite/components/suite-integration-picker.tsx +7 -7
- package/src/suite/components/suite-integration-rules.tsx +5 -5
- package/src/suite/components/suite-layout.test.tsx +32 -3
- package/src/suite/components/suite-layout.tsx +4 -4
- package/src/suite/components/suite-mobile-drawer.tsx +2 -2
- package/src/suite/components/suite-mobile-header.tsx +1 -1
- package/src/suite/components/suite-notification-bell.tsx +27 -6
- package/src/suite/components/suite-notification-settings.tsx +182 -0
- package/src/suite/components/suite-palette-library.test.tsx +46 -0
- package/src/suite/components/suite-palette-library.tsx +82 -15
- package/src/suite/components/suite-settings-layout.tsx +2 -2
- package/src/suite/components/suite-settings-mobile-nav.tsx +10 -10
- package/src/suite/components/suite-sidebar.test.tsx +39 -0
- package/src/suite/components/suite-sidebar.tsx +41 -19
- package/src/suite/components/suite-skeleton.tsx +6 -6
- package/src/suite/components/suite-user-menu.tsx +2 -2
- package/src/suite/components/theme-provider.tsx +2 -0
- package/src/suite/components/today-calibrating.tsx +3 -3
- package/src/suite/components/today-ui.tsx +42 -37
- package/src/suite/components/workspace-switcher.tsx +3 -3
- package/src/suite/index.ts +14 -0
- package/src/suite/lib/suite-notifications.tsx +5 -0
- package/tailwind-preset.ts +9 -0
|
@@ -117,7 +117,7 @@ function DirectoryIconPicker({
|
|
|
117
117
|
return (
|
|
118
118
|
<div className="space-y-2">
|
|
119
119
|
<span className="mb-1.5 block text-sm font-medium text-ph-ink">Icon</span>
|
|
120
|
-
<div className="rounded-
|
|
120
|
+
<div className="rounded-[var(--ph-radius-surface)] border border-ph-border bg-[var(--ph-surface-inset)] p-3">
|
|
121
121
|
<div className="flex items-center gap-3">
|
|
122
122
|
<SuiteEntityIcon
|
|
123
123
|
imageUrl={preview}
|
|
@@ -178,7 +178,7 @@ function DirectoryIcon({
|
|
|
178
178
|
color={item.color}
|
|
179
179
|
tinted
|
|
180
180
|
className="h-8 w-8 text-sm"
|
|
181
|
-
roundedClass="rounded-
|
|
181
|
+
roundedClass="rounded-[var(--ph-radius-control)]"
|
|
182
182
|
/>
|
|
183
183
|
);
|
|
184
184
|
}
|
|
@@ -458,7 +458,7 @@ export function SuiteClientsProjectsDirectory({
|
|
|
458
458
|
{loading ? (
|
|
459
459
|
<p className="text-sm text-ph-mutedtext">Loading directory...</p>
|
|
460
460
|
) : (
|
|
461
|
-
<div className="overflow-hidden rounded-
|
|
461
|
+
<div className="overflow-hidden rounded-[var(--ph-radius-surface)] border border-ph-border bg-ph-surface lg:grid lg:min-h-[22rem] lg:grid-cols-[minmax(14rem,18rem)_1fr]">
|
|
462
462
|
<section
|
|
463
463
|
className={cn(
|
|
464
464
|
"border-ph-border lg:border-r",
|
|
@@ -515,7 +515,7 @@ export function SuiteClientsProjectsDirectory({
|
|
|
515
515
|
clientButtonRefs.current[client.id] = element;
|
|
516
516
|
}}
|
|
517
517
|
className={cn(
|
|
518
|
-
"flex w-full items-center gap-2.5 rounded-
|
|
518
|
+
"flex w-full items-center gap-2.5 rounded-[var(--ph-radius-control)] px-2.5 py-2 text-left",
|
|
519
519
|
selected ? "bg-ph-muted" : "hover:bg-ph-canvas",
|
|
520
520
|
)}
|
|
521
521
|
>
|
|
@@ -627,12 +627,12 @@ export function SuiteClientsProjectsDirectory({
|
|
|
627
627
|
selectedProjects.map((project) => (
|
|
628
628
|
<li key={project.id}>
|
|
629
629
|
<div
|
|
630
|
-
className="flex items-center gap-3 rounded-
|
|
630
|
+
className="flex items-center gap-3 rounded-[var(--ph-radius-control)] px-2 py-1 hover:bg-ph-canvas"
|
|
631
631
|
onClick={() => openEdit(project, "project")}
|
|
632
632
|
>
|
|
633
633
|
<button
|
|
634
634
|
type="button"
|
|
635
|
-
className="flex min-w-0 flex-1 items-center gap-3 rounded-
|
|
635
|
+
className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--ph-radius-control)] py-1 text-left focus-visible:outline-none focus-visible:shadow-[var(--ph-focus-halo)]"
|
|
636
636
|
onClick={() => openEdit(project, "project")}
|
|
637
637
|
>
|
|
638
638
|
<DirectoryIcon item={project} />
|
|
@@ -91,7 +91,7 @@ const DESCRIPTION_LIMIT = 90;
|
|
|
91
91
|
function IntegrationSkeletonCard() {
|
|
92
92
|
return (
|
|
93
93
|
<div
|
|
94
|
-
className="flex min-h-36 min-w-0 animate-pulse flex-col justify-between rounded-
|
|
94
|
+
className="flex min-h-36 min-w-0 animate-pulse flex-col justify-between rounded-[var(--ph-radius-surface)] border border-ph-border bg-ph-surface p-4"
|
|
95
95
|
aria-hidden="true"
|
|
96
96
|
>
|
|
97
97
|
<div className="flex items-start gap-3">
|
|
@@ -608,7 +608,7 @@ export function SuiteIntegrationPicker({
|
|
|
608
608
|
</button>
|
|
609
609
|
{categoryMenuOpen ? (
|
|
610
610
|
<div
|
|
611
|
-
className="absolute z-20 mt-2 max-h-[min(22rem,calc(100dvh-12rem))] w-full max-w-full overflow-hidden rounded-
|
|
611
|
+
className="absolute z-20 mt-2 max-h-[min(22rem,calc(100dvh-12rem))] w-full max-w-full overflow-hidden rounded-[var(--ph-radius-overlay)] border border-[var(--ph-border-control)] bg-[var(--ph-surface-overlay)] p-2 shadow-[var(--ph-shadow-floating)]"
|
|
612
612
|
>
|
|
613
613
|
<Input
|
|
614
614
|
ref={categorySearchRef}
|
|
@@ -643,7 +643,7 @@ export function SuiteIntegrationPicker({
|
|
|
643
643
|
ref={(element) => {
|
|
644
644
|
categoryOptionRefs.current[value] = element;
|
|
645
645
|
}}
|
|
646
|
-
className={`flex w-full min-w-0 items-center rounded-
|
|
646
|
+
className={`flex w-full min-w-0 items-center rounded-[var(--ph-radius-control)] px-3 py-2 text-left text-sm break-words focus-visible:outline-none focus-visible:shadow-[var(--ph-focus-halo)] ${activeCategory === value ? "bg-[var(--ph-selected-bg)] text-[var(--ph-selected-text)] shadow-[inset_0_0_0_1px_var(--ph-selected-border)]" : "text-ph-ink hover:bg-[var(--ph-surface-hover)]"}`}
|
|
647
647
|
onClick={() => selectCategory(value)}
|
|
648
648
|
onKeyDown={(event) => {
|
|
649
649
|
if (event.key === "Escape") {
|
|
@@ -692,7 +692,7 @@ export function SuiteIntegrationPicker({
|
|
|
692
692
|
return (
|
|
693
693
|
<div
|
|
694
694
|
key={item.id}
|
|
695
|
-
className="flex min-h-36 min-w-0 flex-col justify-between rounded-
|
|
695
|
+
className="flex min-h-36 min-w-0 flex-col justify-between rounded-[var(--ph-radius-surface)] border border-ph-border bg-ph-surface p-4"
|
|
696
696
|
>
|
|
697
697
|
<button
|
|
698
698
|
type="button"
|
|
@@ -701,7 +701,7 @@ export function SuiteIntegrationPicker({
|
|
|
701
701
|
longDescription &&
|
|
702
702
|
setExpanded(isExpanded ? null : item.id)
|
|
703
703
|
}
|
|
704
|
-
aria-expanded={isExpanded}
|
|
704
|
+
aria-expanded={longDescription ? isExpanded : undefined}
|
|
705
705
|
>
|
|
706
706
|
<span className="flex items-start gap-3">
|
|
707
707
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-ph-muted text-ph-ink">
|
|
@@ -801,7 +801,7 @@ export function SuiteIntegrationPicker({
|
|
|
801
801
|
{connectedItems.map((item) => (
|
|
802
802
|
<div
|
|
803
803
|
key={item.id}
|
|
804
|
-
className="flex items-center justify-between gap-3 rounded-
|
|
804
|
+
className="flex items-center justify-between gap-3 rounded-[var(--ph-radius-surface)] border border-ph-border bg-[var(--ph-surface-inset)] p-3"
|
|
805
805
|
>
|
|
806
806
|
<span className="flex min-w-0 items-center gap-2 text-sm text-ph-ink">
|
|
807
807
|
{item.icon}
|
|
@@ -885,7 +885,7 @@ export function SuiteIntegrationPicker({
|
|
|
885
885
|
<button
|
|
886
886
|
key={scheme}
|
|
887
887
|
type="button"
|
|
888
|
-
className="flex w-full items-center justify-between rounded-
|
|
888
|
+
className="flex w-full items-center justify-between rounded-[var(--ph-radius-control)] border border-ph-border bg-ph-surface px-3 py-3 text-left text-sm text-ph-ink transition-[background-color,border-color,box-shadow] hover:border-[var(--ph-selected-border)] hover:bg-[var(--ph-surface-hover)] focus-visible:outline-none focus-visible:shadow-[var(--ph-focus-halo)]"
|
|
889
889
|
onClick={() => void prepareConnection(methodItem, scheme)}
|
|
890
890
|
data-test={`${dataTest}-choose-${scheme.toLowerCase()}`}
|
|
891
891
|
>
|
|
@@ -221,7 +221,7 @@ export function SuiteIntegrationRules({
|
|
|
221
221
|
<p className="mt-4 text-sm text-ph-subtle">Loading rules...</p>
|
|
222
222
|
) : rules.length === 0 ? (
|
|
223
223
|
<div
|
|
224
|
-
className="mt-4 rounded-
|
|
224
|
+
className="mt-4 rounded-[var(--ph-radius-surface)] border border-dashed border-ph-border bg-[var(--ph-surface-inset)] px-4 py-6 text-center"
|
|
225
225
|
data-test={`${dataTest}-empty`}
|
|
226
226
|
>
|
|
227
227
|
<p className="text-sm text-ph-subtle">
|
|
@@ -234,7 +234,7 @@ export function SuiteIntegrationRules({
|
|
|
234
234
|
{rules.map((rule) => (
|
|
235
235
|
<li
|
|
236
236
|
key={rule.id}
|
|
237
|
-
className="flex items-center justify-between gap-3 rounded-
|
|
237
|
+
className="flex items-center justify-between gap-3 rounded-[var(--ph-radius-surface)] border border-ph-border bg-[var(--ph-surface-inset)] px-3 py-3"
|
|
238
238
|
data-test={`${dataTest}-rule-${rule.id}`}
|
|
239
239
|
>
|
|
240
240
|
<div className="min-w-0">
|
|
@@ -282,7 +282,7 @@ export function SuiteIntegrationRules({
|
|
|
282
282
|
|
|
283
283
|
{canManage && formOpen && onCreate ? (
|
|
284
284
|
<form
|
|
285
|
-
className="mt-4 space-y-3 rounded-
|
|
285
|
+
className="mt-4 space-y-3 rounded-[var(--ph-radius-surface)] border border-ph-border bg-ph-surface p-4"
|
|
286
286
|
onSubmit={(event) => void submitForm(event)}
|
|
287
287
|
data-test={`${dataTest}-form`}
|
|
288
288
|
>
|
|
@@ -435,7 +435,7 @@ export function SuiteIntegrationRules({
|
|
|
435
435
|
</p>
|
|
436
436
|
{events.length === 0 ? (
|
|
437
437
|
<p
|
|
438
|
-
className="mt-4 rounded-
|
|
438
|
+
className="mt-4 rounded-[var(--ph-radius-surface)] border border-dashed border-ph-border bg-[var(--ph-surface-inset)] px-4 py-6 text-center text-sm text-ph-subtle"
|
|
439
439
|
data-test={`${dataTest}-events-empty`}
|
|
440
440
|
>
|
|
441
441
|
No integration events yet.
|
|
@@ -445,7 +445,7 @@ export function SuiteIntegrationRules({
|
|
|
445
445
|
{events.map((event) => (
|
|
446
446
|
<li
|
|
447
447
|
key={event.id}
|
|
448
|
-
className="flex items-center justify-between gap-3 rounded-
|
|
448
|
+
className="flex items-center justify-between gap-3 rounded-[var(--ph-radius-surface)] border border-ph-border bg-[var(--ph-surface-inset)] px-3 py-2"
|
|
449
449
|
>
|
|
450
450
|
<div className="min-w-0">
|
|
451
451
|
<p className="truncate text-sm text-ph-ink">
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { cleanup, render, screen, within } from "@testing-library/react";
|
|
2
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
SIDEBAR_MAX_WIDTH,
|
|
6
|
+
SIDEBAR_RAIL_WIDTH,
|
|
7
|
+
sidebarColumnWidth,
|
|
8
|
+
} from "../lib/use-sidebar-width";
|
|
5
9
|
import { SuiteAppLayout } from "./suite-app-layout";
|
|
6
10
|
import { SuitePageHeader } from "./suite-layout";
|
|
7
11
|
|
|
@@ -36,6 +40,31 @@ describe("sidebar resize handle", () => {
|
|
|
36
40
|
expect(handle?.previousElementSibling).toHaveClass("isolate");
|
|
37
41
|
expect(handle).toHaveClass("z-20");
|
|
38
42
|
});
|
|
43
|
+
|
|
44
|
+
it("supports range-style keyboard minimum, maximum, and steps", () => {
|
|
45
|
+
const onResizeBy = vi.fn();
|
|
46
|
+
render(
|
|
47
|
+
<SuiteAppLayout
|
|
48
|
+
sidebarWidth={240}
|
|
49
|
+
onStartResize={() => {}}
|
|
50
|
+
onResizeBy={onResizeBy}
|
|
51
|
+
sidebar={<div>Sidebar</div>}
|
|
52
|
+
>
|
|
53
|
+
Page
|
|
54
|
+
</SuiteAppLayout>,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const handle = screen.getByRole("separator", { name: "Resize sidebar" });
|
|
58
|
+
fireEvent.keyDown(handle, { key: "Home" });
|
|
59
|
+
fireEvent.keyDown(handle, { key: "End" });
|
|
60
|
+
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
|
61
|
+
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
|
62
|
+
|
|
63
|
+
expect(onResizeBy).toHaveBeenNthCalledWith(1, SIDEBAR_RAIL_WIDTH - 240);
|
|
64
|
+
expect(onResizeBy).toHaveBeenNthCalledWith(2, SIDEBAR_MAX_WIDTH - 240);
|
|
65
|
+
expect(onResizeBy).toHaveBeenNthCalledWith(3, -16);
|
|
66
|
+
expect(onResizeBy).toHaveBeenNthCalledWith(4, 16);
|
|
67
|
+
});
|
|
39
68
|
});
|
|
40
69
|
|
|
41
70
|
describe("suite scroll ownership", () => {
|
|
@@ -192,7 +192,7 @@ export function SuitePageHeader({
|
|
|
192
192
|
data-test={dataTest}
|
|
193
193
|
data-stuck={sticky ? (stuck ? 'true' : 'false') : undefined}
|
|
194
194
|
className={cn(
|
|
195
|
-
'flex min-w-0 shrink-0 flex-col items-start gap-3 pb-2 sm:flex-row sm:justify-between sm:gap-4 sm:pb-3',
|
|
195
|
+
'suite-page-header flex min-w-0 shrink-0 flex-col items-start gap-3 pb-2 sm:flex-row sm:justify-between sm:gap-4 sm:pb-3',
|
|
196
196
|
// No extra pt here — page inset sets the shared title baseline.
|
|
197
197
|
sticky && 'suite-page-header-sticky',
|
|
198
198
|
className,
|
|
@@ -213,7 +213,7 @@ export function SuitePageHeader({
|
|
|
213
213
|
<div className="flex min-w-0 items-center gap-3">
|
|
214
214
|
{resolvedIcon ? (
|
|
215
215
|
<div
|
|
216
|
-
className="hidden shrink-0 sm:flex sm:items-center [&_svg]:h-6 [&_svg]:w-6"
|
|
216
|
+
className="suite-page-header__icon hidden shrink-0 sm:flex sm:items-center [&_svg]:h-6 [&_svg]:w-6"
|
|
217
217
|
aria-hidden="true"
|
|
218
218
|
>
|
|
219
219
|
{resolvedIcon}
|
|
@@ -222,14 +222,14 @@ export function SuitePageHeader({
|
|
|
222
222
|
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
223
223
|
<h1
|
|
224
224
|
className={cn(
|
|
225
|
-
'min-w-0 break-words font-display
|
|
225
|
+
'ph-page-title min-w-0 break-words font-display font-semibold text-ph-ink text-balance',
|
|
226
226
|
titleClassName,
|
|
227
227
|
)}
|
|
228
228
|
>
|
|
229
229
|
{title}
|
|
230
230
|
</h1>
|
|
231
231
|
{badge ? (
|
|
232
|
-
<span className="rounded-
|
|
232
|
+
<span className="rounded-[var(--ph-radius-control)] bg-ph-muted px-2 py-0.5 text-[length:var(--ph-type-overline)] font-semibold uppercase tracking-wide text-ph-subtle">
|
|
233
233
|
{badge}
|
|
234
234
|
</span>
|
|
235
235
|
) : null}
|
|
@@ -98,7 +98,7 @@ export function SuiteMobileDrawer({
|
|
|
98
98
|
data-test={panelDataTest}
|
|
99
99
|
aria-modal="true"
|
|
100
100
|
className={cn(
|
|
101
|
-
"fixed inset-y-0 left-0 z-50 flex h-dvh max-h-dvh w-[86%] max-w-72 flex-col overflow-hidden bg-ph-surface shadow-
|
|
101
|
+
"fixed inset-y-0 left-0 z-50 flex h-dvh max-h-dvh w-[86%] max-w-72 flex-col overflow-hidden border-r border-ph-border bg-ph-surface shadow-[var(--ph-shadow-floating)] outline-none motion-safe:transition-transform motion-safe:ease-spring-fast lg:hidden",
|
|
102
102
|
side === "right" && "left-auto right-0",
|
|
103
103
|
shown
|
|
104
104
|
? "translate-x-0"
|
|
@@ -127,7 +127,7 @@ export function SuiteMobileDrawer({
|
|
|
127
127
|
type="button"
|
|
128
128
|
id={closeButtonId}
|
|
129
129
|
data-test={closeButtonDataTest}
|
|
130
|
-
className="-mr-1.5
|
|
130
|
+
className="ph-btn ph-btn-ghost ph-btn-square -mr-1.5 h-11 w-11 text-ph-mutedtext"
|
|
131
131
|
aria-label={closeLabel}
|
|
132
132
|
>
|
|
133
133
|
<X className="h-4 w-4" aria-hidden />
|
|
@@ -54,7 +54,7 @@ export function SuiteMobileHeader({
|
|
|
54
54
|
type="button"
|
|
55
55
|
id={menuId}
|
|
56
56
|
data-test={menuDataTest}
|
|
57
|
-
className="suite-press
|
|
57
|
+
className="ph-btn ph-btn-ghost ph-btn-square suite-press h-11 w-11 shrink-0 text-ph-ink"
|
|
58
58
|
onClick={onMenuClick}
|
|
59
59
|
aria-label={menuLabel}
|
|
60
60
|
>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { Bell } from "iconoir-react";
|
|
5
5
|
|
|
6
|
+
import { trackSuiteEvent } from "../../lib/suite-analytics";
|
|
6
7
|
import type { SuiteDropdownMenuComponent } from "../lib/injected";
|
|
7
8
|
import { useSuiteNotifications } from "../lib/suite-notifications";
|
|
8
9
|
|
|
@@ -50,7 +51,14 @@ export function SuiteNotificationBell({
|
|
|
50
51
|
|
|
51
52
|
const onOpenChange = (next: boolean) => {
|
|
52
53
|
setOpen(next);
|
|
53
|
-
if (next)
|
|
54
|
+
if (next) {
|
|
55
|
+
trackSuiteEvent("UI Opened", {
|
|
56
|
+
view: "notification_bell",
|
|
57
|
+
kind: "notifications",
|
|
58
|
+
unread_count: unread,
|
|
59
|
+
});
|
|
60
|
+
refresh();
|
|
61
|
+
}
|
|
54
62
|
};
|
|
55
63
|
|
|
56
64
|
return (
|
|
@@ -65,7 +73,7 @@ export function SuiteNotificationBell({
|
|
|
65
73
|
open={open}
|
|
66
74
|
onOpenChange={onOpenChange}
|
|
67
75
|
trigger={
|
|
68
|
-
<span className="relative inline-flex h-11 w-11 items-center justify-center overflow-visible rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink">
|
|
76
|
+
<span className="relative inline-flex h-11 w-11 items-center justify-center overflow-visible rounded-full text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink">
|
|
69
77
|
<span className="relative inline-flex h-5 w-5">
|
|
70
78
|
<Bell className="h-5 w-5" strokeWidth={1.75} aria-hidden />
|
|
71
79
|
{unread > 0 ? (
|
|
@@ -84,8 +92,14 @@ export function SuiteNotificationBell({
|
|
|
84
92
|
type="button"
|
|
85
93
|
id={`${dataTest}-mark-all`}
|
|
86
94
|
data-test={`${dataTest}-mark-all`}
|
|
87
|
-
onClick={
|
|
88
|
-
|
|
95
|
+
onClick={() => {
|
|
96
|
+
trackSuiteEvent("Notification Read", {
|
|
97
|
+
operation: "all",
|
|
98
|
+
count: unread,
|
|
99
|
+
});
|
|
100
|
+
markAllRead();
|
|
101
|
+
}}
|
|
102
|
+
className="ph-btn ph-btn-link ph-btn-xs text-xs"
|
|
89
103
|
>
|
|
90
104
|
Mark all read
|
|
91
105
|
</button>
|
|
@@ -104,9 +118,16 @@ export function SuiteNotificationBell({
|
|
|
104
118
|
data-test={`${dataTest}-item-${n.id}`}
|
|
105
119
|
onClick={() => {
|
|
106
120
|
setOpen(false);
|
|
121
|
+
trackSuiteEvent("Notification Read", {
|
|
122
|
+
operation: "read",
|
|
123
|
+
notification_id: n.id,
|
|
124
|
+
kind: n.kind,
|
|
125
|
+
source_app: n.source_app,
|
|
126
|
+
unread: !n.read_at,
|
|
127
|
+
});
|
|
107
128
|
select(n);
|
|
108
129
|
}}
|
|
109
|
-
className="flex w-full flex-col gap-0.5 px-3 py-2 text-left
|
|
130
|
+
className="ph-dropdown-item flex w-full flex-col items-stretch gap-0.5 px-3 py-2 text-left"
|
|
110
131
|
>
|
|
111
132
|
<span className="flex items-center gap-2">
|
|
112
133
|
{!n.read_at ? (
|
|
@@ -124,7 +145,7 @@ export function SuiteNotificationBell({
|
|
|
124
145
|
{n.body}
|
|
125
146
|
</span>
|
|
126
147
|
) : null}
|
|
127
|
-
<span className="text-
|
|
148
|
+
<span className="text-xs text-ph-subtle">
|
|
128
149
|
{n.source_app} · {timeAgo(n.created_at)}
|
|
129
150
|
</span>
|
|
130
151
|
</button>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { Alert, Badge, Button, Card } from "@/components/ui";
|
|
5
|
+
import { trackSuiteEvent } from "../../lib/suite-analytics";
|
|
6
|
+
|
|
7
|
+
export type SuiteWebPushState =
|
|
8
|
+
| "unsupported"
|
|
9
|
+
| "unconfigured"
|
|
10
|
+
| "default"
|
|
11
|
+
| "granted"
|
|
12
|
+
| "denied"
|
|
13
|
+
| "subscribed";
|
|
14
|
+
|
|
15
|
+
/** Structural contract matching `createWebPushClient` from @xenide-io/suite-client. */
|
|
16
|
+
export interface SuiteWebPushController {
|
|
17
|
+
supported(): boolean;
|
|
18
|
+
getState(): Promise<SuiteWebPushState>;
|
|
19
|
+
enable(): Promise<void>;
|
|
20
|
+
disable(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SuiteNotificationSettingsProps {
|
|
24
|
+
controller: SuiteWebPushController;
|
|
25
|
+
title?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
dataTest?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STATUS_COPY: Record<SuiteWebPushState, string> = {
|
|
32
|
+
unsupported: "This browser does not support browser notifications.",
|
|
33
|
+
unconfigured: "Browser notifications are not configured on the server yet.",
|
|
34
|
+
default: "Get notified on this device when a suite notification arrives.",
|
|
35
|
+
granted: "Permission granted — turn on browser notifications for this device.",
|
|
36
|
+
denied:
|
|
37
|
+
"Notifications are blocked for this site. Allow them in your browser's site settings, then try again.",
|
|
38
|
+
subscribed: "This device receives browser notifications.",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Settings card that lets a user enable or disable browser notifications for
|
|
43
|
+
* this device. State and delivery live in the app's suite-client web push
|
|
44
|
+
* controller; this component is presentational.
|
|
45
|
+
*/
|
|
46
|
+
export function SuiteNotificationSettings({
|
|
47
|
+
controller,
|
|
48
|
+
title = "Notifications",
|
|
49
|
+
description = "Get suite notifications — tasks, documents and AI runs — as OS notifications on this device, even when the app is closed.",
|
|
50
|
+
dataTest = "suite-notification-settings",
|
|
51
|
+
className,
|
|
52
|
+
}: SuiteNotificationSettingsProps) {
|
|
53
|
+
const [state, setState] = useState<SuiteWebPushState | null>(null);
|
|
54
|
+
const [busy, setBusy] = useState(false);
|
|
55
|
+
const [error, setError] = useState("");
|
|
56
|
+
|
|
57
|
+
const refresh = useCallback(async () => {
|
|
58
|
+
setState(await controller.getState());
|
|
59
|
+
}, [controller]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
let active = true;
|
|
63
|
+
controller.getState().then((next) => {
|
|
64
|
+
if (active) setState(next);
|
|
65
|
+
});
|
|
66
|
+
return () => {
|
|
67
|
+
active = false;
|
|
68
|
+
};
|
|
69
|
+
}, [controller]);
|
|
70
|
+
|
|
71
|
+
const handleEnable = async () => {
|
|
72
|
+
setBusy(true);
|
|
73
|
+
setError("");
|
|
74
|
+
try {
|
|
75
|
+
await controller.enable();
|
|
76
|
+
await refresh();
|
|
77
|
+
trackSuiteEvent("Push Notifications Configured", {
|
|
78
|
+
enabled: true,
|
|
79
|
+
permission:
|
|
80
|
+
typeof Notification !== "undefined" ? Notification.permission : "unknown",
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
setError(
|
|
84
|
+
err instanceof Error ? err.message : "Could not enable notifications.",
|
|
85
|
+
);
|
|
86
|
+
await refresh();
|
|
87
|
+
} finally {
|
|
88
|
+
setBusy(false);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleDisable = async () => {
|
|
93
|
+
setBusy(true);
|
|
94
|
+
setError("");
|
|
95
|
+
try {
|
|
96
|
+
await controller.disable();
|
|
97
|
+
await refresh();
|
|
98
|
+
trackSuiteEvent("Push Notifications Configured", {
|
|
99
|
+
enabled: false,
|
|
100
|
+
permission:
|
|
101
|
+
typeof Notification !== "undefined" ? Notification.permission : "unknown",
|
|
102
|
+
});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
setError(
|
|
105
|
+
err instanceof Error ? err.message : "Could not disable notifications.",
|
|
106
|
+
);
|
|
107
|
+
} finally {
|
|
108
|
+
setBusy(false);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const subscribed = state === "subscribed";
|
|
113
|
+
const canEnable = state === "default" || state === "granted";
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className={className}>
|
|
117
|
+
<div>
|
|
118
|
+
<h2 className="text-xl font-semibold text-ph-ink">{title}</h2>
|
|
119
|
+
<p className="mt-1 text-sm text-ph-subtle">{description}</p>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{error ? (
|
|
123
|
+
<Alert
|
|
124
|
+
id={`${dataTest}-error`}
|
|
125
|
+
data-test={`${dataTest}-error`}
|
|
126
|
+
className="mt-4"
|
|
127
|
+
status="danger"
|
|
128
|
+
title="Notifications unavailable"
|
|
129
|
+
description={error}
|
|
130
|
+
live="assertive"
|
|
131
|
+
/>
|
|
132
|
+
) : null}
|
|
133
|
+
|
|
134
|
+
<Card
|
|
135
|
+
id={`${dataTest}-card`}
|
|
136
|
+
data-test={`${dataTest}-card`}
|
|
137
|
+
variant="outlined"
|
|
138
|
+
bodyClassName="p-5"
|
|
139
|
+
className="mt-4"
|
|
140
|
+
>
|
|
141
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
142
|
+
<div className="flex items-center gap-2">
|
|
143
|
+
<p className="text-sm font-medium text-ph-ink">
|
|
144
|
+
Browser notifications
|
|
145
|
+
</p>
|
|
146
|
+
{subscribed ? <Badge variant="success">On</Badge> : null}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{controller.supported() && canEnable ? (
|
|
150
|
+
<Button
|
|
151
|
+
id={`${dataTest}-enable`}
|
|
152
|
+
data-test={`${dataTest}-enable`}
|
|
153
|
+
variant="primary"
|
|
154
|
+
size="sm"
|
|
155
|
+
disabled={busy}
|
|
156
|
+
onClick={handleEnable}
|
|
157
|
+
>
|
|
158
|
+
{busy ? "Enabling…" : "Enable on this device"}
|
|
159
|
+
</Button>
|
|
160
|
+
) : null}
|
|
161
|
+
|
|
162
|
+
{subscribed ? (
|
|
163
|
+
<Button
|
|
164
|
+
id={`${dataTest}-disable`}
|
|
165
|
+
data-test={`${dataTest}-disable`}
|
|
166
|
+
variant="secondary"
|
|
167
|
+
size="sm"
|
|
168
|
+
disabled={busy}
|
|
169
|
+
onClick={handleDisable}
|
|
170
|
+
>
|
|
171
|
+
{busy ? "Disabling…" : "Disable"}
|
|
172
|
+
</Button>
|
|
173
|
+
) : null}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<p className="mt-3 text-sm text-ph-subtle">
|
|
177
|
+
{state === null ? "Checking this device…" : STATUS_COPY[state]}
|
|
178
|
+
</p>
|
|
179
|
+
</Card>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { SuitePaletteLibrary } from "./suite-palette-library";
|
|
6
|
+
|
|
7
|
+
vi.mock("@/suite/lib/palette-api", () => ({
|
|
8
|
+
fetchPaletteLibrary: vi.fn().mockResolvedValue({
|
|
9
|
+
collections: [
|
|
10
|
+
{
|
|
11
|
+
slug: "office",
|
|
12
|
+
name: "Office",
|
|
13
|
+
style: "line",
|
|
14
|
+
featured: true,
|
|
15
|
+
count: 1,
|
|
16
|
+
preview: ["/office.svg"],
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
}),
|
|
20
|
+
fetchPaletteCollection: vi.fn(),
|
|
21
|
+
findPaletteIcons: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
afterEach(cleanup);
|
|
25
|
+
|
|
26
|
+
describe("SuitePaletteLibrary", () => {
|
|
27
|
+
it("moves from search into options and restores trigger focus on Escape", async () => {
|
|
28
|
+
const user = userEvent.setup();
|
|
29
|
+
render(<SuitePaletteLibrary onPick={vi.fn()} />);
|
|
30
|
+
|
|
31
|
+
const trigger = screen.getByRole("button", { name: "All libraries" });
|
|
32
|
+
await user.click(trigger);
|
|
33
|
+
await waitFor(() => expect(screen.getByText("Office")).toBeInTheDocument());
|
|
34
|
+
|
|
35
|
+
const search = screen.getByRole("searchbox", { name: "Search libraries" });
|
|
36
|
+
expect(search).toHaveFocus();
|
|
37
|
+
await user.keyboard("{ArrowDown}");
|
|
38
|
+
expect(screen.getByRole("option", { name: "All libraries" })).toHaveFocus();
|
|
39
|
+
await user.keyboard("{ArrowDown}");
|
|
40
|
+
expect(screen.getByRole("option", { name: /Office/ })).toHaveFocus();
|
|
41
|
+
await user.keyboard("{Escape}");
|
|
42
|
+
|
|
43
|
+
await waitFor(() => expect(trigger).toHaveFocus());
|
|
44
|
+
expect(screen.queryByRole("listbox", { name: "Libraries" })).not.toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
});
|