@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.196.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/README.md +822 -135
- package/bin/check-runtime.js +15 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/config/portal-route-roots.json +48 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +45587 -0
- package/dist/base-skills/notis-apps/SKILL.md +93 -0
- package/dist/base-skills/notis-apps/references/architecture.md +114 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +97 -0
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +67 -0
- package/dist/base-skills/notis-apps/references/release.md +107 -0
- package/dist/base-skills/notis-apps/references/sdk.md +116 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +24 -0
- package/dist/base-skills/notis-apps/references/views.md +142 -0
- package/dist/base-skills/notis-cli/SKILL.md +154 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +19 -0
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +41 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +60 -0
- package/dist/base-skills/notis-query/references/documents.md +63 -0
- package/dist/base-skills/notis-query/references/query.md +76 -0
- package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
- package/dist/sdk/src/agentContext.ts +36 -0
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/Dialog.tsx +46 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +106 -0
- package/dist/sdk/src/components/DocumentPage.tsx +141 -0
- package/dist/sdk/src/components/Markdown.tsx +60 -0
- package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
- package/dist/sdk/src/components/Skeleton.tsx +24 -0
- package/dist/sdk/src/config.ts +239 -0
- package/dist/sdk/src/documents.ts +258 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
- package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
- package/dist/sdk/src/hooks/useCloudComputer.ts +95 -0
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
- package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/sdk/src/hooks/useDocument.ts +43 -0
- package/dist/sdk/src/hooks/useDocuments.ts +84 -0
- package/dist/sdk/src/hooks/useHandover.ts +78 -0
- package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
- package/dist/sdk/src/hooks/useNotis.ts +42 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +67 -0
- package/dist/sdk/src/hooks/useQuery.ts +74 -0
- package/dist/sdk/src/hooks/useTool.ts +65 -0
- package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
- package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +200 -0
- package/dist/sdk/src/interactions/actions.ts +59 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +740 -0
- package/dist/sdk/src/interactions/visibility.ts +13 -0
- package/dist/sdk/src/interactions.ts +45 -0
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +49 -0
- package/dist/sdk/src/queryCache.ts +172 -0
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +603 -0
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +290 -0
- package/dist/sdk/src/tailwind.ts +66 -0
- package/dist/sdk/src/vite.ts +83 -0
- package/dist/skill-sync/index.js +2143 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +3487 -0
- package/dist/space-harness/host.js +66 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +1042 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +180 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +173 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/pool.js +76 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +201 -0
- package/package.json +25 -8
- package/skills/notis-apps/cli.md +406 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +133 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1476 -203
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +730 -0
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +86 -82
- package/src/command-specs/index.js +31 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/skills.js +304 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/spaces.js +572 -0
- package/src/command-specs/tools.js +455 -139
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +700 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +407 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +2752 -237
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +293 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/skill-sync/cloud-client.ts +121 -0
- package/src/runtime/skill-sync/index.ts +905 -0
- package/src/runtime/skill-sync/local-scanner.ts +1259 -0
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +59 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +54 -0
- package/src/runtime/skill-sync/types.ts +157 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +66 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +50 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +105 -0
- package/src/runtime/space-source.js +281 -0
- package/src/runtime/space-test-server.js +327 -0
- package/src/runtime/space-verification.js +89 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +254 -0
- package/src/runtime/store-screenshot.js +146 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +331 -45
- package/src/runtime/view-render.js +49 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/src/space-harness-host.ts +103 -0
- package/template/.harness/index.html.tmpl +435 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/globals.css +29 -3
- package/template/app/layout.tsx +6 -3
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +3642 -0
- package/template/package.json +19 -16
- package/template/postcss.config.mjs +1 -1
- package/template/tailwind.config.ts +1 -6
- package/template/tsconfig.json +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- package/template/packages/notis-sdk/src/vite.ts +0 -54
- package/template/packages/notis-sdk/tsconfig.json +0 -15
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, type CSSProperties, type ReactElement } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { DocumentEditor } from './DocumentEditor';
|
|
6
|
+
import { ShareControl } from './RecordComponents';
|
|
7
|
+
import { Skeleton } from './Skeleton';
|
|
8
|
+
import type { NotisDocumentPageProps } from '../runtime';
|
|
9
|
+
|
|
10
|
+
const copy = {
|
|
11
|
+
en: { loading: 'Loading document', breadcrumb: 'Breadcrumb' },
|
|
12
|
+
fr: { loading: 'Chargement du document', breadcrumb: "Fil d'Ariane" },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Column widths shared by the host page, its skeleton and the fallback, so nothing moves when the page arrives. */
|
|
16
|
+
export const DOCUMENT_PAGE_WIDTHS = { standard: 900, wide: 1200, full: undefined } as const;
|
|
17
|
+
/**
|
|
18
|
+
* The page's horizontal gutter: 16px, then 48px from 640px wide (the host page's px-4 sm:px-12), and its
|
|
19
|
+
* toolbar height (56px plus its 1px border, Beta's py-3 toolbar). The skeleton uses the same values.
|
|
20
|
+
*/
|
|
21
|
+
export const DOCUMENT_PAGE_GUTTER = { narrow: 16, wide: 48, breakpoint: 640, toolbar: 57 } as const;
|
|
22
|
+
|
|
23
|
+
const SKELETON = 'notis-document-page-skeleton';
|
|
24
|
+
// Inline styles cannot change with the width; this sheet (rendered with the skeleton, so it reaches a
|
|
25
|
+
// Space's ShadowRoot) gives the skeleton the page's own gutter and icon row at every width.
|
|
26
|
+
const skeletonCss = `.${SKELETON}__column{padding:8px ${DOCUMENT_PAGE_GUTTER.narrow}px 0}`
|
|
27
|
+
+ `.${SKELETON}__icon{width:44px;height:44px;margin-bottom:4px}`
|
|
28
|
+
+ `@media (min-width:${DOCUMENT_PAGE_GUTTER.breakpoint}px){.${SKELETON}__column{padding:8px ${DOCUMENT_PAGE_GUTTER.wide}px 0}`
|
|
29
|
+
+ `.${SKELETON}__icon{width:56px;height:56px}}`;
|
|
30
|
+
|
|
31
|
+
function useCopy() {
|
|
32
|
+
return copy[useNotisRuntime()?.context?.locale === 'fr' ? 'fr' : 'en'];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function columnStyle(width: NotisDocumentPageProps['width'] = 'standard'): CSSProperties {
|
|
36
|
+
const max = DOCUMENT_PAGE_WIDTHS[width];
|
|
37
|
+
return { width: '100%', maxWidth: max ? `${max}px` : undefined, margin: '0 auto', boxSizing: 'border-box' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Placeholder shaped like the document page (toolbar, icon row, title, meta strip, body lines) at the
|
|
42
|
+
* same column width and gutter, so the page replaces it without a layout shift. Hosts show it while the
|
|
43
|
+
* record and the editor load; a Space can show it while its own data decides which record opens.
|
|
44
|
+
*/
|
|
45
|
+
export function DocumentPageSkeleton({ width = 'standard', className, label }: {
|
|
46
|
+
width?: NotisDocumentPageProps['width']; className?: string; label?: string;
|
|
47
|
+
}): ReactElement {
|
|
48
|
+
const t = useCopy();
|
|
49
|
+
return <div role="status" aria-busy="true" aria-label={label || t.loading} data-notis-skeleton="document-page" className={className}
|
|
50
|
+
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
51
|
+
<style>{skeletonCss}</style>
|
|
52
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: DOCUMENT_PAGE_GUTTER.toolbar,
|
|
53
|
+
padding: `0 ${DOCUMENT_PAGE_GUTTER.narrow}px`, boxSizing: 'border-box' }}>
|
|
54
|
+
<Skeleton style={{ width: 180, height: 14 }} />
|
|
55
|
+
<Skeleton style={{ width: 48, height: 12 }} />
|
|
56
|
+
</div>
|
|
57
|
+
<div className={`${SKELETON}__column`} style={{ ...columnStyle(width), display: 'flex', flexDirection: 'column' }}>
|
|
58
|
+
<Skeleton className={`${SKELETON}__icon`} style={{ borderRadius: 12, height: undefined }} />
|
|
59
|
+
<Skeleton style={{ width: '58%', height: 36, margin: '2px 0 10px' }} />
|
|
60
|
+
<Skeleton style={{ width: '72%', height: 14, margin: '15px 0' }} />
|
|
61
|
+
<div style={{ display: 'grid', gap: 12, marginTop: 24 }}>
|
|
62
|
+
{[100, 96, 88, 100, 64].map((size, index) => <Skeleton key={index} style={{ width: `${size}%`, height: 16 }} />)}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Shown by hosts that predate DocumentPage: the same record, with the editor these hosts already have.
|
|
70
|
+
* menuItems show as plain toolbar buttons and onSaved is called when a save finishes (title null);
|
|
71
|
+
* layout, header parts, meta and width options other than the column are not available there.
|
|
72
|
+
*/
|
|
73
|
+
function DocumentPageFallback(props: NotisDocumentPageProps) {
|
|
74
|
+
const t = useCopy();
|
|
75
|
+
const { recordKey, breadcrumb, actions, share = false, aboveBody, belowBody, width, readOnly, className, menuItems = [], onSaved, onSavingChange } = props;
|
|
76
|
+
const crumbs = breadcrumb === false ? [] : breadcrumb || [];
|
|
77
|
+
const showToolbar = crumbs.length > 0 || Boolean(actions) || share || menuItems.length > 0;
|
|
78
|
+
// DocumentEditor reports saving, not what it saved: a finished save tells the Space to refresh its lists.
|
|
79
|
+
const saving = useRef(false);
|
|
80
|
+
const savingChange = useCallback((value: boolean) => {
|
|
81
|
+
if (saving.current && !value) onSaved?.({ recordKey, title: null });
|
|
82
|
+
saving.current = value;
|
|
83
|
+
onSavingChange?.(value);
|
|
84
|
+
}, [onSaved, onSavingChange, recordKey]);
|
|
85
|
+
return <div className={className} style={{ width: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
86
|
+
{showToolbar ? <div style={{ display: 'flex', alignItems: 'center', gap: 8, minHeight: 48, padding: '0 16px' }}>
|
|
87
|
+
<nav aria-label={t.breadcrumb} style={{ display: 'flex', alignItems: 'center', gap: 4, flex: 1, minWidth: 0, fontSize: 14 }}>
|
|
88
|
+
{crumbs.map((crumb, index) => <span key={`${crumb.label}-${index}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
89
|
+
{crumb.onSelect ? <button type="button" onClick={crumb.onSelect}
|
|
90
|
+
style={{ background: 'none', border: 0, padding: '4px 6px', borderRadius: 6, cursor: 'pointer', color: 'hsl(var(--muted-foreground))', font: 'inherit' }}>
|
|
91
|
+
{crumb.label}</button> : <span style={{ color: 'hsl(var(--muted-foreground))' }}>{crumb.label}</span>}
|
|
92
|
+
<span aria-hidden="true" style={{ color: 'hsl(var(--muted-foreground))' }}>/</span>
|
|
93
|
+
</span>)}
|
|
94
|
+
</nav>
|
|
95
|
+
{actions}
|
|
96
|
+
{share ? <ShareControl recordKey={recordKey} /> : null}
|
|
97
|
+
{/* The host's '...' menu is not available here: its entries show as plain buttons. */}
|
|
98
|
+
{menuItems.map((item, index) => <button key={`${item.label}-${index}`} type="button" onClick={item.onSelect} disabled={item.disabled}
|
|
99
|
+
style={{ background: 'none', border: 0, padding: '4px 8px', borderRadius: 6, cursor: item.disabled ? 'default' : 'pointer', font: 'inherit', fontSize: 13,
|
|
100
|
+
color: item.destructive ? 'hsl(var(--destructive))' : 'hsl(var(--muted-foreground))', opacity: item.disabled ? 0.5 : 1 }}>{item.label}</button>)}
|
|
101
|
+
</div> : null}
|
|
102
|
+
<div style={{ ...columnStyle(width), padding: '24px 16px 96px', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
103
|
+
{aboveBody}
|
|
104
|
+
<DocumentEditor recordKey={recordKey} variant={props.header === false ? 'body' : 'full'} showProperties={props.properties !== 'hidden'}
|
|
105
|
+
readOnly={readOnly} onDirtyChange={props.onDirtyChange} onSavingChange={savingChange} />
|
|
106
|
+
{belowBody}
|
|
107
|
+
</div>
|
|
108
|
+
</div>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The full page of one record that is the main content of a Space view, such as a note: breadcrumb,
|
|
113
|
+
* Saving or Saved status, '...' menu, cover, icon, title, meta strip, properties that save
|
|
114
|
+
* automatically, and the collaborative body with presence. The host (Portal) draws it and keeps one
|
|
115
|
+
* save queue for the whole record. Files open in their viewer under the header; HTML and reports show
|
|
116
|
+
* only the toolbar (breadcrumb, actions, Share, '...' menu) above their frame, with no header, meta,
|
|
117
|
+
* properties, aboveBody or belowBody.
|
|
118
|
+
*
|
|
119
|
+
* ```tsx
|
|
120
|
+
* if (params.note) return <DocumentPage recordKey={params.note}
|
|
121
|
+
* breadcrumb={[{ label: 'All notes', onSelect: () => setParams({ note: null }) }]}
|
|
122
|
+
* actions={<TrashButton />} onSaved={() => notes.refetch()} />;
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* Slots (`breadcrumb`, `actions`, `menuItems`, `aboveBody`, `belowBody`) and toggles (`share`,
|
|
126
|
+
* `header`, `meta`, `properties`, `width`) adjust it; `layout` rearranges its parts. Hosts without
|
|
127
|
+
* this component show the record with `DocumentEditor` instead.
|
|
128
|
+
*/
|
|
129
|
+
export function DocumentPage(props: NotisDocumentPageProps): ReactElement {
|
|
130
|
+
const Host = useNotisRuntime()?.ui?.DocumentPage;
|
|
131
|
+
return Host ? <Host {...props} /> : <DocumentPageFallback {...props} />;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Starts reading a record before it opens (for example on a list row's hover or focus), so its
|
|
136
|
+
* DocumentPage shows at once. Does nothing outside a host that supports it.
|
|
137
|
+
*/
|
|
138
|
+
export function usePrefetchRecord(): (recordKey: string) => void {
|
|
139
|
+
const prefetch = useNotisRuntime()?.ui?.prefetchRecord;
|
|
140
|
+
return useCallback((recordKey: string) => { if (recordKey) prefetch?.(recordKey); }, [prefetch]);
|
|
141
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { AnchorHTMLAttributes, ReactElement } from 'react';
|
|
4
|
+
import ReactMarkdown, { type Components } from 'react-markdown';
|
|
5
|
+
import remarkGfm from 'remark-gfm';
|
|
6
|
+
|
|
7
|
+
export interface MarkdownProps {
|
|
8
|
+
/** Markdown source to render. */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Typography scale. 'sm' suits dense surfaces (cards, list rows). */
|
|
11
|
+
size?: 'sm' | 'base';
|
|
12
|
+
className?: string;
|
|
13
|
+
/** Per-element overrides, passed through to react-markdown. */
|
|
14
|
+
components?: Components;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function MarkdownLink({
|
|
18
|
+
href,
|
|
19
|
+
children,
|
|
20
|
+
node: _node,
|
|
21
|
+
...rest
|
|
22
|
+
}: AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) {
|
|
23
|
+
const isExternal = typeof href === 'string' && /^https?:\/\//i.test(href);
|
|
24
|
+
return (
|
|
25
|
+
<a
|
|
26
|
+
href={href}
|
|
27
|
+
{...(isExternal ? { target: '_blank', rel: 'noreferrer noopener' } : {})}
|
|
28
|
+
{...rest}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</a>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const defaultComponents: Components = {
|
|
36
|
+
a: MarkdownLink,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Renders a markdown value with Notis typography. Markdown is a value type:
|
|
41
|
+
* use this anywhere markdown appears — document content, database fields,
|
|
42
|
+
* generated text. Styling comes from `.notis-markdown` rules in the SDK
|
|
43
|
+
* stylesheet and follows the portal theme variables.
|
|
44
|
+
*/
|
|
45
|
+
export function Markdown({ value, size = 'base', className, components }: MarkdownProps): ReactElement {
|
|
46
|
+
const classes = ['notis-markdown', size === 'sm' ? 'notis-markdown--sm' : null, className]
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join(' ');
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className={classes}>
|
|
52
|
+
<ReactMarkdown
|
|
53
|
+
remarkPlugins={[remarkGfm]}
|
|
54
|
+
components={components ? { ...defaultComponents, ...components } : defaultComponents}
|
|
55
|
+
>
|
|
56
|
+
{value}
|
|
57
|
+
</ReactMarkdown>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useNotisRuntime } from '../provider';
|
|
4
|
+
import type { NotisMarkdownEditorProps } from '../runtime';
|
|
5
|
+
|
|
6
|
+
function MarkdownEditorFallback({
|
|
7
|
+
value,
|
|
8
|
+
revision,
|
|
9
|
+
readOnly,
|
|
10
|
+
autosaveMs = 1500,
|
|
11
|
+
placeholder = 'Write in Markdown…',
|
|
12
|
+
className,
|
|
13
|
+
onChange,
|
|
14
|
+
onSave,
|
|
15
|
+
onDirtyChange,
|
|
16
|
+
onSavingChange,
|
|
17
|
+
}: NotisMarkdownEditorProps) {
|
|
18
|
+
const [draft, setDraft] = useState(value);
|
|
19
|
+
const [saveAttempt, setSaveAttempt] = useState(0);
|
|
20
|
+
const draftRef = useRef(value);
|
|
21
|
+
const dirtyRef = useRef(false);
|
|
22
|
+
const savingRef = useRef(false);
|
|
23
|
+
const revisionRef = useRef(revision);
|
|
24
|
+
const onSaveRef = useRef(onSave);
|
|
25
|
+
const readOnlyRef = useRef(readOnly);
|
|
26
|
+
const unmountedRef = useRef(false);
|
|
27
|
+
const saveRef = useRef<() => Promise<void>>(async () => undefined);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
onSaveRef.current = onSave;
|
|
31
|
+
readOnlyRef.current = readOnly;
|
|
32
|
+
}, [onSave, readOnly]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (dirtyRef.current) return;
|
|
36
|
+
draftRef.current = value;
|
|
37
|
+
revisionRef.current = revision;
|
|
38
|
+
setDraft(value);
|
|
39
|
+
}, [revision, value]);
|
|
40
|
+
|
|
41
|
+
const save = useCallback(async () => {
|
|
42
|
+
if (!onSave || !dirtyRef.current || readOnly || savingRef.current) return;
|
|
43
|
+
const markdown = draftRef.current;
|
|
44
|
+
savingRef.current = true;
|
|
45
|
+
if (!unmountedRef.current) onSavingChange?.(true);
|
|
46
|
+
try {
|
|
47
|
+
const result = await onSave({ markdown, expectedRevision: revisionRef.current });
|
|
48
|
+
if (result?.revision) revisionRef.current = result.revision;
|
|
49
|
+
if (draftRef.current === markdown) {
|
|
50
|
+
dirtyRef.current = false;
|
|
51
|
+
if (!unmountedRef.current) onDirtyChange?.(false);
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
savingRef.current = false;
|
|
55
|
+
if (!unmountedRef.current) onSavingChange?.(false);
|
|
56
|
+
if (dirtyRef.current && draftRef.current !== markdown) {
|
|
57
|
+
if (unmountedRef.current) {
|
|
58
|
+
void saveRef.current().catch((error) => console.error('Could not save Markdown on unmount', error));
|
|
59
|
+
} else {
|
|
60
|
+
setSaveAttempt((attempt) => attempt + 1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}, [onDirtyChange, onSave, onSavingChange, readOnly]);
|
|
65
|
+
saveRef.current = save;
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
unmountedRef.current = false;
|
|
69
|
+
return () => {
|
|
70
|
+
unmountedRef.current = true;
|
|
71
|
+
if (!savingRef.current) {
|
|
72
|
+
void saveRef.current().catch((error) => console.error('Could not save Markdown on unmount', error));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!dirtyRef.current || !onSave || readOnly) return;
|
|
79
|
+
const timer = window.setTimeout(() => void save(), Math.max(250, autosaveMs));
|
|
80
|
+
return () => window.clearTimeout(timer);
|
|
81
|
+
}, [autosaveMs, draft, onSave, readOnly, save, saveAttempt]);
|
|
82
|
+
|
|
83
|
+
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
84
|
+
const next = event.currentTarget.value;
|
|
85
|
+
draftRef.current = next;
|
|
86
|
+
setDraft(next);
|
|
87
|
+
if (!dirtyRef.current) {
|
|
88
|
+
dirtyRef.current = true;
|
|
89
|
+
onDirtyChange?.(true);
|
|
90
|
+
}
|
|
91
|
+
onChange?.(next);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
95
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
void save();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<textarea
|
|
103
|
+
value={draft}
|
|
104
|
+
readOnly={readOnly}
|
|
105
|
+
placeholder={placeholder}
|
|
106
|
+
className={className}
|
|
107
|
+
onChange={handleChange}
|
|
108
|
+
onKeyDown={handleKeyDown}
|
|
109
|
+
style={{ minHeight: 360, width: '100%', resize: 'vertical' }}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Use Notis' editor while leaving persistence entirely under app control. */
|
|
115
|
+
export function MarkdownEditor(props: NotisMarkdownEditorProps): ReactElement {
|
|
116
|
+
const runtime = useNotisRuntime();
|
|
117
|
+
const HostEditor = runtime?.ui?.MarkdownEditor;
|
|
118
|
+
return HostEditor
|
|
119
|
+
? <HostEditor key={props.resourceKey} {...props} />
|
|
120
|
+
: <MarkdownEditorFallback key={props.resourceKey} {...props} />;
|
|
121
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import type { ResolvedCollectionAction } from '../interactions/actions';
|
|
12
|
+
import { activateShortcutCollection, shortcutDisplay, useShortcuts, type ShortcutDefinition } from '../interactions/shortcuts';
|
|
13
|
+
import { isInteractionElementVisible } from '../interactions/visibility';
|
|
14
|
+
import type { ShortcutScope } from '../interactions/shortcuts';
|
|
15
|
+
|
|
16
|
+
export type MultiSelectAction = ResolvedCollectionAction;
|
|
17
|
+
|
|
18
|
+
/** The Portal owns global launcher positioning; app code only reports its own bar. */
|
|
19
|
+
export const BULK_ACTION_LAYOUT_EVENT = 'notis:bulk-actions-layout';
|
|
20
|
+
export interface BulkActionLayoutDetail {
|
|
21
|
+
bar: HTMLElement;
|
|
22
|
+
active: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MultiSelectActionBarProps {
|
|
26
|
+
selectedCount: number;
|
|
27
|
+
actions: MultiSelectAction[];
|
|
28
|
+
/** Override the "{count} selected" label units. Default: "selected" with no item word. */
|
|
29
|
+
itemLabel?: { singular: string; plural: string };
|
|
30
|
+
/** Optional extra class on the outer container (composed alongside the inline styles). */
|
|
31
|
+
className?: string;
|
|
32
|
+
/** Optional override for the bar's positioning style. Defaults to bottom-center floating. */
|
|
33
|
+
style?: CSSProperties;
|
|
34
|
+
/** Disable action key bindings while leaving the visible toolbar mounted. */
|
|
35
|
+
shortcutsEnabled?: boolean;
|
|
36
|
+
/** Override the default collection shortcut scope. */
|
|
37
|
+
shortcutScope?: ShortcutScope;
|
|
38
|
+
collectionOwnerId?: string;
|
|
39
|
+
isAvailable?: () => boolean;
|
|
40
|
+
onClearSelection?: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const containerBaseStyle: CSSProperties = {
|
|
44
|
+
position: 'fixed',
|
|
45
|
+
bottom: 'calc(var(--notis-viewport-bottom, 0px) + max(1rem, var(--notis-safe-area-bottom, env(safe-area-inset-bottom, 0px))))',
|
|
46
|
+
left: '50%',
|
|
47
|
+
transform: 'translateX(-50%)',
|
|
48
|
+
zIndex: 60,
|
|
49
|
+
display: 'flex',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
gap: '0.25rem',
|
|
52
|
+
padding: '0.375rem 0.5rem',
|
|
53
|
+
borderRadius: '0.5rem',
|
|
54
|
+
background: 'color-mix(in srgb, hsl(var(--foreground)) 95%, transparent)',
|
|
55
|
+
color: 'hsl(var(--background))',
|
|
56
|
+
boxShadow: '0 10px 25px -10px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.08)',
|
|
57
|
+
backdropFilter: 'blur(6px)',
|
|
58
|
+
WebkitBackdropFilter: 'blur(6px)',
|
|
59
|
+
pointerEvents: 'auto',
|
|
60
|
+
fontSize: '13px',
|
|
61
|
+
lineHeight: 1.2,
|
|
62
|
+
width: 'max-content',
|
|
63
|
+
maxWidth: 'calc(100% - 2rem)',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const countStyle: CSSProperties = {
|
|
67
|
+
flexShrink: 0,
|
|
68
|
+
padding: '0 0.5rem',
|
|
69
|
+
fontSize: '12px',
|
|
70
|
+
fontWeight: 500,
|
|
71
|
+
fontVariantNumeric: 'tabular-nums',
|
|
72
|
+
whiteSpace: 'nowrap',
|
|
73
|
+
color: 'color-mix(in srgb, hsl(var(--background)) 70%, transparent)',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const dividerStyle: CSSProperties = {
|
|
77
|
+
width: '1px',
|
|
78
|
+
height: '1rem',
|
|
79
|
+
margin: '0 0.125rem',
|
|
80
|
+
background: 'color-mix(in srgb, hsl(var(--background)) 20%, transparent)',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const baseButtonStyle: CSSProperties = {
|
|
84
|
+
flexShrink: 0,
|
|
85
|
+
display: 'inline-flex',
|
|
86
|
+
alignItems: 'center',
|
|
87
|
+
gap: '0.375rem',
|
|
88
|
+
padding: '0.25rem 0.5rem',
|
|
89
|
+
border: 0,
|
|
90
|
+
background: 'transparent',
|
|
91
|
+
color: 'color-mix(in srgb, hsl(var(--background)) 90%, transparent)',
|
|
92
|
+
borderRadius: '0.375rem',
|
|
93
|
+
cursor: 'pointer',
|
|
94
|
+
fontSize: '13px',
|
|
95
|
+
fontFamily: 'inherit',
|
|
96
|
+
whiteSpace: 'nowrap',
|
|
97
|
+
transition: 'background-color 120ms ease, color 120ms ease',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const keycapStyle: CSSProperties = {
|
|
101
|
+
display: 'inline-flex',
|
|
102
|
+
alignItems: 'center',
|
|
103
|
+
justifyContent: 'center',
|
|
104
|
+
minWidth: '20px',
|
|
105
|
+
height: '20px',
|
|
106
|
+
padding: '0 4px',
|
|
107
|
+
borderRadius: '4px',
|
|
108
|
+
border: '1px solid color-mix(in srgb, hsl(var(--background)) 20%, transparent)',
|
|
109
|
+
background: 'color-mix(in srgb, hsl(var(--background)) 15%, transparent)',
|
|
110
|
+
color: 'inherit',
|
|
111
|
+
fontSize: '11px',
|
|
112
|
+
fontWeight: 500,
|
|
113
|
+
lineHeight: 1,
|
|
114
|
+
fontFamily: 'inherit',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const iconSlotStyle: CSSProperties = {
|
|
118
|
+
display: 'inline-flex',
|
|
119
|
+
alignItems: 'center',
|
|
120
|
+
justifyContent: 'center',
|
|
121
|
+
color: 'inherit',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function MultiSelectActionBar({
|
|
125
|
+
selectedCount,
|
|
126
|
+
actions,
|
|
127
|
+
itemLabel,
|
|
128
|
+
className,
|
|
129
|
+
style,
|
|
130
|
+
shortcutsEnabled = true,
|
|
131
|
+
shortcutScope = 'collection',
|
|
132
|
+
collectionOwnerId,
|
|
133
|
+
isAvailable,
|
|
134
|
+
onClearSelection,
|
|
135
|
+
}: MultiSelectActionBarProps): ReactElement | null {
|
|
136
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
137
|
+
const visible = selectedCount > 0;
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const bar = barRef.current;
|
|
140
|
+
if (!visible || !bar) return;
|
|
141
|
+
// Notify the owning document, including from a shadow root or after unmount.
|
|
142
|
+
// Only the Portal host may measure this bar and change global layout styles.
|
|
143
|
+
const report = (active: boolean) => {
|
|
144
|
+
const owner = bar.ownerDocument;
|
|
145
|
+
const LayoutEvent = owner.defaultView?.CustomEvent;
|
|
146
|
+
if (LayoutEvent) {
|
|
147
|
+
owner.dispatchEvent(new LayoutEvent<BulkActionLayoutDetail>(BULK_ACTION_LAYOUT_EVENT, {
|
|
148
|
+
detail: { bar, active },
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const update = () => report(isAvailable?.() !== false && isInteractionElementVisible(bar));
|
|
153
|
+
update();
|
|
154
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
|
|
155
|
+
observer?.observe(bar);
|
|
156
|
+
const VisibilityObserver = bar.ownerDocument.defaultView?.MutationObserver;
|
|
157
|
+
const visibilityObserver = VisibilityObserver ? new VisibilityObserver(update) : null;
|
|
158
|
+
let element: Element | null = bar;
|
|
159
|
+
while (element) {
|
|
160
|
+
visibilityObserver?.observe(element, { attributes: true, attributeFilter: element === bar.ownerDocument.documentElement ? ['hidden', 'inert', 'aria-hidden', 'class'] : ['hidden', 'inert', 'aria-hidden', 'style', 'class'] });
|
|
161
|
+
const root: Node = element.getRootNode();
|
|
162
|
+
element = element.parentElement ?? ('host' in root ? (root as ShadowRoot).host : null);
|
|
163
|
+
}
|
|
164
|
+
return () => {
|
|
165
|
+
observer?.disconnect();
|
|
166
|
+
visibilityObserver?.disconnect();
|
|
167
|
+
report(false);
|
|
168
|
+
};
|
|
169
|
+
}, [isAvailable, visible]);
|
|
170
|
+
|
|
171
|
+
const actionShortcuts = useMemo<ShortcutDefinition[]>(() => {
|
|
172
|
+
return actions.flatMap((action): ShortcutDefinition[] => {
|
|
173
|
+
if (!action.shortcut) return [];
|
|
174
|
+
return [{
|
|
175
|
+
id: `collection.action.${action.id}`,
|
|
176
|
+
keys: action.shortcut,
|
|
177
|
+
label: action.label,
|
|
178
|
+
allowRepeat: Boolean(action.disabled || action.pending),
|
|
179
|
+
// Keep advertised keys owned while disabled/pending; never fall through to another action.
|
|
180
|
+
onTrigger: () => { if (!action.disabled && !action.pending) action.onRun(); },
|
|
181
|
+
}];
|
|
182
|
+
});
|
|
183
|
+
}, [actions]);
|
|
184
|
+
useShortcuts(actionShortcuts, {
|
|
185
|
+
enabled: shortcutsEnabled && selectedCount > 0,
|
|
186
|
+
scope: shortcutScope,
|
|
187
|
+
priority: 25,
|
|
188
|
+
collectionOwnerId,
|
|
189
|
+
isAvailable: () => isAvailable?.() !== false && isInteractionElementVisible(barRef.current),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (selectedCount === 0) return null;
|
|
193
|
+
|
|
194
|
+
const countWord = itemLabel
|
|
195
|
+
? selectedCount === 1
|
|
196
|
+
? itemLabel.singular
|
|
197
|
+
: itemLabel.plural
|
|
198
|
+
: 'selected';
|
|
199
|
+
const countLabel = itemLabel
|
|
200
|
+
? `${selectedCount} ${countWord} selected`
|
|
201
|
+
: `${selectedCount} selected`;
|
|
202
|
+
|
|
203
|
+
const composedStyle: CSSProperties = { ...containerBaseStyle, ...(style || {}) };
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
ref={barRef}
|
|
208
|
+
data-notis-bulk-actions
|
|
209
|
+
onFocusCapture={() => { if (collectionOwnerId) activateShortcutCollection(collectionOwnerId); }}
|
|
210
|
+
onMouseDownCapture={() => { if (collectionOwnerId) activateShortcutCollection(collectionOwnerId); }}
|
|
211
|
+
onKeyDown={(event) => {
|
|
212
|
+
if (event.key !== 'Escape' || !shortcutsEnabled || !onClearSelection || isAvailable?.() === false) return;
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
event.stopPropagation();
|
|
215
|
+
onClearSelection();
|
|
216
|
+
}}
|
|
217
|
+
role="toolbar"
|
|
218
|
+
aria-label={`Bulk actions for ${selectedCount} selected ${countWord}`}
|
|
219
|
+
className={className}
|
|
220
|
+
style={composedStyle}
|
|
221
|
+
>
|
|
222
|
+
<style>{`
|
|
223
|
+
[data-notis-bulk-action-icon][data-has-shortcut] { display: none !important; }
|
|
224
|
+
@media (max-width: 639px) {
|
|
225
|
+
[data-notis-bulk-actions] { flex-wrap: wrap; width: calc(100% - 2rem) !important; }
|
|
226
|
+
[data-notis-bulk-count] { flex-basis: 100%; padding: 0.375rem 0.5rem !important; font-size: 14px !important; }
|
|
227
|
+
[data-notis-bulk-divider] { display: none; }
|
|
228
|
+
[data-notis-bulk-action-list] { width: 100%; }
|
|
229
|
+
[data-notis-bulk-actions] button { min-height: 48px; font-size: 16px !important; }
|
|
230
|
+
}
|
|
231
|
+
@media (hover: none) and (pointer: coarse) {
|
|
232
|
+
[data-notis-bulk-actions] kbd { display: none !important; }
|
|
233
|
+
[data-notis-bulk-action-icon][data-has-shortcut] { display: inline-flex !important; }
|
|
234
|
+
}
|
|
235
|
+
`}</style>
|
|
236
|
+
<span data-notis-bulk-count style={countStyle}>{countLabel}</span>
|
|
237
|
+
{actions.length > 0 ? <span data-notis-bulk-divider aria-hidden style={dividerStyle} /> : null}
|
|
238
|
+
<div data-notis-bulk-action-list style={{ display: 'flex', minWidth: 0, overflowX: 'auto', overscrollBehaviorX: 'contain', gap: '0.25rem' }}>
|
|
239
|
+
{actions.map((action) => (
|
|
240
|
+
<ActionButton key={action.id} action={action} />
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ActionButton({ action }: { action: MultiSelectAction }) {
|
|
248
|
+
const [hover, setHover] = useState(false);
|
|
249
|
+
const display = action.shortcut ? shortcutDisplay(action.shortcut) : null;
|
|
250
|
+
const buttonStyle: CSSProperties = {
|
|
251
|
+
...baseButtonStyle,
|
|
252
|
+
background: hover
|
|
253
|
+
? 'color-mix(in srgb, hsl(var(--background)) 12%, transparent)'
|
|
254
|
+
: 'transparent',
|
|
255
|
+
color: hover
|
|
256
|
+
? 'hsl(var(--background))'
|
|
257
|
+
: baseButtonStyle.color,
|
|
258
|
+
opacity: action.disabled ? 0.45 : 1,
|
|
259
|
+
cursor: action.disabled || action.pending ? 'not-allowed' : 'pointer',
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
onClick={() => {
|
|
266
|
+
if (!action.disabled && !action.pending) void action.onRun();
|
|
267
|
+
}}
|
|
268
|
+
disabled={action.disabled || action.pending}
|
|
269
|
+
aria-busy={action.pending || undefined}
|
|
270
|
+
aria-keyshortcuts={action.shortcut || undefined}
|
|
271
|
+
data-destructive={action.destructive ? 'true' : 'false'}
|
|
272
|
+
onMouseEnter={() => setHover(true)}
|
|
273
|
+
onMouseLeave={() => setHover(false)}
|
|
274
|
+
onFocus={() => setHover(true)}
|
|
275
|
+
onBlur={() => setHover(false)}
|
|
276
|
+
style={buttonStyle}
|
|
277
|
+
>
|
|
278
|
+
{action.icon ? (
|
|
279
|
+
<span data-notis-bulk-action-icon data-has-shortcut={action.shortcut ? '' : undefined} aria-hidden style={iconSlotStyle}>{action.icon}</span>
|
|
280
|
+
) : null}
|
|
281
|
+
{display ? <kbd aria-hidden style={keycapStyle}>{display}</kbd> : null}
|
|
282
|
+
<span>{action.pending ? `${action.label}…` : action.label}</span>
|
|
283
|
+
</button>
|
|
284
|
+
);
|
|
285
|
+
}
|