diff --git a/blocksuite/affine/data-view/src/__tests__/table-group.unit.spec.ts b/blocksuite/affine/data-view/src/__tests__/table-group.unit.spec.ts
new file mode 100644
index 000000000..56ef6f1ad
--- /dev/null
+++ b/blocksuite/affine/data-view/src/__tests__/table-group.unit.spec.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from 'vitest';
+
+import { mobileEffects } from '../view-presets/table/mobile/effect.js';
+import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
+import { pcEffects } from '../view-presets/table/pc/effect.js';
+import type { TableGroup } from '../view-presets/table/pc/group.js';
+
+/** @vitest-environment happy-dom */
+
+describe('TableGroup', () => {
+ test('toggle collapse on pc', () => {
+ pcEffects();
+ const group = document.createElement(
+ 'affine-data-view-table-group'
+ ) as TableGroup;
+
+ expect(group.collapsed$.value).toBe(false);
+ (group as any)._toggleCollapse();
+ expect(group.collapsed$.value).toBe(true);
+ (group as any)._toggleCollapse();
+ expect(group.collapsed$.value).toBe(false);
+ });
+
+ test('toggle collapse on mobile', () => {
+ mobileEffects();
+ const group = document.createElement(
+ 'mobile-table-group'
+ ) as MobileTableGroup;
+
+ expect(group.collapsed$.value).toBe(false);
+ (group as any)._toggleCollapse();
+ expect(group.collapsed$.value).toBe(true);
+ (group as any)._toggleCollapse();
+ expect(group.collapsed$.value).toBe(false);
+ });
+});
diff --git a/blocksuite/affine/data-view/src/core/group-by/group-title.ts b/blocksuite/affine/data-view/src/core/group-by/group-title.ts
index 9e58bed86..175980f6d 100644
--- a/blocksuite/affine/data-view/src/core/group-by/group-title.ts
+++ b/blocksuite/affine/data-view/src/core/group-by/group-title.ts
@@ -26,13 +26,10 @@ const GroupTitleMobile = (
const type = groupData.tType;
if (!type) return nothing;
- const icon =
- groupData.value == null
- ? ''
- : html` `;
+ const icon = html` `;
const props: GroupRenderProps = {
group: groupData,
readonly: ops.readonly,
@@ -126,13 +123,10 @@ export const GroupTitle = (
const type = groupData.tType;
if (!type) return nothing;
- const icon =
- groupData.value == null
- ? ''
- : html` `;
+ const icon = html` `;
const props: GroupRenderProps = {
group: groupData,
readonly: ops.readonly,
diff --git a/blocksuite/affine/data-view/src/view-presets/table/collapsed-state.ts b/blocksuite/affine/data-view/src/view-presets/table/collapsed-state.ts
new file mode 100644
index 000000000..2a3434695
--- /dev/null
+++ b/blocksuite/affine/data-view/src/view-presets/table/collapsed-state.ts
@@ -0,0 +1,44 @@
+/**
+ * Shared utility for managing table group collapsed state in sessionStorage.
+ * Used by both PC and mobile table group implementations.
+ */
+
+/**
+ * Gets the collapsed state for a specific table group from sessionStorage.
+ * @param viewId - The ID of the table view
+ * @param groupKey - The key of the group
+ * @returns The collapsed state as a boolean, or false if not found or invalid
+ */
+export function getCollapsedState(viewId: string, groupKey: string): boolean {
+ try {
+ const value = sessionStorage.getItem(
+ `affine:table-group:${viewId}:${groupKey}:collapsed`
+ );
+ if (!value) return false;
+ const parsed = JSON.parse(value);
+ return typeof parsed === 'boolean' ? parsed : false;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Sets the collapsed state for a specific table group in sessionStorage.
+ * @param viewId - The ID of the table view
+ * @param groupKey - The key of the group
+ * @param collapsed - The collapsed state to store
+ */
+export function setCollapsedState(
+ viewId: string,
+ groupKey: string,
+ collapsed: boolean
+): void {
+ try {
+ sessionStorage.setItem(
+ `affine:table-group:${viewId}:${groupKey}:collapsed`,
+ JSON.stringify(collapsed)
+ );
+ } catch {
+ // ignore
+ }
+}
diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts
index 47569eba6..9760fbc8c 100644
--- a/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts
+++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts
@@ -4,16 +4,22 @@ import {
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
-import { PlusIcon } from '@blocksuite/icons/lit';
+import {
+ PlusIcon,
+ ToggleDownIcon,
+ ToggleRightIcon,
+} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
+import { signal } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
-import { css, html, unsafeCSS } from 'lit';
+import { css, html, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { GroupTitle } from '../../../core/group-by/group-title.js';
import type { Group } from '../../../core/group-by/trait.js';
import type { Row } from '../../../core/index.js';
+import { getCollapsedState, setCollapsedState } from '../collapsed-state.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import type { MobileTableViewUILogic } from './table-view-ui-logic.js';
@@ -42,6 +48,28 @@ const styles = css`
line-height: 20px;
color: var(--affine-text-secondary-color);
}
+
+ .group-toggle-btn {
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 150ms cubic-bezier(0.42, 0, 1, 1);
+ }
+
+ .group-toggle-btn:hover {
+ background: var(--affine-hover-color);
+ }
+
+ .group-toggle-btn svg {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ user-select: none;
+ }
`;
export class MobileTableGroup extends SignalWatcher(
@@ -49,6 +77,29 @@ export class MobileTableGroup extends SignalWatcher(
) {
static override styles = styles;
+ collapsed$ = signal(false);
+
+ private storageLoaded = false;
+
+ private _loadCollapsedState() {
+ if (this.storageLoaded) return;
+ this.storageLoaded = true;
+ const view = this.tableViewLogic?.view;
+ if (!view) return;
+ const value = getCollapsedState(view.id, this.group?.key ?? 'all');
+ this.collapsed$.value = value;
+ }
+
+ private readonly _toggleCollapse = (e?: MouseEvent) => {
+ e?.stopPropagation();
+ const next = !this.collapsed$.value;
+ this.collapsed$.value = next;
+ const view = this.tableViewLogic?.view;
+ if (view) {
+ setCollapsedState(view.id, this.group?.key ?? 'all', next);
+ }
+ };
+
private readonly clickAddRow = () => {
this.view.rowAdd('end', this.group?.key);
this.requestUpdate();
@@ -93,6 +144,27 @@ export class MobileTableGroup extends SignalWatcher(
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ this._toggleCollapse();
+ }
+ }}
+ >
+ ${this.collapsed$.value
+ ? ToggleRightIcon({ width: '16px', height: '16px' })
+ : ToggleDownIcon({ width: '16px', height: '16px' })}
+
+
${GroupTitle(this.group, {
readonly: this.view.readonly$.value,
clickAdd: this.clickAddRowInStart,
@@ -109,7 +181,6 @@ export class MobileTableGroup extends SignalWatcher(
private renderRows(rows: Row[]) {
return html`
@@ -144,8 +215,26 @@ export class MobileTableGroup extends SignalWatcher(
`;
}
+ override willUpdate(changed: Map
): void {
+ super.willUpdate(changed);
+ if (
+ !this.storageLoaded &&
+ (changed.has('group') || changed.has('tableViewLogic'))
+ ) {
+ this._loadCollapsedState();
+ }
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this._loadCollapsedState();
+ }
+
override render() {
- return this.renderRows(this.rows);
+ return html`
+ ${this.collapsed$.value ? this.renderGroupHeader() : nothing}
+ ${this.collapsed$.value ? nothing : this.renderRows(this.rows)}
+ `;
}
@property({ attribute: false })
diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts
index 460736b0f..982341696 100644
--- a/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts
+++ b/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts
@@ -4,11 +4,15 @@ import {
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
-import { PlusIcon } from '@blocksuite/icons/lit';
+import {
+ PlusIcon,
+ ToggleDownIcon,
+ ToggleRightIcon,
+} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
-import { effect } from '@preact/signals-core';
+import { effect, signal } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
-import { css, html, unsafeCSS } from 'lit';
+import { css, html, nothing, unsafeCSS } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
@@ -18,6 +22,7 @@ import type { Row } from '../../../core/index.js';
import { createDndContext } from '../../../core/utils/wc-dnd/dnd-context.js';
import { defaultActivators } from '../../../core/utils/wc-dnd/sensors/index.js';
import { linearMove } from '../../../core/utils/wc-dnd/utils/linear-move.js';
+import { getCollapsedState, setCollapsedState } from '../collapsed-state.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import { TableViewAreaSelection } from '../selection';
import { DataViewColumnPreview } from './header/column-renderer.js';
@@ -30,6 +35,12 @@ const styles = css`
opacity: 1;
}
+ affine-data-view-table-group {
+ margin-top: 4px;
+ padding-top: 4px;
+ border-top: 1px solid var(--affine-border-color);
+ }
+
.data-view-table-group-add-row {
display: flex;
width: 100%;
@@ -42,6 +53,10 @@ const styles = css`
border-bottom: 1px solid ${unsafeCSS(cssVarV2.layer.insideBorder.border)};
}
+ .affine-data-view-table-group:hover svg {
+ fill: var(--affine-icon-color);
+ }
+
@media print {
.data-view-table-group-add-row {
display: none;
@@ -60,6 +75,28 @@ const styles = css`
line-height: 20px;
color: var(--affine-text-secondary-color);
}
+
+ .group-toggle-btn {
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 150ms cubic-bezier(0.42, 0, 1, 1);
+ }
+
+ .group-toggle-btn:hover {
+ background: var(--affine-hover-color);
+ }
+
+ .group-toggle-btn svg {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ user-select: none;
+ }
`;
export class TableGroup extends SignalWatcher(
@@ -67,6 +104,29 @@ export class TableGroup extends SignalWatcher(
) {
static override styles = styles;
+ collapsed$ = signal(false);
+
+ private storageLoaded = false;
+
+ private _loadCollapsedState() {
+ if (this.storageLoaded) return;
+ this.storageLoaded = true;
+ const view = this.tableViewLogic?.view;
+ if (!view) return;
+ const value = getCollapsedState(view.id, this.group?.key ?? 'all');
+ this.collapsed$.value = value;
+ }
+
+ private readonly _toggleCollapse = (e?: MouseEvent) => {
+ e?.stopPropagation();
+ const next = !this.collapsed$.value;
+ this.collapsed$.value = next;
+ const view = this.tableViewLogic?.view;
+ if (view) {
+ setCollapsedState(view.id, this.group?.key ?? 'all', next);
+ }
+ };
+
private readonly clickAddRow = () => {
this.view.rowAdd('end', this.group?.key);
const selectionController = this.tableViewLogic.selectionController;
@@ -137,10 +197,32 @@ export class TableGroup extends SignalWatcher(
if (!this.group) {
return null;
}
+
return html`
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ this._toggleCollapse();
+ }
+ }}
+ >
+ ${this.collapsed$.value
+ ? ToggleRightIcon({ width: '16px', height: '16px' })
+ : ToggleDownIcon({ width: '16px', height: '16px' })}
+
+
${GroupTitle(this.group, {
readonly: this.view.readonly$.value,
clickAdd: this.clickAddRowInStart,
@@ -244,8 +326,8 @@ export class TableGroup extends SignalWatcher(
private renderRows(rows: Row[]) {
return html`
${repeat(
@@ -284,13 +366,27 @@ export class TableGroup extends SignalWatcher(
`;
}
+ override willUpdate(changed: Map
): void {
+ super.willUpdate(changed);
+ if (
+ !this.storageLoaded &&
+ (changed.has('group') || changed.has('tableViewLogic'))
+ ) {
+ this._loadCollapsedState();
+ }
+ }
+
override connectedCallback(): void {
super.connectedCallback();
+ this._loadCollapsedState();
this.showIndicator();
}
override render() {
- return this.renderRows(this.rows);
+ return html`
+ ${this.collapsed$.value ? this.renderGroupHeader() : nothing}
+ ${this.collapsed$.value ? nothing : this.renderRows(this.rows)}
+ `;
}
@query('.affine-database-block-rows')