@casualoffice/sheets 0.18.0 → 0.19.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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License").
5
+ */
6
+
7
+ /**
8
+ * Tables side panel: lists the formatted tables on the active sheet with
9
+ * inline rename, theme swatches, and delete. Ported from the standalone app's
10
+ * TablesPanel — the app-only `useUI`/`useBusy` couplings are replaced by the
11
+ * panel host's `onClose`, and the Univer facade is reached through `api.univer`.
12
+ */
13
+ import { useEffect, useMemo, useState, type CSSProperties } from 'react';
14
+
15
+ import { ensurePluginByName } from '../univer';
16
+ import type { PanelComponentProps } from './extensions';
17
+ import { Icon } from './Icon';
18
+
19
+ // Univer's six built-in table themes, surfaced as swatches.
20
+ const TABLE_THEMES = [
21
+ { id: 'table-default-0', label: 'Indigo', swatch: '#6280F9' },
22
+ { id: 'table-default-1', label: 'Teal', swatch: '#16BDCA' },
23
+ { id: 'table-default-2', label: 'Green', swatch: '#31C48D' },
24
+ { id: 'table-default-3', label: 'Purple', swatch: '#AC94FA' },
25
+ { id: 'table-default-4', label: 'Pink', swatch: '#F17EBB' },
26
+ { id: 'table-default-5', label: 'Red', swatch: '#F98080' },
27
+ ] as const;
28
+ type TableThemeId = (typeof TABLE_THEMES)[number]['id'];
29
+
30
+ interface TableRange {
31
+ startRow: number;
32
+ startColumn: number;
33
+ endRow: number;
34
+ endColumn: number;
35
+ }
36
+ interface RawTable {
37
+ id: string;
38
+ name: string;
39
+ subUnitId: string;
40
+ range: TableRange;
41
+ }
42
+ interface TableInfo extends RawTable {
43
+ styleId: string;
44
+ }
45
+
46
+ function colLetters(col: number): string {
47
+ let n = col + 1;
48
+ let out = '';
49
+ while (n > 0) {
50
+ const r = (n - 1) % 26;
51
+ out = String.fromCharCode(65 + r) + out;
52
+ n = Math.floor((n - 1) / 26);
53
+ }
54
+ return out;
55
+ }
56
+ function toA1(r: TableRange): string {
57
+ const start = `${colLetters(r.startColumn)}${r.startRow + 1}`;
58
+ const end = `${colLetters(r.endColumn)}${r.endRow + 1}`;
59
+ return start === end ? start : `${start}:${end}`;
60
+ }
61
+
62
+ const REFRESH_CMDS = new Set([
63
+ 'sheet.command.add-table',
64
+ 'sheet.command.delete-table',
65
+ 'sheet.command.set-table-config',
66
+ 'sheet.mutation.add-table',
67
+ 'sheet.mutation.set-table-config',
68
+ 'sheet.mutation.delete-table',
69
+ 'sheet.operation.set-worksheet-activate',
70
+ 'doc.command-replace-snapshot',
71
+ ]);
72
+
73
+ /* eslint-disable @typescript-eslint/no-explicit-any */
74
+ function readTables(univer: any, sheetId: string | null): TableInfo[] {
75
+ try {
76
+ const wb = univer.getActiveWorkbook?.();
77
+ if (!wb) return [];
78
+ const all: RawTable[] = wb.getTableList?.() ?? [];
79
+ return all
80
+ .filter((t) => (sheetId ? t.subUnitId === sheetId : true))
81
+ .map((t) => ({ ...t, styleId: 'table-default-0' }));
82
+ } catch {
83
+ // Table plugin still registering — the CommandExecuted subscription
84
+ // recomputes once it's ready.
85
+ return [];
86
+ }
87
+ }
88
+
89
+ const header: CSSProperties = {
90
+ display: 'flex',
91
+ alignItems: 'center',
92
+ gap: 8,
93
+ padding: '10px 12px',
94
+ borderBottom: '1px solid var(--cs-chrome-border, #edeff3)',
95
+ };
96
+
97
+ export function TablesPanel({ api, onClose }: PanelComponentProps) {
98
+ const univer = (api as any).univer;
99
+ const [tables, setTables] = useState<TableInfo[]>([]);
100
+ const [renaming, setRenaming] = useState<{ id: string; draft: string } | null>(null);
101
+
102
+ useEffect(() => {
103
+ if (!univer) return;
104
+ let cancelled = false;
105
+ let disp: { dispose?: () => void } | undefined;
106
+ const compute = () => {
107
+ if (cancelled) return;
108
+ const sheetId = univer.getActiveWorkbook?.()?.getActiveSheet?.()?.getSheetId?.() ?? null;
109
+ setTables(readTables(univer, sheetId));
110
+ };
111
+ // The table plugin is lazy-loaded — `getTableList()` resolves SheetTableService
112
+ // and throws if the plugin isn't registered yet, so ensure it before reading.
113
+ void ensurePluginByName('table').then(() => {
114
+ if (cancelled) return;
115
+ compute();
116
+ disp = univer.addEvent(univer.Event.CommandExecuted, (e: { id?: string }) => {
117
+ if (e.id && REFRESH_CMDS.has(e.id)) compute();
118
+ });
119
+ });
120
+ return () => {
121
+ cancelled = true;
122
+ disp?.dispose?.();
123
+ };
124
+ }, [univer]);
125
+
126
+ const themesById = useMemo(() => {
127
+ const m = new Map<string, (typeof TABLE_THEMES)[number]>();
128
+ for (const t of TABLE_THEMES) m.set(t.id, t);
129
+ return m;
130
+ }, []);
131
+
132
+ const empty = tables.length === 0;
133
+
134
+ const onRenameCommit = async (id: string, currentName: string) => {
135
+ if (!renaming || renaming.id !== id) return;
136
+ const next = renaming.draft.trim();
137
+ setRenaming(null);
138
+ if (!next || next === currentName) return;
139
+ const sheet = univer.getActiveWorkbook?.()?.getActiveSheet?.();
140
+ if (!sheet) return;
141
+ await ensurePluginByName('table');
142
+ const ok = await Promise.resolve(sheet.setTableName?.(id, next));
143
+ if (ok === false) console.warn(`[tables] rename rejected: "${next}"`);
144
+ };
145
+
146
+ const onPickTheme = async (id: string, themeId: TableThemeId) => {
147
+ const wb = univer.getActiveWorkbook?.();
148
+ if (!wb) return;
149
+ await ensurePluginByName('table');
150
+ univer.executeCommand('sheet.command.set-table-config', {
151
+ unitId: wb.getId(),
152
+ tableId: id,
153
+ theme: themeId,
154
+ });
155
+ };
156
+
157
+ const onDelete = async (id: string) => {
158
+ const wb = univer.getActiveWorkbook?.();
159
+ if (!wb) return;
160
+ await ensurePluginByName('table');
161
+ wb.removeTable?.(id);
162
+ };
163
+
164
+ return (
165
+ <div
166
+ data-testid="cs-tables-panel"
167
+ style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
168
+ >
169
+ <header style={header}>
170
+ <Icon name="table" size={18} />
171
+ <span style={{ fontWeight: 600, flex: 1 }}>Tables</span>
172
+ {!empty && <span style={{ opacity: 0.6, fontSize: 12 }}>{tables.length}</span>}
173
+ <button
174
+ type="button"
175
+ aria-label="Close tables panel"
176
+ onClick={onClose}
177
+ style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'inherit' }}
178
+ >
179
+ <Icon name="close" size={18} />
180
+ </button>
181
+ </header>
182
+
183
+ <div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
184
+ {empty ? (
185
+ <div
186
+ data-testid="cs-tables-panel-empty"
187
+ style={{ textAlign: 'center', opacity: 0.75, padding: '24px 8px' }}
188
+ >
189
+ <Icon name="table_rows" size={40} style={{ opacity: 0.4 }} />
190
+ <div style={{ fontWeight: 600, marginTop: 8 }}>No tables on this sheet</div>
191
+ <div style={{ fontSize: 13, marginTop: 4 }}>
192
+ Select your data, then use <strong>Insert → Table</strong> from the menu.
193
+ </div>
194
+ </div>
195
+ ) : (
196
+ <ul
197
+ style={{
198
+ listStyle: 'none',
199
+ margin: 0,
200
+ padding: 0,
201
+ display: 'flex',
202
+ flexDirection: 'column',
203
+ gap: 8,
204
+ }}
205
+ >
206
+ {tables.map((t) => {
207
+ const isRenaming = renaming?.id === t.id;
208
+ const theme = themesById.get(t.styleId);
209
+ return (
210
+ <li
211
+ key={t.id}
212
+ data-testid={`cs-tables-panel-row-${t.id}`}
213
+ style={{
214
+ border: '1px solid var(--cs-chrome-border, #edeff3)',
215
+ borderRadius: 8,
216
+ padding: 10,
217
+ display: 'flex',
218
+ flexDirection: 'column',
219
+ gap: 6,
220
+ }}
221
+ >
222
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
223
+ {isRenaming ? (
224
+ <input
225
+ autoFocus
226
+ value={renaming.draft}
227
+ onChange={(e) => setRenaming({ id: t.id, draft: e.target.value })}
228
+ onBlur={() => onRenameCommit(t.id, t.name)}
229
+ onKeyDown={(e) => {
230
+ if (e.key === 'Enter') onRenameCommit(t.id, t.name);
231
+ if (e.key === 'Escape') setRenaming(null);
232
+ }}
233
+ style={{ flex: 1, font: 'inherit', padding: '2px 4px' }}
234
+ />
235
+ ) : (
236
+ <button
237
+ type="button"
238
+ onClick={() => setRenaming({ id: t.id, draft: t.name })}
239
+ title="Click to rename"
240
+ style={{
241
+ flex: 1,
242
+ textAlign: 'left',
243
+ border: 'none',
244
+ background: 'transparent',
245
+ cursor: 'pointer',
246
+ font: 'inherit',
247
+ fontWeight: 600,
248
+ color: 'inherit',
249
+ }}
250
+ >
251
+ {t.name}
252
+ </button>
253
+ )}
254
+ <span style={{ opacity: 0.6, fontSize: 12 }}>{toA1(t.range)}</span>
255
+ <button
256
+ type="button"
257
+ aria-label={`Delete table ${t.name}`}
258
+ title="Delete table"
259
+ onClick={() => onDelete(t.id)}
260
+ style={{
261
+ border: 'none',
262
+ background: 'transparent',
263
+ cursor: 'pointer',
264
+ color: 'inherit',
265
+ }}
266
+ >
267
+ <Icon name="delete" size={16} />
268
+ </button>
269
+ </div>
270
+ <div role="group" aria-label="Table theme" style={{ display: 'flex', gap: 4 }}>
271
+ {TABLE_THEMES.map((opt) => (
272
+ <button
273
+ key={opt.id}
274
+ type="button"
275
+ title={opt.label}
276
+ aria-label={opt.label}
277
+ aria-pressed={theme?.id === opt.id}
278
+ onClick={() => onPickTheme(t.id, opt.id)}
279
+ style={{
280
+ width: 18,
281
+ height: 18,
282
+ borderRadius: 4,
283
+ cursor: 'pointer',
284
+ background: opt.swatch,
285
+ border:
286
+ theme?.id === opt.id
287
+ ? '2px solid var(--cs-chrome-active-fg, #0e7490)'
288
+ : '1px solid rgba(0,0,0,0.15)',
289
+ }}
290
+ />
291
+ ))}
292
+ </div>
293
+ </li>
294
+ );
295
+ })}
296
+ </ul>
297
+ )}
298
+ </div>
299
+ </div>
300
+ );
301
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License").
5
+ */
6
+
7
+ /**
8
+ * Panel store for the SDK chrome's side-panel rail (Tables/Charts/Comments/…).
9
+ *
10
+ * A single `openPanelId` is the whole state — that IS the mutex: opening one
11
+ * panel closes any other, exactly like the standalone app's PanelMutex, but
12
+ * without the app-only `ui-context`. The rail toggles panels through here and
13
+ * the panel host renders whichever id is open. Kept deliberately tiny so the
14
+ * chrome stays a thin shell around Univer.
15
+ */
16
+ import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
17
+
18
+ export interface PanelStore {
19
+ /** The id of the currently-open panel, or null when the rail is all-closed. */
20
+ openPanelId: string | null;
21
+ /** Open `id`, replacing whatever was open (mutex). */
22
+ open: (id: string) => void;
23
+ /** Close the open panel. */
24
+ close: () => void;
25
+ /** Toggle `id`: open it if closed, close it if it's the open one. */
26
+ toggle: (id: string) => void;
27
+ }
28
+
29
+ const PanelContext = createContext<PanelStore | null>(null);
30
+
31
+ export function PanelProvider({ children }: { children: ReactNode }) {
32
+ const [openPanelId, setOpenPanelId] = useState<string | null>(null);
33
+ const store = useMemo<PanelStore>(
34
+ () => ({
35
+ openPanelId,
36
+ open: (id) => setOpenPanelId(id),
37
+ close: () => setOpenPanelId(null),
38
+ toggle: (id) => setOpenPanelId((cur) => (cur === id ? null : id)),
39
+ }),
40
+ [openPanelId],
41
+ );
42
+ return <PanelContext.Provider value={store}>{children}</PanelContext.Provider>;
43
+ }
44
+
45
+ /** Read the panel store. Returns a no-op store when used outside a provider. */
46
+ export function usePanels(): PanelStore {
47
+ return (
48
+ useContext(PanelContext) ?? {
49
+ openPanelId: null,
50
+ open: () => {},
51
+ close: () => {},
52
+ toggle: () => {},
53
+ }
54
+ );
55
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License").
5
+ */
6
+
7
+ /**
8
+ * Built-in side panels the SDK chrome ships (mirrors `BUILT_IN_DIALOGS`). Each
9
+ * entry gets a rail button + a body rendered by the panel host. Hosts can add
10
+ * more via `extensions.panels` (see extensions.ts) — those are merged after
11
+ * these on the rail.
12
+ */
13
+ import { lazy, type ComponentType } from 'react';
14
+
15
+ import type { PanelComponentProps } from './extensions';
16
+ import { TablesPanel } from './TablesPanel';
17
+ import { PivotFieldsPanel } from './PivotFieldsPanel';
18
+ import { CommentsPanel } from './CommentsPanel';
19
+ import { HistoryPanel } from './HistoryPanel';
20
+ // Charts panel pulls echarts — lazy so it (and echarts) only load when the
21
+ // panel is first opened. PanelHost renders panels inside a Suspense boundary.
22
+ const ChartsPanel = lazy(() =>
23
+ import('../charts/ChartsPanel').then((m) => ({ default: m.ChartsPanel })),
24
+ );
25
+
26
+ export interface BuiltInPanel {
27
+ /** Stable id (rail `data-testid` `cs-panel-rail-<id>`, mutex key). */
28
+ id: string;
29
+ /** Rail button tooltip / aria-label. */
30
+ label: string;
31
+ /** Material Symbols icon name for the rail button. */
32
+ icon: string;
33
+ /** The panel body; receives `{ api, onClose }`. */
34
+ component: ComponentType<PanelComponentProps>;
35
+ }
36
+
37
+ export const BUILT_IN_PANELS: BuiltInPanel[] = [
38
+ { id: 'tables', label: 'Tables', icon: 'table', component: TablesPanel },
39
+ {
40
+ id: 'pivot',
41
+ label: 'PivotTable Fields',
42
+ icon: 'pivot_table_chart',
43
+ component: PivotFieldsPanel,
44
+ },
45
+ { id: 'charts', label: 'Charts', icon: 'analytics', component: ChartsPanel },
46
+ { id: 'comments', label: 'Comments', icon: 'forum', component: CommentsPanel },
47
+ { id: 'history', label: 'History', icon: 'history', component: HistoryPanel },
48
+ ];
@@ -106,6 +106,16 @@ import {
106
106
  import type { ChromeExtensions } from '../chrome/extensions';
107
107
  import type { DialogKind } from '../chrome/dialog-context';
108
108
  import { AiPanelSurface, type SheetsAiConfig } from '../ai/AiPanelSurface';
109
+ import { PanelProvider } from '../chrome/panel-context';
110
+ import { PanelRail } from '../chrome/PanelRail';
111
+ import { PanelHost } from '../chrome/PanelHost';
112
+ // Charts: the provider is echarts-free (resources/types only) so it imports
113
+ // eagerly; the overlay pulls echarts, so it's lazy — echarts becomes its own
114
+ // async chunk, loaded only when a chart actually renders.
115
+ import { ChartsProvider } from '../charts/charts-context';
116
+ const ChartLayer = lazy(() =>
117
+ import('../charts/ChartLayer').then((m) => ({ default: m.ChartLayer })),
118
+ );
109
119
  // Chrome is lazy-loaded from the `@casualoffice/sheets/chrome` subpath (NOT a
110
120
  // relative import — that would inline under this build's splitting:false). The
111
121
  // subpath is externalised in tsup, so the consumer's bundler code-splits it and
@@ -713,47 +723,63 @@ export function CasualSheets({
713
723
  } as CSSProperties;
714
724
 
715
725
  return (
716
- <div
717
- className={className}
718
- data-testid={testId}
719
- data-theme={dark ? 'dark' : 'light'}
720
- onKeyDownCapture={onKeyDownCapture}
721
- style={{
722
- ...DEFAULT_STYLE,
723
- ...chromeVars,
724
- ...style,
725
- display: 'flex',
726
- flexDirection: 'column',
727
- }}
728
- >
729
- {/* Bars appear once their lazy chunk loads (a tick after first paint); the
726
+ <PanelProvider>
727
+ <div
728
+ className={className}
729
+ data-testid={testId}
730
+ data-theme={dark ? 'dark' : 'light'}
731
+ onKeyDownCapture={onKeyDownCapture}
732
+ style={{
733
+ ...DEFAULT_STYLE,
734
+ ...chromeVars,
735
+ ...style,
736
+ display: 'flex',
737
+ flexDirection: 'column',
738
+ }}
739
+ >
740
+ {/* Bars appear once their lazy chunk loads (a tick after first paint); the
730
741
  grid host is OUTSIDE Suspense so Univer mounts immediately. */}
731
- <Suspense fallback={null}>
732
- <ChromeTop
733
- api={chromeApi}
734
- features={features}
735
- onDialogRequest={onDialogRequest}
736
- hostOwnedDialogs={hostOwnedDialogs}
737
- extensions={extensions}
738
- />
739
- </Suspense>
740
- {hasAi ? (
741
- // Grid + AI pane share a flex row so the built-in chrome (top/bottom
742
- // bars) still spans the full width. Shape fixed at mount by `hasAi`.
742
+ <Suspense fallback={null}>
743
+ <ChromeTop
744
+ api={chromeApi}
745
+ features={features}
746
+ onDialogRequest={onDialogRequest}
747
+ hostOwnedDialogs={hostOwnedDialogs}
748
+ extensions={extensions}
749
+ />
750
+ </Suspense>
751
+ {/* Grid + side panels + rail share a flex row so the built-in chrome
752
+ (top/bottom bars) still spans the full width. The row shape is stable
753
+ (grid host never remounts); PanelHost/AI aside only add/remove
754
+ siblings, and the rail is always present. */}
743
755
  <div style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'row' }}>
744
756
  <div
745
757
  ref={hostRef}
758
+ data-testid="univer-host"
746
759
  style={{ flex: '1 1 auto', minWidth: 0, minHeight: 0, position: 'relative' }}
747
760
  />
748
- <AiPanelSurface config={ai} api={aiApi} />
761
+ {/* Charts context + overlay live once the api is ready (it arrives
762
+ after Univer boots into the grid host above, so it can't gate the
763
+ host). ChartLayer portals onto the `univer-host` marker; the
764
+ Charts panel (inside PanelHost) reads the same context. */}
765
+ {chromeApi ? (
766
+ <ChartsProvider api={chromeApi}>
767
+ <Suspense fallback={null}>
768
+ <ChartLayer />
769
+ </Suspense>
770
+ <PanelHost api={chromeApi} extensions={extensions} />
771
+ </ChartsProvider>
772
+ ) : (
773
+ <PanelHost api={chromeApi} extensions={extensions} />
774
+ )}
775
+ {hasAi && <AiPanelSurface config={ai} api={aiApi} />}
776
+ <PanelRail extensions={extensions} />
749
777
  </div>
750
- ) : (
751
- <div ref={hostRef} style={{ flex: '1 1 auto', minHeight: 0, position: 'relative' }} />
752
- )}
753
- <Suspense fallback={null}>
754
- <ChromeBottom api={chromeApi} />
755
- </Suspense>
756
- </div>
778
+ <Suspense fallback={null}>
779
+ <ChromeBottom api={chromeApi} />
780
+ </Suspense>
781
+ </div>
782
+ </PanelProvider>
757
783
  );
758
784
  }
759
785