Files
f832b28dac feat(editor): add date grouping configurations (#12679)
https://github.com/user-attachments/assets/d5578060-2c8c-47a5-ba65-ef2e9430518b

This PR adds the ability to group-by date with configuration which an
example is shown in the image below:


![image](https://github.com/user-attachments/assets/8762342a-999e-444e-afa2-5cfbf7e24907)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Date-based grouping modes (relative, day, week Sun/Mon, month, year),
a date group renderer, and quick lookup for group-by configs by name.

* **Improvements**
* Enhanced group settings: date sub‑modes, week‑start, per‑group
visibility, Hide All/Show All, date sort order, improved drag/drop and
reorder.
* Consistent popup placement/middleware, nested popup positioning,
per‑item close-on-select, and enforced minimum menu heights.
* UI: empty groups now display "No <property>"; views defensively handle
null/hidden groups.

* **Tests**
  * Added unit tests for date-key sorting and comparison.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Norkz <richardlora557@gmail.com>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-12-11 22:32:21 +00:00

274 lines
7.4 KiB
TypeScript

import { IS_MOBILE } from '@blocksuite/global/env';
import { ArrowRightSmallIcon } from '@blocksuite/icons/lit';
import {
autoPlacement,
autoUpdate,
computePosition,
type Middleware,
offset,
shift,
} from '@floating-ui/dom';
import { css, html, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { MenuButton } from './button.js';
import { MenuFocusable } from './focusable.js';
import { Menu, type MenuOptions } from './menu.js';
import { popMenu, popupTargetFromElement } from './menu-renderer.js';
import type { MenuItemRender } from './types.js';
export type MenuSubMenuData = {
content: () => TemplateResult;
options: MenuOptions;
select?: () => void;
class?: string;
openOnHover?: boolean;
middleware?: Middleware[];
autoHeight?: boolean;
closeOnSelect?: boolean;
};
export const subMenuOffset = offset({
mainAxis: 16,
crossAxis: 0,
});
export const subMenuPlacements = autoPlacement({
allowedPlacements: ['bottom-end'],
});
export const subMenuMiddleware = [subMenuOffset, subMenuPlacements];
export const dropdownSubMenuMiddleware = [
autoPlacement({ allowedPlacements: ['bottom-end'] }),
offset({ mainAxis: 8, crossAxis: 0 }),
shift({ crossAxis: true }),
];
export class MenuSubMenu extends MenuFocusable {
static override styles = [
MenuButton.styles,
css`
.affine-menu-button svg:last-child {
transition: transform 150ms cubic-bezier(0.42, 0, 1, 1);
}
affine-menu-sub-menu.active .affine-menu-button svg:last-child {
transform: rotate(90deg);
}
`,
];
createTime = 0;
override connectedCallback() {
super.connectedCallback();
this.createTime = Date.now();
if (this.data.openOnHover !== false) {
this.disposables.addFromEvent(this, 'mouseenter', this.onMouseEnter);
}
this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault();
e.stopPropagation();
if (this.data.select) {
this.data.select();
if (this.data.closeOnSelect !== false) {
this.menu.close();
}
} else {
this.openSubMenu();
}
});
}
onMouseEnter() {
if (Date.now() - this.createTime > 100) {
this.openSubMenu();
}
}
override onPressEnter() {
this.onMouseEnter();
}
openSubMenu() {
if (this.data.openOnHover === false) {
const { menu } = popMenu(popupTargetFromElement(this), {
options: {
...this.data.options,
onComplete: () => {
if (this.data.closeOnSelect !== false) {
this.menu.close();
}
},
onClose: () => {
menu.menuElement.remove();
this.data.options.onClose?.();
},
},
middleware: this.data.middleware,
});
if (this.data.autoHeight) {
menu.menuElement.style.minHeight = 'fit-content';
menu.menuElement.style.maxHeight = 'fit-content';
}
menu.menuElement.style.minWidth = '200px';
this.menu.openSubMenu(menu);
return;
}
const focus = this.menu.currentFocused$.value;
const menu = new Menu({
...this.data.options,
onComplete: () => {
if (this.data.closeOnSelect !== false) {
this.menu.close();
}
},
onClose: () => {
menu.menuElement.remove();
this.menu.focusTo(focus);
this.data.options.onClose?.();
unsub();
},
});
this.menu.menuElement.parentElement?.append(menu.menuElement);
if (this.data.autoHeight) {
menu.menuElement.style.minHeight = 'fit-content';
menu.menuElement.style.maxHeight = 'fit-content';
}
menu.menuElement.style.minWidth = '200px';
const unsub = autoUpdate(this, menu.menuElement, () => {
computePosition(this, menu.menuElement, {
middleware: this.data.middleware ?? subMenuMiddleware,
})
.then(({ x, y }) => {
menu.menuElement.style.left = `${x}px`;
menu.menuElement.style.top = `${y}px`;
})
.catch(err => console.error(err));
});
this.menu.openSubMenu(menu);
// in case that the menu is not closed, but the component is removed,
this.disposables.add(unsub);
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'affine-menu-button': true,
focused: this.isFocused$.value,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuSubMenuData;
}
export class MobileSubMenu extends MenuFocusable {
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault();
e.stopPropagation();
this.openSubMenu();
});
}
onMouseEnter() {
this.openSubMenu();
}
override onPressEnter() {
this.onMouseEnter();
}
openSubMenu() {
const { menu } = popMenu(popupTargetFromElement(this), {
options: {
...this.data.options,
onComplete: () => {
if (this.data.closeOnSelect !== false) {
this.menu.close();
}
},
onClose: () => {
menu.menuElement.remove();
this.data.options.onClose?.();
},
},
middleware: this.data.middleware,
});
if (this.data.autoHeight) {
menu.menuElement.style.minHeight = 'fit-content';
menu.menuElement.style.maxHeight = 'fit-content';
}
menu.menuElement.style.minWidth = '200px';
this.menu.openSubMenu(menu);
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'mobile-menu-button': true,
focused: this.isFocused$.value,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuSubMenuData;
}
export const renderSubMenu = (data: MenuSubMenuData, menu: Menu) => {
if (IS_MOBILE) {
return html` <mobile-sub-menu
.data="${data}"
.menu="${menu}"
></mobile-sub-menu>`;
}
return html` <affine-menu-sub-menu
.data="${data}"
.menu="${menu}"
></affine-menu-sub-menu>`;
};
export const subMenuItems = {
subMenu:
(config: {
name: string;
label?: () => TemplateResult;
select?: () => void;
isSelected?: boolean;
postfix?: TemplateResult;
prefix?: TemplateResult;
class?: string;
options: MenuOptions;
disableArrow?: boolean;
hide?: () => boolean;
openOnHover?: boolean;
middleware?: Middleware[];
autoHeight?: boolean;
closeOnSelect?: boolean;
}) =>
menu => {
if (config.hide?.() || !menu.search(config.name)) {
return;
}
const data: MenuSubMenuData = {
content: () =>
html`${config.prefix}
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
${config.postfix}
${config.disableArrow ? nothing : ArrowRightSmallIcon()} `,
class: config.class,
options: config.options,
openOnHover: config.openOnHover,
middleware: config.middleware,
autoHeight: config.autoHeight,
closeOnSelect: config.closeOnSelect,
};
return renderSubMenu(data, menu);
},
} satisfies Record<string, MenuItemRender<never>>;