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')