diff --git a/dev/zui-dev/package.json b/dev/zui-dev/package.json new file mode 100644 index 0000000000..15e7525a4e --- /dev/null +++ b/dev/zui-dev/package.json @@ -0,0 +1,7 @@ +{ + "name": "zui-dev", + "description": "ZUI dev helpers", + "version": "0.0.1", + "type": "module", + "main": "src/main.ts" +} diff --git a/dev/zui-dev/src/dev.ts b/dev/zui-dev/src/dev.ts new file mode 100644 index 0000000000..2d265a1b26 --- /dev/null +++ b/dev/zui-dev/src/dev.ts @@ -0,0 +1,26 @@ +export type PageLoadListener = () => void; +export type PageUpdateListener = () => void; + +export function onPageLoad(listener: PageLoadListener) { + const isLoaded = document.getElementById('libPage')?.classList.contains('is-loaded'); + if (isLoaded) { + listener(); + } else { + document.addEventListener('dev-page-load', listener); + } +} + +export function onPageUpdate(listener: PageUpdateListener) { + onPageLoad(listener); + document.addEventListener('dev-page-update', listener); +} + +Object.assign(globalThis, { + onPageLoad, + onPageUpdate, +}); + +declare global { + function onPageLoad(listener: PageLoadListener): void; + function onPageUpdate(listener: PageUpdateListener): void; +} diff --git a/dev/zui-dev/src/main.ts b/dev/zui-dev/src/main.ts new file mode 100644 index 0000000000..855d4662c2 --- /dev/null +++ b/dev/zui-dev/src/main.ts @@ -0,0 +1 @@ +export * from './dev'; diff --git a/docs/_/.vitepress/theme-config.ts b/docs/_/.vitepress/theme-config.ts index 6756a18345..0bf05d4a3d 100644 --- a/docs/_/.vitepress/theme-config.ts +++ b/docs/_/.vitepress/theme-config.ts @@ -63,7 +63,7 @@ export const extLibs = [...zuiLib.reduce((set, lib) => { function createNav() { return [ - {text: '指引', link: '/guide/start/', activeMatch: '/guide/'}, + {text: '文档', link: '/guide/start/', activeMatch: '/guide/'}, {text: 'CSS 工具类', link: '/utilities/skin/utilities/solid', activeMatch: '/utilities/'}, {text: '组件', link: '/lib/components/button/', activeMatch: '/lib/'}, {text: 'ZUI1', link: 'https://openzui.com/1/'}, diff --git a/lib/btn-group/src/types/btn-group-options.ts b/lib/btn-group/src/types/btn-group-options.ts index 278630a2c1..55c1d7cb92 100644 --- a/lib/btn-group/src/types/btn-group-options.ts +++ b/lib/btn-group/src/types/btn-group-options.ts @@ -3,7 +3,7 @@ import type {CommonListProps, Item} from '@zui/common-list'; import type {BtnGroupItem} from './btn-group-item'; export interface BtnGroupOptions extends CommonListProps { - size?: 'xs' | 'sm' | 'lg' | 'xl'; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; type?: string; btnType?: string; btnProps?: Partial; diff --git a/lib/button/src/component/button.tsx b/lib/button/src/component/button.tsx index 054727bd06..dbaf92b3f3 100644 --- a/lib/button/src/component/button.tsx +++ b/lib/button/src/component/button.tsx @@ -16,12 +16,12 @@ export class Button

extends HElement

{ } protected _getChildren(props: RenderableProps

) { - const {loading, loadingIcon, loadingText, icon, text, children, trailingIcon, caret} = props; + const {loading, loadingIcon, loadingText, icon, iconClass, text, textClass, children, trailingIcon, trailingIconClass, caret} = props; return [ - loading ? : , - this._isEmptyText ? null : {loading ? loadingText : text}, + loading ? : , + this._isEmptyText ? null : {loading ? loadingText : text}, loading ? null : children, - loading ? null : , + loading ? null : , loading ? null : caret ? : null, ]; } @@ -43,7 +43,7 @@ export class Button

extends HElement

{ protected _getProps(props: RenderableProps

) { const component = this._getComponent(props); - const {url, target, disabled, btnType = 'button', hint} = props; + const {url, target, disabled, btnType = 'button', hint, command} = props; const asLink = component === 'a'; const componentProps: Record = { ...super._getProps(props), @@ -67,6 +67,9 @@ export class Button

extends HElement

{ if (target !== undefined) { componentProps[asLink ? 'target' : 'data-target'] = target; } + if (command) { + componentProps['zui-command'] = command; + } } return componentProps; } diff --git a/lib/button/src/types/button-props.ts b/lib/button/src/types/button-props.ts index 98a5e9745f..13846a02b7 100644 --- a/lib/button/src/types/button-props.ts +++ b/lib/button/src/types/button-props.ts @@ -1,5 +1,5 @@ -import type {ComponentChildren, JSX} from 'preact'; -import type {IconType, HElementProps} from '@zui/core'; +import type {JSX, ComponentChildren} from 'preact'; +import type {IconType, HElementProps, ClassNameLike} from '@zui/core'; export interface ButtonProps extends HElementProps { type?: string; // primary, secondary ... @@ -12,12 +12,16 @@ export interface ButtonProps extends HElementProps { rounded?: boolean | string; active?: boolean; icon?: IconType; + iconClass?: ClassNameLike; text?: ComponentChildren; + textClass?: ClassNameLike; square?: boolean; trailingIcon?: IconType; + trailingIconClass?: ClassNameLike; caret?: 'up' | 'down' | 'left' | 'right' | boolean; hint?: string; loading?: boolean; loadingIcon?: IconType; loadingText?: string; + command?: string; } diff --git a/lib/calendar/README.md b/lib/calendar/README.md new file mode 100644 index 0000000000..5867440a97 --- /dev/null +++ b/lib/calendar/README.md @@ -0,0 +1,59 @@ +# Calendar 日历 + +用于展示和管理事件的日历组件。它允许用户查看日期、添加特定日期的事件来安排日程。提供可配置的事件与日历集。 + +## 基本使用 + +使用HTML标签 `

` 作为容器来实现日历组件,可使用zui-create-calendar、new zui.Calendar()等指令来配置日历组件。 + +## 默认样式 + +
+ +## 自定义事件与事件集 + + + 事件是在日历中添加的具体事件,可自定义事件的颜色、文字、日期、拖拽回调函数、点击回调函数等。事件集是一组事件的集合与事件唯一关联。事件、事件集id都需要唯一。任意一个事件可进行拖拽来修改事件时间,并会触发回调函数。左侧日历集可决定属于该日日历集的事件是否显示。 +
+ +## 可配置项 + +### 日历: +| 参数 | 类型 | 作用 | +|----------------------|:------------------------------------------------------:|------------------------------------------| +| `date` | Date | 当前日期 | +| `calendarEvents` | CalendarEvent[] | 当前的日历事件数组 | +| `calendarEventGroups`| CalendarEventGroup[] | 当前的事件组数组 | +| `mode` | 'day' \| 'week' \| 'year' | 日历的显示模式 | +| `showCalendarGroup` | boolean | 是否显示日历事件组 | +| `shrinkFreeWeekend` | boolean | 是否压缩空闲周末 | +| `onDateClick` | (date: Date) => void | 日期点击时的回调函数 | +| `onDragChange` | (newState: DraggableState, oldState: DraggableState) => void | 拖动状态变化时的回调函数 | +| `onEventClick` | (e: CalendarEvent) => void | 事件点击时的回调函数 | +| `maxVisibleEvents` | number | 每个日期最大可见事件数量 | + | + + +### 事件属性: +calendarEvent: + +| 参数 | 类型 | 作用 | +| ------------- |:-------------:| ----- | +| `id` | string | 事件的唯一标识符 | +| `title` | string | 事件的标题 | +| `calendarEventGroup` | string | 事件所属的事件组ID | +| `date` | Date | 事件的日期 | +| `description` | string | 事件的描述(可选) | + +### 事件集: +CalendarEventGroups: + +| 参数 | 类型 | 作用 | +| ------------- |:-------------:| ----- | +| `id` | string | 事件组的唯一标识符 | +| `title` | string | 事件组的标题(可选) | +| `color` | string | 事件组的颜色(可选) | +| `checked` | boolean | 事件组是否被选中(可选) | + + diff --git a/lib/calendar/dev.ts b/lib/calendar/dev.ts new file mode 100644 index 0000000000..0c8e86e63e --- /dev/null +++ b/lib/calendar/dev.ts @@ -0,0 +1,121 @@ +import 'preact/debug'; +import 'zui-dev'; +import {Calendar} from './src/main'; + +onPageLoad(() => { + const calendarNormal = new Calendar('.calendar-normal', {}); + const calendarEvent = new Calendar('.calendar-event', { + maxVisibleEvents:2, + shrinkFreeWeekend:true, + calendarEvents:[{ + id: '1', + title: '观看天花板', + calendarEventGroup: '1', + date:new Date(2024, 10, 25, 9, 29, 12, 34), + description: '观看天花板', + }, + { + id: '2', + title: '和绿巨人睡觉', + calendarEventGroup: '2', + date:new Date(2024, 10, 26, 7, 12, 47), + description: '和绿巨人睡觉', + }, + { + id: '3', + title: '和路人乙捶打', + calendarEventGroup: '3', + date:new Date(2024, 10, 27, 7, 30, 30), + description: '和路人乙捶打', + }, + { + id: '4', + title: '自言自语', + calendarEventGroup: '4', + date:new Date(2024, 10, 28, 1, 58, 30), + description: '自言自语', + }, + { + id: '5', + title: '观望天花板', + calendarEventGroup: '5', + date:new Date(2024, 10, 29, 7, 30, 30), + description: '观望天花板', + }, + { + id: '6', + title: 'event3', + calendarEventGroup: '1', + date:new Date(2024, 11, 9), + description: '你好呀', + }, + ], + calendarEventGroups:[{ + id: '1', + title: 'event set 1', + color: '#f39988'}, { + id: '2', + title: 'event set 2', + color: '#d4a785'}, {id: '3', title: 'event set 3', color: '#29bcd2'}, + {id: '4', title: 'event set 4', color: '#7ec67e'}, + {id: '5', title: 'event set 5', color: '#f1bd73'}]}); + const calendar = new Calendar('.calendar', { + maxVisibleEvents:2, + shrinkFreeWeekend:true, + calendarEvents:[{ + id: '1', + title: '观看天花板', + calendarEventGroup: '1', + date:new Date(2024, 8, 25, 9, 29), + description: '观看天花板', + }, + { + id: '2', + title: '和绿巨人睡觉', + calendarEventGroup: '2', + date:new Date(2024, 8, 26, 7, 12, 47), + description: '和绿巨人睡觉', + }, + { + id: '3', + title: '和路人乙捶打', + calendarEventGroup: '3', + date:new Date(2024, 8, 27, 7, 30, 30), + description: '和路人乙捶打', + }, + { + id: '4', + title: '自言自语', + calendarEventGroup: '4', + date:new Date(2024, 8, 28, 1, 58, 30), + description: '自言自语', + }, + { + id: '5', + title: '观望天花板', + calendarEventGroup: '5', + date:new Date(2024, 8, 29, 7, 30, 30), + description: '观望天花板', + }, + { + id: '6', + title: 'event3', + calendarEventGroup: '1', + date:new Date(2024, 7, 9), + description: '你好呀', + }, + ], + calendarEventGroups:[{ + id: '1', + title: 'event set 1', + color: '#f39988'}, { + id: '2', + title: 'event set 2', + color: '#d4a785'}, {id: '3', title: 'event set 3', color: '#29bcd2'}, + {id: '4', title: 'event set 4', color: '#7ec67e'}, + {id: '5', title: 'event set 5', color: '#f1bd73'}]}); + + console.log('> calendar-normal instance created', calendarNormal); + console.log('> calendar-event instance created', calendarEvent); + console.log('> calendar instance created', calendar); +}); \ No newline at end of file diff --git a/lib/calendar/docs/lib/components/index.md b/lib/calendar/docs/lib/components/index.md new file mode 100644 index 0000000000..5867440a97 --- /dev/null +++ b/lib/calendar/docs/lib/components/index.md @@ -0,0 +1,59 @@ +# Calendar 日历 + +用于展示和管理事件的日历组件。它允许用户查看日期、添加特定日期的事件来安排日程。提供可配置的事件与日历集。 + +## 基本使用 + +使用HTML标签 `
` 作为容器来实现日历组件,可使用zui-create-calendar、new zui.Calendar()等指令来配置日历组件。 + +## 默认样式 + +
+ +## 自定义事件与事件集 + + + 事件是在日历中添加的具体事件,可自定义事件的颜色、文字、日期、拖拽回调函数、点击回调函数等。事件集是一组事件的集合与事件唯一关联。事件、事件集id都需要唯一。任意一个事件可进行拖拽来修改事件时间,并会触发回调函数。左侧日历集可决定属于该日日历集的事件是否显示。 +
+ +## 可配置项 + +### 日历: +| 参数 | 类型 | 作用 | +|----------------------|:------------------------------------------------------:|------------------------------------------| +| `date` | Date | 当前日期 | +| `calendarEvents` | CalendarEvent[] | 当前的日历事件数组 | +| `calendarEventGroups`| CalendarEventGroup[] | 当前的事件组数组 | +| `mode` | 'day' \| 'week' \| 'year' | 日历的显示模式 | +| `showCalendarGroup` | boolean | 是否显示日历事件组 | +| `shrinkFreeWeekend` | boolean | 是否压缩空闲周末 | +| `onDateClick` | (date: Date) => void | 日期点击时的回调函数 | +| `onDragChange` | (newState: DraggableState, oldState: DraggableState) => void | 拖动状态变化时的回调函数 | +| `onEventClick` | (e: CalendarEvent) => void | 事件点击时的回调函数 | +| `maxVisibleEvents` | number | 每个日期最大可见事件数量 | + | + + +### 事件属性: +calendarEvent: + +| 参数 | 类型 | 作用 | +| ------------- |:-------------:| ----- | +| `id` | string | 事件的唯一标识符 | +| `title` | string | 事件的标题 | +| `calendarEventGroup` | string | 事件所属的事件组ID | +| `date` | Date | 事件的日期 | +| `description` | string | 事件的描述(可选) | + +### 事件集: +CalendarEventGroups: + +| 参数 | 类型 | 作用 | +| ------------- |:-------------:| ----- | +| `id` | string | 事件组的唯一标识符 | +| `title` | string | 事件组的标题(可选) | +| `color` | string | 事件组的颜色(可选) | +| `checked` | boolean | 事件组是否被选中(可选) | + + diff --git a/lib/calendar/package.json b/lib/calendar/package.json new file mode 100644 index 0000000000..c6dfa1d7ec --- /dev/null +++ b/lib/calendar/package.json @@ -0,0 +1,38 @@ +{ + "name": "@zui/calendar", + "description": "ZUI Calendar", + "version": "0.0.1", + "keywords": [ + "js", + "cs", + "zui:component" + ], + "main": "src/main.ts", + "module": "src/main.ts", + "browser": "src/main.ts", + "dependencies": { + "@zui/core": "workspace:^0.0.1", + "@zui/helpers": "workspace:^0.0.1" + }, + "files": [ + "./src/**/*" + ], + "devDependencies": { + "zui-dev": "workspace:^0.0.1" + }, + "zui": { + "type": "component", + "displayName": "日历", + "notReady": true, + "wip": true, + "contributes": { + "css": [ + "class", + "var" + ], + "js": [ + "component" + ] + } + } +} diff --git a/lib/calendar/src/component/calendar-content.tsx b/lib/calendar/src/component/calendar-content.tsx new file mode 100644 index 0000000000..d269f621d9 --- /dev/null +++ b/lib/calendar/src/component/calendar-content.tsx @@ -0,0 +1,216 @@ +import {HElement} from '@zui/core'; +import '@zui/label'; +import {CalendarEventDom} from './calendar-event'; +import {Draggable} from '@zui/dnd'; + +import type {CalendarProps, CalendarEvent, CalendarContentState, EventState} from '../types'; +import type {Attributes, VNode} from 'preact'; +import {createRef} from 'preact'; +import {i18n} from '@zui/core'; +import '../i18n'; + +export class CalendarContent

extends HElement { + + dragEvent: Draggable | null = null; + + ref = createRef(); + + constructor(props: P) { + super(props); + this.state = {'isExtended':false, 'dateList': this.generateCalendarPageByDate(props.date), 'eventMap': this.generateCalendarEvents(), 'eventSetMap': this.props.eventSetMap}; + } + + //判断周末是否需要收缩 + judgeWeekendShouldShrink() { + let flag = false; + for (let i = 0; i < 6; i++) { + flag = flag || Boolean(this.formatDate(this.state.dateList[i][5].date)); + flag = flag || Boolean(this.formatDate(this.state.dateList[i][6].date)); + } + return !flag; + } + + formatDate(date: Date): string { + return date.toLocaleDateString('en-CA'); // 'en-CA' locale uses YYYY-MM-DD format + } + + componentWillUpdate(nextProps: Readonly

): void { + if (nextProps.eventSetMap !== this.props.eventSetMap) { + this.setState({'eventMap': this.generateCalendarEvents()}); + } + } + + generateCalendarEvents() { + const map = new Map(); + this.props.calendarEventMap?.forEach((value) => { + if (value.date !== undefined && this.props.calendarEventGroupMap?.get(value?.calendarEventGroup)?.checked) { + const dateKey = this.formatDate(value.date); // 将日期转换为 'YYYY-MM-DD' 格式 + if (!map.has(dateKey)) { + map.set(dateKey, [value]); + } else { + const currentEvents = map.get(dateKey); + if (currentEvents) { + map.set(dateKey, currentEvents.concat(value)); + } else { + map.set(dateKey, [value]); + } + } + } + }); + return map; + } + + componentDidMount() { + const {onDragChange} = this.props; + this.dragEvent = new Draggable(this.ref.current, { + target:'[target="true"]', + onChange(newState, oldState) { + if (onDragChange) { + onDragChange(newState, oldState); + } + }, + onDrop: (event, dragElement, dropElement) => { + console.log('onDrop', {event, dragElement, dropElement}); + if (dragElement && dropElement) { + const prevDate = new Date( dragElement.dataset.date || ''); + const moveToDate: Date = new Date( dropElement.dataset.date || ''); + console.log(prevDate, moveToDate); + console.log('Date', this.formatDate(prevDate), this.formatDate(moveToDate)); + const index: number = Number(dragElement.dataset.index); + const prevDateEvents = this.state.eventMap.get(this.formatDate(prevDate)) || []; + const moveToDateEvents = this.state.eventMap.get(this.formatDate(moveToDate)) || []; + console.log('prevDateEvents', prevDateEvents, 'moveToDateEvents', moveToDateEvents); + if (prevDateEvents) { + const emptyAry: CalendarEvent[] = []; + const eventToMove = index !== undefined && index >= 0 && index < prevDateEvents.length ? [prevDateEvents[index]] : emptyAry; + console.log('eventToMove', eventToMove); + eventToMove[0].date = new Date(moveToDate.getFullYear(), moveToDate.getMonth(), moveToDate.getDate(), prevDate.getHours(), prevDate.getMinutes(), prevDate.getSeconds()); + console.log('changeDate', eventToMove[0].date); + if (eventToMove && Array.isArray(moveToDateEvents)) { + moveToDateEvents.push(...eventToMove); + console.log('moveToDateEvents', moveToDateEvents); + const newEventMap = new Map(this.state.eventMap); + newEventMap.set(this.formatDate(moveToDate), moveToDateEvents); + console.log('set', moveToDate?.toISOString().split('T')[0], moveToDateEvents); + console.log('newMap', newEventMap); + if (index !== undefined && index >= 0 && index < prevDateEvents.length) { + prevDateEvents.splice(index, 1); + if (prevDateEvents.length === 0) { + newEventMap.delete(this.formatDate(prevDate)); + } else { + newEventMap.set(this.formatDate(prevDate), [...prevDateEvents]); + } + } + console.log(newEventMap); + this.setState({eventMap: newEventMap}); + } + } + } + }, + }); + } + + componentDidUpdate(prevProps: P) { + if (prevProps.date.getFullYear() !== this.props.date.getFullYear() || prevProps.date.getMonth() !== this.props.date.getMonth() || prevProps.date.getDate() !== this.props.date.getDate()) { + this.setState({'dateList': this.generateCalendarPageByDate(this.props.date)}); + } + } + + componentWillUnmount() { + this.dragEvent?.destroy(); + } + + generateCalendarPageByDate(date: Date): EventState[][] { + const year = date.getFullYear(); + const month = date.getMonth(); + + // 获取该月第一天的日期对象 + const firstDayOfMonth = new Date(year, month, 1); + let firstDayOfWeek = firstDayOfMonth.getDay() - 1; + if (firstDayOfWeek === -1) { + firstDayOfWeek = 6; + } + + // 初始化日期格子 + const page: EventState[][] = []; + const currentDate = new Date(firstDayOfMonth); + + // 填充前导空白天数 + let week: EventState[] = new Array(firstDayOfWeek).fill({date: null}); + + // 填充该月的日期 + for (let i = 0; i < 6; i++) { + for (let j = week.length; j < 7; j++) { + week.push({date: new Date(currentDate)}); + currentDate.setDate(currentDate.getDate() + 1); + } + page.push(week); + week = []; + } + const firstWeek = page[0]; + + // 填充第一周的前导日期 + for (let i = firstDayOfWeek; i >= 0; i--) { + const prevDate = new Date(firstDayOfMonth); + prevDate.setDate(prevDate.getDate() - (firstDayOfWeek - i)); + firstWeek[i] = {date: prevDate}; + } + + return page; + } + + //返回对应的样式 + getStyle(index: number, isShrinkWeekend?: boolean) { + if (isShrinkWeekend && (index === 5 || index === 6)) { + return {width:'10%'}; + } + if (isShrinkWeekend) { + return {width:'16%'}; + } + } + + render(): VNode { + const headerList: string[] = i18n.getLang('weekNames') || []; + const monthFormat = i18n.getLang('monthFormat'); + const {shrinkFreeWeekend, maxVisibleEvents, onEventClick} = this.props; + // 处理事件map + const tdStyle = {position:'relative', verticalAlign:'top'}; + const isShrinkWeekend = shrinkFreeWeekend && this.judgeWeekendShouldShrink(); + + return (

+ + + + { + headerList?.map((item, index) => { + return ; + }) + } + + + + {this.state.dateList?.map((line) => { + return ({ line.map((item, index) => { + return ; + })} + ); + })} + +
{item}
{}} style={{...tdStyle, ...this.getStyle(index, isShrinkWeekend)}} data-date = {new Date(item.date)} key={`${item.date.getMonth() + 1}-${item.date.getDate()}`} target='true' className={'calendar-td' + ' ' + (this.props.date.getFullYear() === item.date.getFullYear() && item.date.getMonth() + 1 === this.props.date.getMonth() + 1 ? 'is-current-month' : '') + (new Date().getFullYear() === item.date.getFullYear() && item.date.getMonth() + 1 === new Date().getMonth() + 1 && item.date.getDate() === new Date().getDate() ? '-today' : '')} > +
+
+ {item.date.getDate() == 1 ? : ''} +
{item.date.getDate()}
+
+ {this.state.eventMap ? ( + ) : null} +
); + } +} \ No newline at end of file diff --git a/lib/calendar/src/component/calendar-event.tsx b/lib/calendar/src/component/calendar-event.tsx new file mode 100644 index 0000000000..c03e42a9cb --- /dev/null +++ b/lib/calendar/src/component/calendar-event.tsx @@ -0,0 +1,103 @@ +import type {CalendarEventProps, CalendarEvent} from '../types'; +import {HElement} from '@zui/core'; +import type {Attributes, VNode} from 'preact'; +import {getUniqueCode} from '@zui/helpers/src/string-code'; +import {createRef} from 'preact'; + +export class CalendarEventDom

extends HElement { + calendarContentRef: preact.RefObject; + + constructor(props: P) { + super(props); + this.state = { + isExtend: false, + }; + this.calendarContentRef = createRef(); + } + + componentDidMount() { + this.updateHeight(); + } + + componentDidUpdate(prevProps: P, prevState: {isExtend: boolean}) { + if (prevProps.calendarEvents !== this.props.calendarEvents || prevState.isExtend !== this.state.isExtend) { + this.updateHeight(); + } + } + + calculateHeight(calendarEvents: CalendarEvent[]) { + let height = 0; + calendarEvents?.map((event) => { + height += Math.trunc((event.description?.length || 0) / 14) * 28; + }); + return height; + } + + updateHeight() { + const maxHeight = 310; + const {calendarEvents, maxVisibleEvents, calendarEventGroups} = this.props; + const isExtended = this.state.isExtend; + if (this.calendarContentRef.current && isExtended && calendarEvents && calendarEvents.length < 10) { + const totalHeight = this.calculateHeight(calendarEvents); + this.calendarContentRef.current.style.height = `${totalHeight}px`; + } else if (this.calendarContentRef.current && !isExtended && maxVisibleEvents) { + this.calendarContentRef.current.style.height = '110px'; + } else if (this.calendarContentRef.current && !isExtended && calendarEvents && calendarEvents.length > 10) { + this.calendarContentRef.current.style.height = `${maxHeight}px`; + } + if (this.calendarContentRef.current && (!calendarEvents || calendarEvents.length < 4)) { + this.calendarContentRef.current.style.height = '110px'; + } + if (!calendarEventGroups && this.calendarContentRef.current && calendarEvents && isExtended) { + this.calendarContentRef.current.style.height = `${this.calculateHeight(calendarEvents) + 20}px`; + } else if (!calendarEventGroups && this.calendarContentRef.current && calendarEvents && !isExtended && maxVisibleEvents) { + this.calendarContentRef.current.style.height = `${this.calculateHeight(calendarEvents.slice(0, maxVisibleEvents)) + 25}px`; + } + } + + getColor(event: CalendarEvent) { + const {eventSetMap, calendarEventGroups, calendarEventGroup} = this.props; + if (eventSetMap && eventSetMap.has(event.calendarEventGroup) && (calendarEventGroups)) { + let color = ''; + color = calendarEventGroup?.color || ''; + if (!color) { + calendarEventGroups?.forEach(element => { + if (element.id == event.calendarEventGroup) { + color = element.color || ''; + } + }); + } + return color; + } else if (calendarEventGroup) { + let color = ''; + color = calendarEventGroup?.color || ''; + return color; + } else { + const hueDistance = 43; + const saturation = 0.4; + const lightness = 0.6; + const colorCode = event?.calendarEventGroup ?? event?.calendarEventGroup.toString() ?? event.description; + const hue = (typeof colorCode === 'number' ? colorCode : getUniqueCode(colorCode)) * hueDistance % 360; + const background = `hsl(${hue},${saturation * 100}%,${lightness * 100}%)`; + return background; + } + } + + render(): VNode { + let {calendarEvents} = this.props; + const {maxVisibleEvents, onEventClick} = this.props; + const prevLen = calendarEvents?.length || ''; + if (maxVisibleEvents && !this.state.isExtend && calendarEvents && calendarEvents?.length > maxVisibleEvents) { + calendarEvents = calendarEvents.slice(0, maxVisibleEvents); + } + return (

+ { + calendarEvents?.map((event, index) => { + //判断它的groupId是否存在于eventSetMap中,如果存在,则取出对应的颜色,否则取出默认颜色 + return
onEventClick && onEventClick(event)} data-event={event.id} data-index={index} data-date ={new Date(event.date)} draggable={true} class="calendar-event-item" key={event.id}>{event.date.getHours() + ':' + event.date.getMinutes()}{event.description}
; + })} +
+ {maxVisibleEvents && Number(prevLen) > maxVisibleEvents && (this.state.isExtend ? {this.setState({isExtend:false});}} > : {this.setState({isExtend:true});}} >)}
+
); + } +} diff --git a/lib/calendar/src/component/calendar-header.tsx b/lib/calendar/src/component/calendar-header.tsx new file mode 100644 index 0000000000..6e3aa4354e --- /dev/null +++ b/lib/calendar/src/component/calendar-header.tsx @@ -0,0 +1,26 @@ +import {Attributes} from 'preact'; +import type {CalendarHeaderProps} from '../types'; +import {formatDate} from '@zui/helpers'; +import {HElement, VNode} from '@zui/core'; +import {Button} from '@zui/button/src/component'; +import {i18n} from '@zui/core'; +import '../i18n'; + +export class CalendarHeader

extends HElement { + render(props: CalendarHeaderProps): VNode { + const {date, onMonthChange, onDateChange, onShowCalendarGroup} = props; + const calendarSet = i18n.getLang('calendarSet'); + const today = i18n.getLang('today'); + const dateFormat = i18n.getLang('dateFormat'); + const formattedDate = formatDate(date, dateFormat); + return ( +

+
+ + + onMonthChange?.('prev')}> + onMonthChange?.('next')} >
+
{formattedDate}
+ ); + } +} \ No newline at end of file diff --git a/lib/calendar/src/component/calendar-sidebar.tsx b/lib/calendar/src/component/calendar-sidebar.tsx new file mode 100644 index 0000000000..cf05e900dd --- /dev/null +++ b/lib/calendar/src/component/calendar-sidebar.tsx @@ -0,0 +1,44 @@ +import {Attributes} from 'preact'; +import type {CalendarSidebarProps, CalendarEventGroup} from '../types'; +import {HElement, VNode} from '@zui/core'; +import '../../../checkbox/src/component/index'; +import '../i18n'; +import '../../../checkbox/src/style/index.css'; + + +//这个组件只需要渲染事件集,勾选后是否显示事件就行了 + +export class CalendarSidebar

extends HElement { + + groupSetManege(groupId: string, isChecked: boolean) { + const {calendarGroupMap, setCalendarGroupMap} = this.props; + const group = calendarGroupMap?.get(groupId); + const newMap = new Map(calendarGroupMap); + if (group && newMap && setCalendarGroupMap) { + group.checked = isChecked; + newMap?.set(groupId, group); + console.log(newMap); + setCalendarGroupMap(newMap); + } + } + + renderEvent(calendarEventGroups: Map): VNode | VNode[] | null { + const result = []; + for (const [key, value] of calendarEventGroups.entries()) { + if (value && key) { + result.push(); + } + } + return result; + } + + render(): VNode { + const {calendarGroupMap} = this.props; + return (

{this.renderEvent(calendarGroupMap || new Map())}
); + } +} \ No newline at end of file diff --git a/lib/calendar/src/component/calendar.tsx b/lib/calendar/src/component/calendar.tsx new file mode 100644 index 0000000000..ef8c3219a5 --- /dev/null +++ b/lib/calendar/src/component/calendar.tsx @@ -0,0 +1,150 @@ +import {HElement} from '@zui/core'; +import {CalendarSidebar} from './calendar-sidebar'; +import {CalendarHeader} from './calendar-header'; +import {CalendarContent} from './calendar-content'; + +import type {CalendarEventGroup, CalendarProps, CalendarSidebarProps, CalendarEvent, CalendarState, CalendarHeaderProps} from '../types'; +import type {Attributes, ComponentChildren, VNode} from 'preact'; + +import '../i18n'; + +export class Calendar

extends HElement { + + protected _abort?: AbortController; + + constructor(props: P) { + super(props); + //初始化日历集与日历状态 + this.state = {'date': new Date(), 'showCalendarGroup': false, 'calendarEventMap':this.generateEventMap(this.props.calendarEvents), 'calendarGroupMap': this.generateCalendarGroupMap(this.props.calendarEventGroups), 'eventSetMap': this.generateCalendarSetEvents()}; + } + + //给予Sidebar的事件集与事件的对应关系更新 + protected setCalendarGroupMap(eventSetMap: Map): void { + this.setState({'calendarGroupMap': eventSetMap}); + this.setState({'eventSetMap': this.generateCalendarSetEvents()}); + } + + //生成事件的唯一Map + generateEventMap(calendarEvents?: CalendarEvent[]): Map { + const map = new Map(); + calendarEvents?.map((item) => { + if (item.id !== undefined && !map.has(item.id)) { + map.set(item.id, item); + } else { + throw new Error('[ZUI] CalendarEvent id must be unique'); + } + }); + return map; + } + + //生成日历集的唯一Map + generateCalendarGroupMap(calendarEventGroups?: CalendarEventGroup[]): Map { + const map = new Map(); + calendarEventGroups?.map((item) => { + if (item.id !== undefined && !map.has(item.id)) { + item.checked = item.checked || true; + map.set(item.id, item); + } else { + throw new Error('[ZUI] CalendarEventGroup id must be unique'); + } + }); + return map; + } + + //生成事件集与事件的对应关系 + generateCalendarSetEvents(): Map { + const map = new Map(); + if (this.state?.calendarEventMap && this.state?.calendarGroupMap) { + this.state.calendarGroupMap?.forEach((item) => { + if (item.id !== undefined && item.checked) { + this.state.calendarEventMap?.forEach(element => { + if (element.calendarEventGroup == item.id) { + map.set(item.id, map.get(item.id)?.concat(element) || [element]); + } + }); + } + }); + } else { + this.props.calendarEventMap?.forEach((value) => { + if (value.date !== undefined) { + const dateKey = new Date(value.date).toISOString().split('T')[0]; // 将日期转换为 'YYYY-MM-DD' 格式 + if (!map.has(dateKey)) { + map.set(dateKey, [value]); + } else { + const currentEvents = map.get(dateKey); + if (currentEvents) { + map.set(dateKey, currentEvents.concat(value)); + } else { + map.set(dateKey, [value]); + } + } + } + }); + } + return map; + } + + + protected _renderSidebar():ComponentChildren { + const CalendarSidebarOptions: CalendarSidebarProps = { + eventSetMap:this.state.eventSetMap, + calendarGroupMap: this.state.calendarGroupMap || new Map(), + setCalendarGroupMap:this.setCalendarGroupMap.bind(this), + onShowCalendarGroup:()=>{this.setState({'showCalendarGroup':!this.state.showCalendarGroup});}, + }; + return (); + } + + + protected _renderHeader(): ComponentChildren { + const CalendarHeaderOptions: CalendarHeaderProps = { + date: this.state.date, + onShowCalendarGroup:()=>{this.setState({'showCalendarGroup':!this.state.showCalendarGroup});}, + onDateChange: (newDate: Date) => { this.setState({'date':newDate});}, + onMonthChange: (type: string) => { + const currentMonth = this.state.date.getMonth(); + const newDate = new Date(this.state.date); + if (type === 'prev') { + if (currentMonth === 0) { + newDate.setFullYear(this.state.date.getFullYear() - 1); + newDate.setMonth(11); + } else { + newDate.setMonth(currentMonth - 1); + } + } else if (type === 'next') { + if (currentMonth === 11) { + newDate.setFullYear(this.state.date.getFullYear() + 1); + newDate.setMonth(0); + } else { + newDate.setMonth(currentMonth + 1); + } + } + this.setState({date: newDate}); + }, + + }; + return (); + } + + + protected _renderContent(): ComponentChildren { + const CalendarOptions: CalendarProps = { + date: this.state.date, + showCalendarGroup: this.state.showCalendarGroup, + calendarEventMap: this.state.calendarEventMap, + calendarEventGroupMap: this.state.calendarGroupMap, + eventSetMap: this.state.eventSetMap || new Map(), + onDateClick: this.props.onDateClick, + onEventClick: this.props.onEventClick, + onDragChange: this.props.onDragChange, + shrinkFreeWeekend: this.props.shrinkFreeWeekend, + maxVisibleEvents: this.props.maxVisibleEvents, + mode: this.props.mode, + }; + return (); + } + + render(): VNode { + return (

{this.state.showCalendarGroup &&
{this._renderSidebar()}
}
{this._renderHeader()}{ this._renderContent()}
); + } +} \ No newline at end of file diff --git a/lib/calendar/src/component/event-item.tsx b/lib/calendar/src/component/event-item.tsx new file mode 100644 index 0000000000..318732f554 --- /dev/null +++ b/lib/calendar/src/component/event-item.tsx @@ -0,0 +1,16 @@ +import {RenderableProps, Attributes} from 'preact'; +import type {EventItemProps} from '../types'; +import {CalendarEventDom} from './calendar-event'; +import {HElement, VNode} from '@zui/core'; +import '../i18n'; + +export class EventItem

extends HElement { + + render(props: RenderableProps

): VNode { + const {calendarEvent, calendarEventGroup, maxVisibleEvents} = props; + return (

+
{calendarEventGroup?.title}
+ +
); + } +} \ No newline at end of file diff --git a/lib/calendar/src/component/index.tsx b/lib/calendar/src/component/index.tsx new file mode 100644 index 0000000000..3e92238830 --- /dev/null +++ b/lib/calendar/src/component/index.tsx @@ -0,0 +1,3 @@ +import '@zui/button'; + +export * from './calendar'; \ No newline at end of file diff --git a/lib/calendar/src/i18n/index.ts b/lib/calendar/src/i18n/index.ts new file mode 100644 index 0000000000..80f6d8f8a9 --- /dev/null +++ b/lib/calendar/src/i18n/index.ts @@ -0,0 +1,31 @@ +import {i18n} from '@zui/core'; + +i18n.addLang({ + 'zh_cn': { + calendarSet:'日历集', + today: '今天', + yearFormat: '年', + monthFormat: '月', + weekNames: [ '一', '二', '三', '四', '五', '六', '日'], + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], + dateFormat: 'YYYY年MM月dd日', + }, + 'zh_tw': { + calendarSet:'日曆集', + today: '今天', + yearFormat: '年', + monthFormat: '月', + weekNames: ['一', '二', '三', '四', '五', '六', '日'], + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], + dateFormat: 'YYYY年MM月dd日', + }, + en: { + calendarSet:'CalendarSet', + today: 'Today', + yearFormat: '', + monthFormat: '', + weekNames: [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + monthNames: ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sept.', 'Oct.', 'Nov.', 'Dec.'], + dateFormat: 'MM/dd/YYYY', + }, +}); diff --git a/lib/calendar/src/main.ts b/lib/calendar/src/main.ts new file mode 100644 index 0000000000..09d265f6a0 --- /dev/null +++ b/lib/calendar/src/main.ts @@ -0,0 +1,3 @@ +export * from './vanilla'; +export * from './types'; +import './style/index.css'; \ No newline at end of file diff --git a/lib/calendar/src/style/calendar-content.css b/lib/calendar/src/style/calendar-content.css new file mode 100644 index 0000000000..ff99a79b31 --- /dev/null +++ b/lib/calendar/src/style/calendar-content.css @@ -0,0 +1,51 @@ +.calendar{ + @apply -flex -w-full; +} +.calendar-table{ + @apply -w-full; +} + +.calendar-body-header{ + @apply -px-2 -py-1 -flex -justify-end -items-center; +} + +.calendar-content-header { + @apply -px-1 -py-1 -border -border-gray-300; + border-width:0 1px; +} + +.calendar-td{ + @apply -text-start -border; + width: 14.2857%; +} + +.calendar-header-part{ + @apply -px-4 -py-2 -text-center -text-gray-700; +} + +.calendar-body-header-month{ + @apply -mr-1; +} +.calendar-body-part{ + + @apply -flex -flex-col -justify-start; +} + + +.is-current-month{ + @apply -bg-white; +} + +.is-current-month-today{ + /* @apply -bg-gray-50; */ + @apply: -bg-primary; + border-image: linear-gradient(to right, #367df1, #f1367d); + border-image-slice: 1; + border:solid 2px #3b82f6; + background: none; + /* color: white; */ +} + +.is-today .calendar-body-header{ + @apply -bg-gray-200; +} diff --git a/lib/calendar/src/style/calendar-event.css b/lib/calendar/src/style/calendar-event.css new file mode 100644 index 0000000000..c231a57ddb --- /dev/null +++ b/lib/calendar/src/style/calendar-event.css @@ -0,0 +1,31 @@ +.calendar-event-item { + @apply -relative -px-1 -border -outline-2 -outline-gray-200/50 -text-white; +} +.calendar-event-item-time { + @apply -w-7 -inline-block; + /* display: inline-block; */ +} +.calendar-body-bottom{ + @apply -absolute -right-0.5 -bottom-0.5 -cursor-pointer; +} +.chevron-left, +.chevron-right, +.chevron-down, +.chevron-up { +@apply -w-[1em] -h-[1em] -relative -inline-flex -items-center -justify-center; +} +.chevron-left::before, +.chevron-right::before, +.chevron-down::before, +.chevron-up::before { +@apply -content-[''] -block -w-[0.6923077em] -h-[0.6923077em] -rotate-45 -border-current -border -border-t-0 -border-l-0 -origin-center --translate-y-0.5; +} +.chevron-left::before { +@apply -rotate-[135deg] -translate-y-0 -translate-x-0.5; +} +.chevron-right::before { +@apply --rotate-45 -translate-y-0 --translate-x-0.5; +} +.chevron-up::before { +@apply -rotate-[225deg] -translate-y-0.5; +} \ No newline at end of file diff --git a/lib/calendar/src/style/calendar-header.css b/lib/calendar/src/style/calendar-header.css new file mode 100644 index 0000000000..a8918f65ac --- /dev/null +++ b/lib/calendar/src/style/calendar-header.css @@ -0,0 +1,24 @@ + +.calendar-header { + @apply -flex -items-center -justify-between -py-2 -border-b -border-gray-200; +} + +.calendar-header-left { + @apply -flex -items-center; +} + +.calendar-content { + @apply -text-lg; +} + +.calendar-header-left .btn-front { + @apply -mr-4 -px-4 -py-2; +} + +.btn-left { + @apply -bg-gray-100 -rounded-r-none; +} + +.btn-right { + @apply -bg-gray-100 -rounded-l-none; +} \ No newline at end of file diff --git a/lib/calendar/src/style/calendar-sidebar.css b/lib/calendar/src/style/calendar-sidebar.css new file mode 100644 index 0000000000..a888011467 --- /dev/null +++ b/lib/calendar/src/style/calendar-sidebar.css @@ -0,0 +1,15 @@ +.component{ + @apply -flex -flex-row -w-full; +} +.component-calendar{ + @apply -w-full; +} +.calendar-eventGroup{ + @apply -relative; +} + +.calendar-sidebar{ + @apply -w-1/6 -flex-row -pr-2; + @apply -py-2; + writing-mode: horizontal-tb; +} \ No newline at end of file diff --git a/lib/calendar/src/style/index.css b/lib/calendar/src/style/index.css new file mode 100644 index 0000000000..da6823d76d --- /dev/null +++ b/lib/calendar/src/style/index.css @@ -0,0 +1,4 @@ +@import './calendar-header.css'; +@import './calendar-content.css'; +@import './calendar-event.css'; +@import './calendar-sidebar.css' \ No newline at end of file diff --git a/lib/calendar/src/types/calendar-event-props.ts b/lib/calendar/src/types/calendar-event-props.ts new file mode 100644 index 0000000000..b18a3751b9 --- /dev/null +++ b/lib/calendar/src/types/calendar-event-props.ts @@ -0,0 +1,31 @@ +import type {HElementProps} from '@zui/core'; +export interface CalendarEvent { + id:string; + title: string; + calendarEventGroup: string; + date: Date; + description?: string; +} + +export interface EventItemProps extends HElementProps { + calendarEvent?: CalendarEvent[]; + calendarEventGroup?: CalendarEventGroup; + maxVisibleEvents?: number; +} + +export interface CalendarEventProps extends HElementProps { + date?: string; + calendarEvents?: CalendarEvent[]; + eventSetMap?:Map; + onEventClick?: (e: CalendarEvent) => void; + calendarEventGroups?: Map; + calendarEventGroup?: CalendarEventGroup; + maxVisibleEvents?: number; +} +//事件集 +export interface CalendarEventGroup { + id: string; + title?: string; + color?: string; + checked?: boolean; +} \ No newline at end of file diff --git a/lib/calendar/src/types/calendar-header-props.ts b/lib/calendar/src/types/calendar-header-props.ts new file mode 100644 index 0000000000..c418b91926 --- /dev/null +++ b/lib/calendar/src/types/calendar-header-props.ts @@ -0,0 +1,7 @@ +import type {HElementProps} from '@zui/core'; +export interface CalendarHeaderProps extends HElementProps { + date: Date; + onShowCalendarGroup: () => void; + onDateChange: (date: Date) => void; + onMonthChange: (direction: 'prev' | 'next') => void; +} \ No newline at end of file diff --git a/lib/calendar/src/types/calendar-props.ts b/lib/calendar/src/types/calendar-props.ts new file mode 100644 index 0000000000..a66d493853 --- /dev/null +++ b/lib/calendar/src/types/calendar-props.ts @@ -0,0 +1,20 @@ +import type {ClassNameLike, CustomContentType, HElementProps} from '@zui/core'; +import {CalendarEvent, CalendarEventGroup} from './calendar-event-props'; +import {DraggableState} from '@zui/dnd/src/types'; +import {Draggable} from '@zui/dnd/src/vanilla'; +export interface CalendarProps extends HElementProps { + date: Date; + eventSetMap?: Map; + calendarEventMap?: Map; + calendarEventGroupMap?: Map; + calendarEvents?: CalendarEvent[]; + calendarEventGroups?: CalendarEventGroup[]; + mode?: 'day' | 'week' | 'year'; + dragEvent?: Draggable; + showCalendarGroup?: boolean; + shrinkFreeWeekend?: boolean; + onDateClick?: (date: Date) => void; + onDragChange?:( newState: DraggableState, oldState: DraggableState) => void; + onEventClick?: (e: CalendarEvent) => void; + maxVisibleEvents?: number; +} \ No newline at end of file diff --git a/lib/calendar/src/types/calendar-sidebar-props.ts b/lib/calendar/src/types/calendar-sidebar-props.ts new file mode 100644 index 0000000000..56ea406a95 --- /dev/null +++ b/lib/calendar/src/types/calendar-sidebar-props.ts @@ -0,0 +1,11 @@ +import type {HElementProps} from '@zui/core'; +import type {CalendarEvent} from './calendar-event-props'; +import type {CalendarEventGroup} from './calendar-event-props'; + +export interface CalendarSidebarProps extends HElementProps { + onShowCalendarGroupChange?: () => void; + calendarGroupMap?:Map; + eventSetMap?:Map; + maxVisibleEvents?: number; + setCalendarGroupMap?: (eventSetMap: Map) => void; +} \ No newline at end of file diff --git a/lib/calendar/src/types/calendar-state.ts b/lib/calendar/src/types/calendar-state.ts new file mode 100644 index 0000000000..07e3fd4bcd --- /dev/null +++ b/lib/calendar/src/types/calendar-state.ts @@ -0,0 +1,22 @@ +import {Draggable} from '@zui/dnd/src/vanilla'; +import {CalendarEvent, CalendarEventGroup} from './calendar-event-props'; +export interface CalendarState { + date: Date; + showCalendarGroup: boolean; + eventSetMap: Map; + calendarEventMap?:Map; + calendarGroupMap?:Map; +} + +export interface CalendarContentState { + isExtended: boolean; + dateList:{date:Date} [][]; + calendarEventMap?: Map; + eventSetMap?: Map; + eventMap:Map; + dragEvent?: Draggable; +} + +export interface EventState { + date: Date; +} \ No newline at end of file diff --git a/lib/calendar/src/types/index.ts b/lib/calendar/src/types/index.ts new file mode 100644 index 0000000000..e29a5433be --- /dev/null +++ b/lib/calendar/src/types/index.ts @@ -0,0 +1,5 @@ +export * from './calendar-event-props'; +export * from './calendar-header-props'; +export * from './calendar-props'; +export * from './calendar-state'; +export * from './calendar-sidebar-props'; \ No newline at end of file diff --git a/lib/calendar/src/vanilla/calendar.ts b/lib/calendar/src/vanilla/calendar.ts new file mode 100644 index 0000000000..93db06cac1 --- /dev/null +++ b/lib/calendar/src/vanilla/calendar.ts @@ -0,0 +1,12 @@ +import {ComponentFromReact} from '@zui/core'; +import {Calendar as CalendarReact} from '../component'; + +import type {CalendarProps} from '../types'; + +export class Calendar extends ComponentFromReact { + static NAME = 'Calendar'; + + static Component = CalendarReact; + + static replace = true; +} \ No newline at end of file diff --git a/lib/calendar/src/vanilla/index.ts b/lib/calendar/src/vanilla/index.ts new file mode 100644 index 0000000000..04be2de8be --- /dev/null +++ b/lib/calendar/src/vanilla/index.ts @@ -0,0 +1,2 @@ +export * from './calendar'; +// export * from './card-list'; \ No newline at end of file diff --git a/lib/calendar/tsconfig.json b/lib/calendar/tsconfig.json new file mode 100644 index 0000000000..d32482380c --- /dev/null +++ b/lib/calendar/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact" + }, + "include": [ + "src", + "docs", + ] + } \ No newline at end of file diff --git a/lib/common-list/src/component/common-list.tsx b/lib/common-list/src/component/common-list.tsx index 81d69844c4..f331b985c0 100644 --- a/lib/common-list/src/component/common-list.tsx +++ b/lib/common-list/src/component/common-list.tsx @@ -276,6 +276,13 @@ export class CommonList

ext } else if (!Array.isArray(items)) { items = []; } + const {getItems} = props; + if (getItems) { + const result = getItems.call(this, items as Item[]); + if (result !== undefined) { + return result; + } + } return items as Item[]; } diff --git a/lib/common-list/src/types/common-list-props.ts b/lib/common-list/src/types/common-list-props.ts index 1bbfd4530b..a463b986fc 100644 --- a/lib/common-list/src/types/common-list-props.ts +++ b/lib/common-list/src/types/common-list-props.ts @@ -50,6 +50,14 @@ export interface CommonListProps extends HElementProps { */ getItem?: (item: T, index: number) => T | false | undefined; + /** + * Get items, can convert original items. + * + * @param items - The list items. + * @returns The modified list items. + */ + getItems?: (items: Item[]) => Item[] | undefined; + /** * Item render functions. */ diff --git a/lib/contextmenu/README.md b/lib/contextmenu/README.md index b52a79f30d..b4593e4577 100644 --- a/lib/contextmenu/README.md +++ b/lib/contextmenu/README.md @@ -1,5 +1,27 @@ # 右键菜单 +## 弹出面板中的右键菜单 + +```html:example + +

+ + + + + +``` + + ## 被动打开目标菜单 ```html:example diff --git a/lib/contextmenu/dev.ts b/lib/contextmenu/dev.ts index a64658000e..ff28e77b6a 100644 --- a/lib/contextmenu/dev.ts +++ b/lib/contextmenu/dev.ts @@ -6,7 +6,7 @@ import 'zui-dev'; import 'preact/debug'; import {ContextMenu} from './src/main'; -onPageLoad(() => { +onPageUpdate(() => { const contextMenu = new ContextMenu('#menuToggle1', { menu: { items: [ diff --git a/lib/contextmenu/src/vanilla/contextmenu.ts b/lib/contextmenu/src/vanilla/contextmenu.ts index 6d1184de12..2a11520e16 100644 --- a/lib/contextmenu/src/vanilla/contextmenu.ts +++ b/lib/contextmenu/src/vanilla/contextmenu.ts @@ -1,4 +1,3 @@ -import {$} from '@zui/core'; import {Dropdown} from '@zui/dropdown'; import type {ContextMenuOptions} from '../types/contextmenu-options'; @@ -22,13 +21,6 @@ export class ContextMenu extends Dropdown { } return options; } - - protected _onClickDoc = (event: MouseEvent) => { - const $target = $(event.target as HTMLElement); - if (!$target.closest('.not-hide-menu,.form-control,input,label,.nested-toggle-icon').length) { - this.hide(); - } - }; } ContextMenu.register(); diff --git a/lib/core/src/ajax/ajax.ts b/lib/core/src/ajax/ajax.ts index 5f3b00c25a..c894d2ba97 100644 --- a/lib/core/src/ajax/ajax.ts +++ b/lib/core/src/ajax/ajax.ts @@ -1,6 +1,6 @@ import {$} from '@zui/core'; -import type {AjaxCallbackMap, AjaxCompleteCallback, AjaxErrorCallback, AjaxFormItemValue, AjaxSetting, AjaxSuccessCallback} from './types'; +import type {AjaxBeforeSendCallback, AjaxCallbackMap, AjaxCompleteCallback, AjaxErrorCallback, AjaxFormItemValue, AjaxSetting, AjaxSuccessCallback} from './types'; function setHeader(headers: HeadersInit, name: string, value: string) { if (headers instanceof Headers) { @@ -72,7 +72,9 @@ export function createFormData(data: string | FormData | URLSearchParams | Recor return formData; } -export class Ajax { +export class Ajax { + static globalBeforeSends: AjaxBeforeSendCallback[] = []; + private declare _timeoutID: number; private _controller: AbortController; @@ -199,9 +201,6 @@ export class Ajax { ...initOptions } = this.setting; - if (beforeSend?.(initOptions) === false) { - return; - } if (type) { initOptions.method = type; } @@ -227,6 +226,21 @@ export class Ajax { this.abort(); }); } + + const beforeSends = [...(this.constructor as typeof Ajax).globalBeforeSends, beforeSend]; + for (const callback of beforeSends) { + if (!callback) { + continue; + } + const result = callback.call(this, initOptions); + if (result === false) { + return; + } + if (result) { + Object.assign(initOptions, result); + } + } + if (success) { this.success(success); } @@ -290,6 +304,9 @@ export class Ajax { throw new Error(statusText); } } catch (err) { + if (this.data === undefined && data !== undefined) { + this.data = data as T; + } error = err as Error; let skipTriggerError = false; if (error.name === 'AbortError') { diff --git a/lib/core/src/ajax/fetcher.ts b/lib/core/src/ajax/fetcher.ts index 3a360b5210..f9be081d9b 100644 --- a/lib/core/src/ajax/fetcher.ts +++ b/lib/core/src/ajax/fetcher.ts @@ -1,15 +1,16 @@ +import {formatString} from '@zui/helpers/src/format-string'; import {$} from '../cash'; import {Ajax} from './ajax'; import type {AjaxSetting, FetcherSetting} from './types'; -export async function fetchData(setting: FetcherSetting, args: A = ([] as unknown as A), extraAjaxSetting?: Partial | ((ajaxSetting: AjaxSetting) => Partial)): Promise { +export async function fetchData(setting: FetcherSetting, args: A = ([] as unknown as A), extraAjaxSetting?: Partial | ((ajaxSetting: AjaxSetting) => Partial), thisObj?: THIS, ajaxGetter?: (ajax: Ajax) => void): Promise { const ajaxSetting = {throws: true, dataType: 'json'} as AjaxSetting; if (typeof setting === 'string') { ajaxSetting.url = setting; } else if (typeof setting === 'object') { $.extend(ajaxSetting, setting); } else if (typeof setting === 'function') { - const result = setting(...args); + const result = setting.call(thisObj as THIS, ...args); if (result instanceof Promise) { const data = await result; return data; @@ -19,7 +20,11 @@ export async function fetchData(setting if (extraAjaxSetting) { $.extend(ajaxSetting, typeof extraAjaxSetting === 'function' ? extraAjaxSetting(ajaxSetting) : extraAjaxSetting); } + if (ajaxSetting.url) { + ajaxSetting.url = formatString(ajaxSetting.url, ...args); + } const ajax = new Ajax(ajaxSetting); + ajaxGetter?.(ajax); const [data] = await ajax.send(); return data as T; } @@ -30,7 +35,7 @@ export function isFetchSetting(setting: FetcherSetting | unknown): setting is Fe declare module 'cash-dom' { interface CashStatic { - fetch(setting: FetcherSetting, args?: A, extraAjaxSetting?: Partial | ((ajaxSetting: AjaxSetting) => Partial)): Promise + fetch(setting: FetcherSetting, args: A, extraAjaxSetting?: Partial | ((ajaxSetting: AjaxSetting) => Partial), thisObj?: THIS, ajaxGetter?: (ajax: Ajax) => void): Promise; } } diff --git a/lib/core/src/ajax/types.ts b/lib/core/src/ajax/types.ts index c385e12f2b..eeb8abace9 100644 --- a/lib/core/src/ajax/types.ts +++ b/lib/core/src/ajax/types.ts @@ -43,6 +43,6 @@ export type FetcherUrl = string; export type FetcherInit = AjaxSetting; -export type FetcherFn = (...args: A) => Promise | T; +export type FetcherFn = (this: THIS, ...args: A) => Promise | T; -export type FetcherSetting = FetcherUrl | FetcherInit | FetcherFn; +export type FetcherSetting = FetcherUrl | FetcherInit | FetcherFn; diff --git a/lib/core/src/component/component.ts b/lib/core/src/component/component.ts index af68e7fd0c..edf563c0f5 100644 --- a/lib/core/src/component/component.ts +++ b/lib/core/src/component/component.ts @@ -133,7 +133,7 @@ export class Component>) { - const {KEY, DATA_KEY, DEFAULT, MULTI_INSTANCE, NAME, ATTR_KEY, ALL, TYPED_ALL} = this.constructor; + const {KEY, DATA_KEY, MULTI_INSTANCE, NAME, ATTR_KEY, ALL, TYPED_ALL} = this.constructor; if (!NAME) { throw new Error('[ZUI] The component must have a "NAME" static property.'); @@ -149,8 +149,7 @@ export class Component; - this.setOptions(options); + this.resetOptions(options); this._key = this.options.key ?? `__${gid}`; if (ALL.has(element)) { @@ -177,10 +176,12 @@ export class Component { this._inited = true; await this.afterInit(); this.emit('inited', this.options); + this.options.$onInited?.call(this); }); } @@ -288,7 +289,7 @@ export class Component>, reset?: boolean): ComponentOptions { if (reset) { - this._options = { + const finalOptions = { ...this.constructor.DEFAULT, ...(options?.$optionsFromDataset !== false ? this.$element.dataset() : {}), ...options, } as ComponentOptions; + const {$options} = finalOptions; + if ($options) { + const extraOptions = typeof $options === 'function' ? $options.call(this, this.element, finalOptions) : $options; + if (extraOptions) { + $.extend(finalOptions, extraOptions); + } + delete finalOptions.$options; + } + this._options = finalOptions; } else if (options) { $.extend(this._options, options); } return this._options!; } + resetOptions(options?: Partial>) { + return this.setOptions(options, true); + } + /** * Emit a component event. * @param event The event name. @@ -522,8 +538,12 @@ export class Component { ALL.get(element)?.forEach(checkInstance); }); - } else { + } else if (this !== Component) { TYPED_ALL.get(this.NAME)?.forEach(checkInstance); + } else { + ALL.forEach((components) => { + components.forEach(checkInstance); + }); } return list.sort((a, b) => a.gid - b.gid); } @@ -565,6 +585,8 @@ export class Component { diff --git a/lib/core/src/component/types/component-options.ts b/lib/core/src/component/types/component-options.ts index c2d9ea0483..de5209270f 100644 --- a/lib/core/src/component/types/component-options.ts +++ b/lib/core/src/component/types/component-options.ts @@ -15,4 +15,9 @@ export type ComponentBaseOptions = { /** * The component options. */ -export type ComponentOptions = ComponentBaseOptions & O; +export type ComponentOptions = ComponentBaseOptions & O & { + $options?: Partial | ((element: HTMLElement, options: Partial) => Partial | undefined); + $onCreate?: () => void; + $onInited?: () => void; + $onDestroy?: () => void; +}; diff --git a/lib/core/src/config/index.ts b/lib/core/src/config/index.ts index d65fc93a54..b1ae4995d6 100644 --- a/lib/core/src/config/index.ts +++ b/lib/core/src/config/index.ts @@ -1,9 +1,11 @@ +/* eslint-disable @typescript-eslint/naming-convention */ -// eslint-disable-next-line @typescript-eslint/naming-convention declare const __APP_VERSION__: string; -// eslint-disable-next-line @typescript-eslint/naming-convention declare const __BUILD_TIME__: number; +declare const __BUILD_MODE__: string; + export const VERSION = __APP_VERSION__; export const BUILD = __BUILD_TIME__; +export const BUILD_MODE = __BUILD_MODE__; diff --git a/lib/core/src/dom/get-lib.ts b/lib/core/src/dom/get-lib.ts index 833c20cfb0..c1fc8f4793 100644 --- a/lib/core/src/dom/get-lib.ts +++ b/lib/core/src/dom/get-lib.ts @@ -12,6 +12,14 @@ export interface LoadJSOptions { integrity?: string; } +export interface LoadJSModuleOptions extends LoadJSOptions { + type: 'module', + imports?: string | Record; + srcList?: {src: string, imports?: string | Record}[]; + globalVar?: boolean | string; + resolve?: (result: T) => void; +} + export interface LoadCSSOptions { src: string; id?: string; @@ -77,7 +85,7 @@ $.registerLib = function (name: string, options: GetLibOptions): void { /** * Load a CSS file by append a link tag to the head. */ -function loadCSS(options: string | LoadCSSOptions): Promise { +export function loadCSS(options: string | LoadCSSOptions): Promise { return new Promise((resolve, reject) => { if (typeof options === 'string') { options = {src: options}; @@ -104,7 +112,7 @@ function loadCSS(options: string | LoadCSSOptions): Promise { }); } -function loadJS(options: string | LoadJSOptions): Promise { +export function loadJS(options: string | LoadJSOptions): Promise { return new Promise((resolve, reject) => { if (typeof options === 'string') { options = {src: options}; @@ -147,6 +155,71 @@ function loadJS(options: string | LoadJSOptions): Promise { }); } +export function loadModule(options: string | LoadJSModuleOptions): Promise { + return new Promise((resolve) => { + if (typeof options === 'string') { + options = {type: 'module', src: options}; + } + const {src, imports, srcList = [], id} = options; + if (src) { + srcList.unshift({src, imports}); + } + + const srcListID = srcList.map(x => x.src).join(','); + const $oldScripts = $(id ? `#${id}` : `script[data-src-list="${srcListID}"]`); + if ($oldScripts.length) { + const moduleResult = $oldScripts.data('module'); + if (moduleResult) { + resolve(moduleResult); + } else { + const callbacks = $oldScripts.data('resolves') || []; + callbacks.push(resolve); + $oldScripts.data('resolves', callbacks); + } + return; + } + const {async = true, defer = false, integrity, globalVar, resolve: resolveCallback} = options; + const script = document.createElement('script'); + const resolveID = `zui-module-resolve-${$.guid++}`; + const $script = $(script); + Object.assign(window, {[resolveID]: (result: T) => { + const scriptResolves: ((result: T) => void)[] = $script.data('module', result).data('resolves') || []; + scriptResolves.forEach(x => x(result)); + $script.removeData('resolves'); + resolveCallback?.(result); + resolve(result); + delete (window as unknown as Record)[resolveID]; + }}); + script.async = async; + script.defer = defer; + script.type = 'module'; + $script.attr('data-src-list', srcListID).attr('data-resolve-id', resolveID); + const importNames: string[] = []; + script.text = [ + ...srcList.map(({src: importSrc, imports: importMap}) => { + if (imports) { + if (typeof importMap === 'string') { + importNames.push(importMap); + return `import * as ${importMap} from '${importSrc}';`; + } + if (importMap) { + importNames.push(...Object.values(importMap)); + return `import {${Object.entries(importMap).map(([key, value]) => `${key} as ${value}`).join(',')}} from '${importSrc}';`; + } + } + return `import '${importSrc}';`; + }), + `const zuiImportResult = {${importNames.map(x => `${x}: ${x},`)}};`, + globalVar ? `Object.assign(window, ${globalVar === true ? 'zuiImportResult' : `{${globalVar}: zuiImportResult}`});` : '', + `if(window['${resolveID}']) window['${resolveID}'](zuiImportResult);`, + ].join('\n'); + if (integrity) { + script.integrity = integrity; + } + $('head').append(script); + }); +} + /** Define the $.getLib method. */ $.getLib = async function (optionsOrSrc: string | string[] | GetLibOptions, optionsOrCallback?: Omit | GetLibCallback, callback?: GetLibCallback): Promise { if (typeof optionsOrSrc === 'string') { @@ -181,8 +254,9 @@ $.getLib = async function (optionsOrSrc: string | string[] | GetLib check = name; } const libVarName = typeof check === 'string' ? check : name; + let moduleResult: T | undefined; const getLibVar = (): T | undefined => { - return libVarName ? ((window as unknown as Record)[libVarName] as T) : undefined; + return libVarName ? ((window as unknown as Record)[libVarName] as T || moduleResult) : undefined; }; if (typeof check === 'string') { check = () => !!getLibVar(); @@ -204,7 +278,7 @@ $.getLib = async function (optionsOrSrc: string | string[] | GetLib srcOptions = {src: srcOptions}; } let {src} = srcOptions; - if (root) { + if (root && !/https?:\/\//.test(src)) { src = `${root}${(root.endsWith('/') || src.startsWith('/')) ? '' : '/'}${src}`; } const loadOptions = { @@ -216,6 +290,10 @@ $.getLib = async function (optionsOrSrc: string | string[] | GetLib await loadCSS(loadOptions as LoadCSSOptions); continue; } + if (loadOptions.type === 'module') { + moduleResult = await loadModule(loadOptions as LoadJSModuleOptions); + continue; + } await loadJS(loadOptions as LoadJSOptions); } return onSuccess(); diff --git a/lib/core/src/dom/is-detached.ts b/lib/core/src/dom/is-detached.ts index 5e8feb89d8..46594f070b 100644 --- a/lib/core/src/dom/is-detached.ts +++ b/lib/core/src/dom/is-detached.ts @@ -5,8 +5,8 @@ import {$, Cash} from '../cash'; * @param element The element to check. * @returns Whether the element is detached from document. */ -export function isElementDetached(element: Node): boolean { - if (element.parentNode === document) { +export function isElementDetached(element?: Node): boolean { + if (!element || element.parentNode === document) { return false; } if (!element.parentNode) { diff --git a/lib/core/src/helpers/commands.ts b/lib/core/src/helpers/commands.ts new file mode 100644 index 0000000000..06c94e98e1 --- /dev/null +++ b/lib/core/src/helpers/commands.ts @@ -0,0 +1,382 @@ +import {deepCall} from '@zui/helpers'; +import {$, Cash, type Selector} from '../cash'; +import {nextGid} from './gid'; + +export interface CommandContext { + name: string, + options?: Record, + event?: Event, + scope?: string, + prevResult?: unknown, + element?: HTMLElement, + abort?: () => void, +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type CommandCallback = (context: CommandContext, params: any[]) => any; + +export type CommandEventCallback = (event: Event, data: [context: CommandContext, params: unknown[]]) => void; + +export type CommandsBindOptions = { + scope?: string, + events?: string, + onCommand?: CommandCallback, + commands?: Record, + scoped?: boolean, +}; + +export type CommandsBindInfo = CommandsBindOptions & { + element: HTMLElement, + gid: number, +}; + +export interface CommandExecutionOptions { + execute: (context: CommandContext, params: unknown[]) => unknown; + event: Event; + scope?: string; + options?: Record; + signal?: AbortSignal; +} + +export interface CommandExecuteInfo { + name: string; + scope: string; + options: Record; + params: unknown[]; +} + +export interface CommandsExecuteInfo { + async?: boolean; + commands: CommandExecuteInfo[]; +} + +export type CommandLike = string | CommandExecuteInfo | undefined; + +export type CommandsLike = string | CommandsExecuteInfo | (string | CommandExecuteInfo)[]; + +export function parseCommand(commandLike: CommandLike): CommandExecuteInfo | undefined { + if (!commandLike) { + return; + } + if (typeof commandLike === 'object') { + return commandLike; + } + commandLike = commandLike.replace(/^#/, ''); + if (!commandLike.length) { + return; + } + if (!commandLike.startsWith('/')) { + commandLike = `/${commandLike}`; + } + const url = new URL(window.location.origin + commandLike); + const [, name = '', ...params] = url.pathname.split('/'); + let finalName = name.trim(); + if (!finalName.length) { + return; + } + let scope = ''; + if (finalName.includes('~')) { + [finalName, scope] = finalName.split('~'); + } + return { + name: finalName, + scope, + options: Object.fromEntries([...url.searchParams.entries()].map(([key, value]) => { + try { + if (value.includes('%')) { + value = decodeURIComponent(value); + } + value = JSON.parse(value); + // eslint-disable-next-line no-empty + } catch (_) {} + return [key, value]; + })), + params: params.map((param) => { + if (param === 'undefined') return undefined; + if (param === 'null') return null; + try { + if (param.includes('%')) { + param = decodeURIComponent(param); + } + return JSON.parse(param); + } catch (_) { + return param; + } + }), + }; +} + +export function parseCommands(commandsLike: CommandsLike): CommandsExecuteInfo { + if (Array.isArray(commandsLike)) { + return {commands: commandsLike.map(parseCommand).filter(Boolean) as CommandExecuteInfo[]}; + } + if (typeof commandsLike === 'object') { + return commandsLike; + } + commandsLike = commandsLike.replace(/^#!?/, ''); + const async = commandsLike.includes('>'); + const commands = commandsLike.split(async ? '>' : '|').map(parseCommand); + return { + async, + commands: commands.filter(Boolean) as CommandExecuteInfo[], + }; +} + +/** + * 执行单个命令。 + * Execute single command. + * + * @param command 命令。 Command. + * @param options 选项。 Options. + * @returns 命令执行结果。 Command execution result. + */ +export function executeCommand(command: CommandLike, options: CommandExecutionOptions, prevResult?: unknown): unknown { + if (typeof command === 'string') { + command = parseCommand(command); + } + if (!command) { + return; + } + const {execute, event, scope} = options; + if (scope && command.scope && command.scope !== scope) { + return; + } + return execute({ + name: command.name, + options: { + ...options.options, + ...command.options, + }, + event, + scope: command.scope, + prevResult, + }, command.params); +} + +/** + * 执行命令行。 + * Execute command line. + * + * @param commands 命令行。 Command line. + * @param context 上下文信息。 Context information. + * @returns 命令执行结果。 Command execution result. + */ +export async function executeCommands(commands: CommandsLike, options: CommandExecutionOptions): Promise { + const {async, commands: commandList} = parseCommands(commands); + if (!commandList.length) { + return []; + } + const {signal} = options; + if (async) { + const results = []; + let result; + for (const command of commandList) { + if (!signal?.aborted) { + break; + } + result = await executeCommand(command, options, result); + if (signal?.aborted) { + result = undefined; + } + results.push(result); + } + return results; + } + const results = await Promise.all(commandList.map(command => { + if (signal?.aborted) { + return; + } + return executeCommand(command, options); + })); + return results; +} + +const COMMAND_DATA_NAME = 'zui.commands'; +const COMMANDS_ATTR = 'z-commands'; +const COMMAND_PROXY_ATTR = 'zui-commands-proxy'; +const COMMAND_ATTR = 'zui-command'; + +export function bindCommands(element?: Selector, options?: CommandsBindOptions | CommandCallback | string): void { + if (typeof options === 'string') { + options = {scope: options}; + } else if (typeof options === 'function') { + options = {onCommand: options}; + } + const {scope = '', events = 'click'} = options ?? {}; + const $element = $(element); + const zCommands = ($element.attr(COMMANDS_ATTR) || '').split(','); + if (scope && !zCommands.includes(scope)) { + zCommands.push(scope); + } + $element.attr(COMMANDS_ATTR, zCommands.join(',')).data(COMMAND_DATA_NAME, { + [scope]: { + ...options, + scope, + events, + gid: nextGid(), + }, + ...$element.data(COMMAND_DATA_NAME), + }); +} + +export function unbindCommands(element: Selector, scopes: string | true = true): void { + const $element = $(element); + if (scopes === true) { + $element.removeAttr(COMMANDS_ATTR); + $element.removeData(COMMAND_DATA_NAME); + } else if (scopes.length) { + const boundCommands = $element.data(COMMAND_DATA_NAME) || {}; + scopes.split(',').forEach(scope => { + delete boundCommands[scope]; + }); + const boundScopes = Object.keys(boundCommands); + if (boundScopes.length) { + $element.attr(COMMANDS_ATTR, boundScopes.join(',')).data(COMMAND_DATA_NAME, bindCommands); + } else { + unbindCommands($element, true); + } + } +} + +function getCommandBindInfo($target: Cash, scope?: string): CommandsBindInfo | undefined { + let $element = $target.closest(`[${COMMANDS_ATTR}]`); + if (!$element.length) { + const $proxy = $target.closest(`[${COMMAND_PROXY_ATTR}]`); + if ($proxy.length) { + $element = $($proxy.data('zui.commandProxy') || $proxy.attr('COMMAND_PROXY_ATTR')).closest(`[${COMMANDS_ATTR}]`); + } + } + if (!$element.length) { + return; + } + const commandsData = $element.data(COMMAND_DATA_NAME) || {}; + const boundCommands = Object.values(commandsData).sort((a, b) => (b.gid - a.gid)); + let commandInfo: CommandsBindInfo | undefined; + if (scope?.length) { + commandInfo = boundCommands.find(x => x.scope === scope); + if (!commandInfo) { + commandInfo = boundCommands.find(x => !x.scope?.length && !x.scoped); + } + return commandInfo; + } else { + commandInfo = boundCommands.find(x => !x.scope?.length && !x.scoped); + if (!commandInfo) { + commandInfo = boundCommands.find(x => !x.scoped); + } + } + if (commandInfo) { + commandInfo.element = $element[0] as HTMLElement; + } else { + commandInfo = getCommandBindInfo($target.parent(), scope); + } + return commandInfo; +} + +function handleGlobalCommand(event: Event & {commandHandled?: boolean}) { + if (!event.currentTarget) { + return; + } + const $target = $(event.currentTarget as HTMLElement); + if ($target.closest('.disabled,[disabled]').length) { + return; + } + const commandLine = $target.attr(COMMAND_ATTR) || ($target.is('a[href^="#!"]') ? $target.attr('href') : ''); + if (!commandLine) { + return; + } + const abortController = new AbortController(); + const abort = () => abortController.abort(); + executeCommands(commandLine, { + signal: abortController.signal, + execute: (context, params) => { + const {scope, name} = context; + const finalContext = { + ...context, + abort, + }; + let result; + const bindInfo = getCommandBindInfo($target, scope); + if (bindInfo) { + finalContext.element = bindInfo.element; + const onCommand = (bindInfo.commands ? (bindInfo.commands[`${scope}~${name}`] || bindInfo.commands[name]) : null) || bindInfo.onCommand; + if (onCommand) { + result = onCommand(finalContext, params); + if (event.commandHandled) { + return result; + } + } + } + + const eventData = [finalContext, params]; + $target.trigger('command', eventData).trigger(`command:${scope ? `${name}.${scope}` : name}`, eventData); + if (scope) { + $target.trigger(`command:.${scope}`, eventData); + } + + if (event.commandHandled) { + return result; + } + + if (scope === 'event') { + if (name === 'stop') { + event.stopPropagation(); + } else if (name === 'prevent') { + event.preventDefault(); + } else { + deepCall(event, name, params); + } + return; + } + if (scope === 'window') { + return deepCall(window, name, params); + } + if (scope === 'zui') { + return deepCall((window as unknown as {zui: object}).zui, name, params); + } + if (scope === 'target') { + return deepCall($target[0] as HTMLElement, name, params); + } + if (scope === '$target') { + return deepCall($target, name, params); + } + if (scope === '$') { + return deepCall($, name, params); + } + + return result; + }, + event, + }); +} + +declare module 'cash-dom' { + interface Cash { + command(this: Cash, scopedName: string, callback: CommandEventCallback): Cash; + offCommand(this: Cash, scopedName: string, callback?: CommandEventCallback): Cash; + + commands(this: Cash, options?: CommandsBindOptions | CommandCallback | string): Cash; + unbindCommands(this: Cash, scope?: string): Cash; + } +} + +$.fn.command = function (this: Cash, scopedName: string, callback: CommandEventCallback): Cash { + return this.on(`command:${scopedName}`, callback); +}; + +$.fn.offCommand = function (this: Cash, scopedName: string, callback?: CommandEventCallback): Cash { + return this.off(`command:${scopedName}`, callback as CommandEventCallback); +}; + +$.fn.commands = function (this: Cash, options?: CommandsBindOptions | CommandCallback | string): Cash { + this.each((_, element) => bindCommands(element, options)); + return this; +}; + +$.fn.unbindCommands = function (this: Cash, scope?: string): Cash { + this.each((_, element) => unbindCommands(element, scope)); + return this; +}; + +$(() => { + $(document).on('click.zui.command', `[${COMMAND_ATTR}],a[href^="#!"]`, handleGlobalCommand); +}); diff --git a/lib/core/src/helpers/computed.ts b/lib/core/src/helpers/computed.ts index ba517df212..56fe19606c 100644 --- a/lib/core/src/helpers/computed.ts +++ b/lib/core/src/helpers/computed.ts @@ -50,6 +50,17 @@ export class Computed { return this._lastDependencies ? this._value as T : this.compute(); } + /** + * Set the dependencies of the computed value. + * + * @param dependencies The dependencies of the computed value. + * @returns The computed value. + */ + depends(dependencies: D | (() => D)) { + this._dependencies = dependencies; + return this; + } + /** * Forces the computed value to be recomputed. * @param dependencies The new dependencies to use for recomputing the value. @@ -78,7 +89,10 @@ export class Computed { // Check if dependencies changed. const lastDependencies = this._lastDependencies; if (!lastDependencies || dependencies.some((dept, index) => { - return isDiff(dept instanceof Computed ? dept.value : dept, lastDependencies[index]); + if (dept instanceof Computed) { + return dept.value !== lastDependencies[index]; + } + return isDiff(dept, lastDependencies[index]); })) { this._value = this._compute(); this._lastDependencies = dependencies.map(x => x instanceof Computed ? x.cache : x) as D; diff --git a/lib/core/src/helpers/global-event.ts b/lib/core/src/helpers/global-event.ts index 8188ff9a67..47a721ff62 100644 --- a/lib/core/src/helpers/global-event.ts +++ b/lib/core/src/helpers/global-event.ts @@ -124,7 +124,7 @@ function handleGlobalEvent(this: Cash, event: Event) { const zuiOn = $element.attr('zui-on'); if (zuiOn) { const [events, code] = zuiOn.split('~').map(x => x.trim()); - if (events) { + if (events && events.split(' ').includes(type)) { processGlobalEvent($element, event, $.extend({ on: events, }, code ? (code.startsWith('{') ? evalValue(code) : {do: code}) : getZData($element, {prefix: 'data-', evalValue: ['call', 'if', 'do']}))); @@ -138,7 +138,7 @@ function handleGlobalEvent(this: Cash, event: Event) { } const dataOn = $element.attr('data-on'); - if (dataOn) { + if (dataOn && dataOn.split(' ').includes(type)) { processGlobalEvent($element, event, getZData($element, {prefix: 'data-', evalValue: ['call', 'if', 'do']}) as GlobalEventOptions); } } @@ -147,4 +147,6 @@ export function registerGlobalListener(events: string[]) { $(document).off('.zui.global').on(events.map(event => `${event}.zui.global`).join(' '), `[zui-on],${events.map(x => `[zui-on-${x}]`)},[data-on]`, handleGlobalEvent); } -registerGlobalListener(['click', 'change', 'inited']); +$(() => { + registerGlobalListener(['click', 'change', 'inited']); +}); diff --git a/lib/core/src/helpers/index.ts b/lib/core/src/helpers/index.ts index d8de4cbdc1..eb28cadf81 100644 --- a/lib/core/src/helpers/index.ts +++ b/lib/core/src/helpers/index.ts @@ -19,3 +19,4 @@ export * from './bus'; export * from './hotkeys'; export * from './fullscreen'; export * from './sticky'; +export * from './commands'; diff --git a/lib/core/src/helpers/is-diff.ts b/lib/core/src/helpers/is-diff.ts index a2833f821d..5a14d2f2ff 100644 --- a/lib/core/src/helpers/is-diff.ts +++ b/lib/core/src/helpers/is-diff.ts @@ -23,7 +23,7 @@ export function isDiff(value1: unknown, value2: unknown) { return true; } } - return true; + return false; } const keys1 = Object.keys(value1); @@ -36,11 +36,11 @@ export function isDiff(value1: unknown, value2: unknown) { return true; } } - return true; + return false; } if (typeOfValue1 === 'function' && typeOfValue2 === 'function') { return value1.toString() !== value2.toString(); } } - return true; + return value1 !== value2; } diff --git a/lib/core/src/helpers/size.ts b/lib/core/src/helpers/size.ts index eddace80d6..f40501d3f5 100644 --- a/lib/core/src/helpers/size.ts +++ b/lib/core/src/helpers/size.ts @@ -1,8 +1,8 @@ -export type SizeSetting = number | `${number}%` | `${number}px` | `${number}/${number}` | (string & {}) | (() => SizeSetting); +export type SizeSetting = number | `${number}%` | `${number}px` | `${number}/${number}` | (string & {}) | ((...args: A) => SizeSetting); -export function parseSize(size: SizeSetting): [value: number, type?: 'px' | '%'] { +export function parseSize(size: SizeSetting, callbackArgs?: A): [value: number, type?: 'px' | '%'] { if (typeof size === 'function') { - return parseSize(size()); + return parseSize(size(...(callbackArgs || []))); } if (typeof size === 'number') { return [size]; @@ -18,11 +18,11 @@ export function parseSize(size: SizeSetting): [value: number, type?: 'px' | '%'] return [NaN]; } -export function toCssSize(size: SizeSetting | undefined | null): string | null { +export function toCssSize(size: SizeSetting | undefined | null, callbackArgs?: A): string | null { if (size === undefined || size === null) { return null; } - const [val, unit = 'px'] = parseSize(size); + const [val, unit = 'px'] = parseSize(size, callbackArgs); if (Number.isNaN(val)) { return typeof size === 'string' ? size : null; } diff --git a/lib/core/src/react/components/custom-content.tsx b/lib/core/src/react/components/custom-content.tsx index 5c193a8a52..8632563a6c 100644 --- a/lib/core/src/react/components/custom-content.tsx +++ b/lib/core/src/react/components/custom-content.tsx @@ -1,10 +1,11 @@ import {isValidElement} from 'preact'; import {HtmlContent} from './html-content'; import {HElement} from './h-element'; +import {LazyContent} from './lazy-content'; import {mergeProps} from '../../helpers'; import type {ComponentChildren, VNode} from 'preact'; -import type {HtmlContentProps, HElementProps, CustomContentType, CustomContentGenerator, CustomContentProps} from '../types'; +import type {HtmlContentProps, HElementProps, CustomContentType, CustomContentGenerator, CustomContentProps, LazyContentProps} from '../types'; /** * Render custom content. @@ -29,7 +30,10 @@ export function renderCustomContent(props: CustomContentProps): ComponentChildre } return content; } - if (content && typeof content === 'object' && (typeof (content as HtmlContentProps).html === 'string' || (content as HtmlContentProps).component)) { + if (content && typeof content === 'object' && (typeof (content as HtmlContentProps).html === 'string' || (content as HtmlContentProps).component || (content as LazyContentProps).fetcher)) { + if ((content as LazyContentProps).fetcher) { + return ; + } if ((content as HtmlContentProps).html) { return ; } diff --git a/lib/core/src/react/components/h-element-signals.ts b/lib/core/src/react/components/h-element-signals.ts new file mode 100644 index 0000000000..fe2838676c --- /dev/null +++ b/lib/core/src/react/components/h-element-signals.ts @@ -0,0 +1,43 @@ +import type {RenderableProps} from 'preact'; +import {HElement} from './h-element'; +import {type Signal, signal, batch} from '../signals'; +import type {HElementProps} from '../types'; + +export class HElementSignals

}> extends HElement { + static HElementSignals = true; + + declare signals: SIGNALS; + + constructor(props: P) { + super(props); + + this.signals = {} as SIGNALS; + const {state} = this; + this.changeState(state); + this.state = {} as S; + } + + changeState(state: Partial | ((prevState: Readonly) => Partial), callback?: () => void): Promise { + return new Promise(resolve => { + batch(() => { + if (typeof state === 'function') { + state = state(this.state); + } + for (const key in state) { + const sg = this.signals[key as unknown as keyof SIGNALS] as Signal; + if (sg) { + sg.value = state[key as keyof S]; + } else { + this.signals[key as unknown as keyof SIGNALS] = signal(state[key as keyof S]) as SIGNALS[keyof SIGNALS]; + } + } + resolve(this.state); + callback?.(); + }); + }); + } + + resetState(props?: RenderableProps

) { + this.changeState(this.getDefaultState(props)); + } +} diff --git a/lib/core/src/react/components/h-element.ts b/lib/core/src/react/components/h-element.ts index 70314fca6e..a2e8b88f8f 100644 --- a/lib/core/src/react/components/h-element.ts +++ b/lib/core/src/react/components/h-element.ts @@ -1,8 +1,10 @@ import {h, Component} from 'preact'; +import {deepCall} from '@zui/helpers'; import {nextGid} from '../../helpers/gid'; import {classes} from '../../helpers/classes'; import {getReactComponent} from './components'; import {i18n} from '../../i18n'; +import {bindCommands, unbindCommands, type CommandContext} from '../../helpers'; import type {JSX, ComponentType, RenderableProps, ComponentChildren} from 'preact'; import type {ClassNameLike} from '../../helpers/classes'; @@ -60,6 +62,13 @@ export class HElement

extends Component { return [this.props.i18n, this.constructor.i18n]; } + /** + * Get the command scope. + */ + get commandScope() { + return this.constructor.NAME; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars getDefaultState(_props?: RenderableProps

): S { return {} as S; @@ -124,12 +133,32 @@ export class HElement

extends Component { }); } + executeCommand(context: CommandContext | string, params: unknown[] = []) { + const {onCommand, commands} = this.props; + let result; + if (typeof context === 'string') { + context = {name: context}; + } + const {scope, name} = context; + const onCommandFromProps = commands ? (commands[`${scope}~${name}`] || commands[name]) : null; + if (onCommandFromProps) { + return onCommandFromProps.call(this, context, params); + } + if (!context.scope || context.scope === this.commandScope) { + result = deepCall(this, context.name, params); + } + if (onCommand) { + result = onCommand.call(this, context, params); + } + return result; + } + protected _getClassName(props: RenderableProps

): ClassNameLike { return props.className; } protected _getProps(props: RenderableProps

): Record { - const {className, attrs, props: componentProps, data, forwardRef, children, component, style, class: classNameAlt, ...others} = props; + const {className, attrs, props: componentProps, data, forwardRef, children, component, style, class: classNameAlt, commands, onCommand, ...others} = props; const customProps = new Set((this.constructor as typeof HElement).customProps); const strDangerouslySetInnerHTML = 'dangerouslySetInnerHTML'; const other = Object.keys(others).reduce>((map, key) => { @@ -160,6 +189,24 @@ export class HElement

extends Component { return [component, componentProps, children]; } + componentDidMount(): void { + const {commands, onCommand} = this.props; + if (commands || onCommand) { + bindCommands(this.element, { + commands, + scope: this.commandScope, + onCommand: this.executeCommand.bind(this), + }); + } + } + + componentWillUnmount(): void { + const {commands, onCommand} = this.props; + if (commands || onCommand) { + unbindCommands(this.element, this.commandScope); + } + } + render(props: RenderableProps

) { props = this._beforeRender(props) || props; let component = this._getComponent(props); diff --git a/lib/core/src/react/components/index.ts b/lib/core/src/react/components/index.ts index 36607b2012..bf49064ea8 100644 --- a/lib/core/src/react/components/index.ts +++ b/lib/core/src/react/components/index.ts @@ -1,4 +1,5 @@ export * from './h-element'; +export * from './h-element-signals'; export * from './html-content'; export * from './custom-render'; export * from './custom-content'; diff --git a/lib/core/src/react/components/lazy-content.tsx b/lib/core/src/react/components/lazy-content.tsx new file mode 100644 index 0000000000..3c07f96af0 --- /dev/null +++ b/lib/core/src/react/components/lazy-content.tsx @@ -0,0 +1,89 @@ +import {Component, createRef} from 'preact'; +import type {LazyContentProps, CustomContentType} from '../types'; +import {$} from '../../cash'; +import {fetchData, FetcherSetting, type Ajax} from '../../ajax'; +import {HtmlContent} from './html-content'; +import {CustomContent} from './custom-content'; +import {classes} from '../../helpers'; + +export type LazyContentState = { + loading?: boolean; + error?: Error; + content?: CustomContentType; +}; + +export class LazyContent extends Component { + static defaultProps: Partial = { + type: 'html', + loadingIndicator: true, + loadingClass: 'loading', + }; + + state: LazyContentState = {}; + + protected _ref = createRef(); + + protected _ajax?: Ajax; + + async load(newFetcher?: FetcherSetting) { + const {props} = this; + const {fetcher, type, fetcherArgs, fetcherThis = this} = props; + this.setState({loading: true, error: undefined, content: undefined}); + try { + const content = await fetchData(newFetcher || fetcher, fetcherArgs, {throws: true, dataType: type === 'custom' ? 'json' : 'text'}, fetcherThis, (ajax) => { + this._ajax = ajax; + }); + this.setState({content: content as CustomContentType, loading: false}); + } catch (error) { + this.setState({error: error as Error, loading: false}); + } + this._ajax = undefined; + } + + componentDidMount(): void { + this.load(); + $(this._ref.current).on('loadContent.zui', (event: Event, fetcher?: FetcherSetting) => { + event.stopPropagation(); + this.load(fetcher); + }); + } + + componentDidUpdate(previousProps: Readonly): void { + if (this.props.fetcher !== previousProps.fetcher || this.props.fetcherArgs !== previousProps.fetcherArgs || this.props.fetcherThis !== previousProps.fetcherThis) { + this.load(); + } + } + + componentWillUnmount(): void { + this._ajax?.abort(); + $(this._ref.current).off('.zui'); + } + + protected _renderContent(_props: LazyContentProps, others: Partial) { + const {loading, error, content = ''} = this.state; + const {loadingContent, errorText, type, ...otherProps} = others; + if (loading) { + return loadingContent; + } + if (error) { + return errorText ?? error.message; + } + if (type === 'html') { + return ; + } + if (type === 'text') { + return content; + } + return ; + } + + render(props: LazyContentProps) { + const {loading} = this.state; + const {id, loadingClass, loadingIndicator, className, style, attrs, loadingText, ...others} = props; + return ( +

+ {this._renderContent(props, others)} +
+ ); + } +} diff --git a/lib/core/src/react/components/share.ts b/lib/core/src/react/components/share.ts index 71cbacc9c6..218a5f2680 100644 --- a/lib/core/src/react/components/share.ts +++ b/lib/core/src/react/components/share.ts @@ -1,6 +1,7 @@ import {HElement} from './h-element'; import {HtmlContent} from './html-content'; import {CustomContent} from './custom-content'; +import {LazyContent} from './lazy-content'; import {Icon} from './icon'; import {Portal} from './portals'; import {registerReactComponent} from './components'; @@ -11,7 +12,9 @@ registerReactComponent({ HtmlContent, html: HtmlContent, CustomContent, + LazyContent, custom: CustomContent, + lazy: LazyContent, Icon, Portal, }); diff --git a/lib/core/src/react/index.ts b/lib/core/src/react/index.ts index bbe3ea4a25..4b58925822 100644 --- a/lib/core/src/react/index.ts +++ b/lib/core/src/react/index.ts @@ -1,4 +1,5 @@ export * from './preact'; +export * from './signals'; export * from './components'; export * from './component-from-react'; export * from './types'; diff --git a/lib/core/src/react/signals.ts b/lib/core/src/react/signals.ts new file mode 100644 index 0000000000..99b7c33d42 --- /dev/null +++ b/lib/core/src/react/signals.ts @@ -0,0 +1,2 @@ +export {Signal, signal, batch, computed, effect, untracked} from '@preact/signals'; +export type * from '@preact/signals'; diff --git a/lib/core/src/react/types/custom-content-static.ts b/lib/core/src/react/types/custom-content-static.ts index 1f7a27db6a..47c7b49902 100644 --- a/lib/core/src/react/types/custom-content-static.ts +++ b/lib/core/src/react/types/custom-content-static.ts @@ -1,5 +1,6 @@ import type {ComponentChildren} from 'preact'; import type {HtmlContentProps} from './html-content-props'; import type {HElementProps} from './h-element-props'; +import type {LazyContentProps} from './lazy-content-props'; -export type CustomContentStatic = ComponentChildren | HtmlContentProps | HElementProps; +export type CustomContentStatic = ComponentChildren | HtmlContentProps | LazyContentProps | HElementProps; diff --git a/lib/core/src/react/types/h-element-props.ts b/lib/core/src/react/types/h-element-props.ts index d5fbce124f..4d6f4d5d41 100644 --- a/lib/core/src/react/types/h-element-props.ts +++ b/lib/core/src/react/types/h-element-props.ts @@ -1,6 +1,7 @@ import type {PreactDOMAttributes, JSX, RefObject, ComponentType, Attributes} from 'preact'; import type {ClassNameLike} from '../../helpers/classes'; import type {I18nLangMap} from '../../i18n'; +import type {CommandCallback} from '../../helpers'; /** * The HTML props that can be passed to a component which root not is a html element. @@ -61,4 +62,14 @@ export interface HElementProps extends PreactDOMAttributes, Attributes { * The other props of the element. */ [dataKey: `data-${string}` | `on${string}` | `zui-${string}`]: unknown; + + /** + * The command callback. + */ + onCommand?: CommandCallback, + + /** + * The commands callback map. + */ + commands?: Record, } diff --git a/lib/core/src/react/types/index.ts b/lib/core/src/react/types/index.ts index 5c84a471c2..12644fa2e7 100644 --- a/lib/core/src/react/types/index.ts +++ b/lib/core/src/react/types/index.ts @@ -1,5 +1,6 @@ export * from './h-element-props'; export * from './html-content-props'; +export * from './lazy-content-props'; export * from './custom-content-props'; export * from './custom-content-static'; export * from './custom-content-generator'; diff --git a/lib/core/src/react/types/lazy-content-props.ts b/lib/core/src/react/types/lazy-content-props.ts new file mode 100644 index 0000000000..6ab7227429 --- /dev/null +++ b/lib/core/src/react/types/lazy-content-props.ts @@ -0,0 +1,20 @@ +import type {JSX} from 'preact/jsx-runtime'; +import type {FetcherSetting} from '../../ajax'; +import type {ClassNameLike} from '../../helpers'; +import type {CustomContentType} from './custom-content-type'; + +export type LazyContentProps = { + id?: string; + className?: ClassNameLike; + style?: JSX.CSSProperties; + attrs?: Record; + loadingClass?: ClassNameLike; + loadingIndicator?: boolean; + loadingContent?: CustomContentType; + fetcher: FetcherSetting; + fetcherArgs: A; + fetcherThis?: THIS; + loadingText?: string; + errorText?: string; + type?: 'html' | 'text' | 'custom'; +}; diff --git a/lib/datetime-picker/dev.ts b/lib/datetime-picker/dev.ts index 02fb570839..19e561d0ad 100644 --- a/lib/datetime-picker/dev.ts +++ b/lib/datetime-picker/dev.ts @@ -1,5 +1,6 @@ import 'zui-dev'; import '@zui/button'; +import '@zui/list'; import '@zui/menu'; import '@zui/input-control'; import '@zui/checkbox'; @@ -37,6 +38,11 @@ onPageUpdate(() => { console.log('datePicker.onChange', value); }, allowInvalid: true, + isAllowDate: (date) => { + const day = date.getDate(); + const week = date.getDay(); + return day !== 20 && week !== 2; + }, }); console.log('> datePicker', datePicker); diff --git a/lib/datetime-picker/src/component/date-picker-menu.tsx b/lib/datetime-picker/src/component/date-picker-menu.tsx index 888b0c30d0..05d80ea47a 100644 --- a/lib/datetime-picker/src/component/date-picker-menu.tsx +++ b/lib/datetime-picker/src/component/date-picker-menu.tsx @@ -150,6 +150,7 @@ export class DatePickerMenu extends Component ) : null diff --git a/lib/datetime-picker/src/component/date-picker.tsx b/lib/datetime-picker/src/component/date-picker.tsx index 02c355ac33..aa38e796e8 100644 --- a/lib/datetime-picker/src/component/date-picker.tsx +++ b/lib/datetime-picker/src/component/date-picker.tsx @@ -32,18 +32,28 @@ export class DatePicker extends return this._date; } - setDate = (value: string) => { + setDate = (value: string, force?: boolean) => { const {disabled, readonly} = this.props; - if (disabled || readonly) { + if (!force && (disabled || readonly)) { return; } const newValue = this._calcValue(value); - this.setState({value: newValue}, () => { + return this.changeState({value: newValue}, () => { this._afterSetDate(); }); }; + setValue(value: string, silent?: boolean) { + if (silent) { + const trigger = this._trigger.current; + if (trigger) { + trigger._skipTriggerChange = value; + } + } + return this.setDate(value, true) as Promise; + } + _calcValue(value: string): string { const {onInvalid, defaultValue = '', required, allowInvalid, format} = this.props; let date = this._parseDate(value); @@ -65,7 +75,7 @@ export class DatePicker extends _parseDate(value: string): Date | null { const date = getDate(value); - return date ? campDate(date, ...this._getDateRange(value)) : null; + return (date && this._isAllowDate(date)) ? campDate(date, ...this._getDateRange(value)) : null; } _afterSetDate() { @@ -137,8 +147,13 @@ export class DatePicker extends }; } + protected _isAllowDate(date: Date): boolean { + const result = this.props.isAllowDate?.call(this, date) ?? true; + return result === true || (typeof result === 'object' && result && result.allow); + } + _renderPop(props: T, state: PickState): ComponentChildren { - const {weekNames, monthNames, weekStart, yearText, todayText, clearText, menu, actions, required} = props; + const {weekNames, monthNames, weekStart, yearText, todayText, clearText, menu, actions, required, isAllowDate} = props; const [minDate, maxDate] = this._getDateRange(state.value); return ( extends actions={actions} minDate={minDate} maxDate={maxDate} + isAllowDate={isAllowDate ? this._isAllowDate.bind(this) : undefined} /> ); } diff --git a/lib/datetime-picker/src/component/mini-calendar.tsx b/lib/datetime-picker/src/component/mini-calendar.tsx index 31aa486e77..ec0ee5d952 100644 --- a/lib/datetime-picker/src/component/mini-calendar.tsx +++ b/lib/datetime-picker/src/component/mini-calendar.tsx @@ -47,6 +47,7 @@ export class MiniCalendar extends Component { selections = [], maxDate, minDate, + isAllowDate, } = props; const weekNamesView: ComponentChild[] = []; const btnClass = 'btn ghost square rounded-full'; @@ -67,6 +68,10 @@ export class MiniCalendar extends Component { const rowDays: ComponentChild[] = []; for (let i = 0; i < 7; i++) { const day = new Date(time); + let allowInfo = isAllowDate?.(day) ?? true; + if (typeof allowInfo === 'boolean') { + allowInfo = {allow: allowInfo}; + } const date = day.getDate(); const dateStr = formatDate(day, dateFormat); const weekDay = day.getDay(); @@ -79,11 +84,11 @@ export class MiniCalendar extends Component { 'is-out-month': !isInMonth, 'is-today': isSameDay(day, now), 'is-weekend': weekDay === 0 || weekDay === 6, - disabled: !isSameDay(day, maxDateTime) && !isSameDay(day, minDateTime) && (time > maxDateTime || time < minDateTime), + disabled: !allowInfo.allow || ((time > maxDateTime || time < minDateTime) && !isSameDay(day, maxDateTime) && !isSameDay(day, minDateTime)), }); rowDays.push(
- +
, ); time += TIME_DAY; diff --git a/lib/datetime-picker/src/component/time-picker.tsx b/lib/datetime-picker/src/component/time-picker.tsx index 502f5c87b3..6d9d71f80e 100644 --- a/lib/datetime-picker/src/component/time-picker.tsx +++ b/lib/datetime-picker/src/component/time-picker.tsx @@ -1,4 +1,3 @@ -import {ComponentChildren, RenderableProps} from 'preact'; import {Icon, classes} from '@zui/core'; import {formatDate, createDate} from '@zui/helpers'; import {Pick} from '@zui/pick/src/components/pick'; @@ -54,8 +53,8 @@ export class TimePicker extends Pick { this.setTime(''); }; - setTime(value: string | {hour?: number, minute?: number}) { - if (this.props.disabled || this.props.readonly) { + setTime(value: string | {hour?: number, minute?: number}, force?: boolean) { + if (!force && (this.props.disabled || this.props.readonly)) { return; } let valueString = ''; @@ -68,14 +67,24 @@ export class TimePicker extends Pick { } const date = parseTime(valueString); - const {onInvalid, required, defaultValue} = this.props; - this.setState({value: date ? formatDate(date, this.props.format) : (required ? defaultValue : '')}, () => { + const {onInvalid, required, defaultValue, format} = this.props; + return this.changeState({value: date ? formatDate(date, format) : (required ? defaultValue : '')}, () => { if (!date && onInvalid) { onInvalid(valueString); } }); } + setValue(value: string, silent?: boolean) { + if (silent) { + const trigger = this._trigger.current; + if (trigger) { + trigger._skipTriggerChange = value; + } + } + return this.setTime(value, true) as Promise; + } + getTime(): [hour: number, minute: number] | null { const date = parseTime(this.state.value); return date ? [date.getHours(), date.getMinutes()] : null; diff --git a/lib/datetime-picker/src/style/time-picker.css b/lib/datetime-picker/src/style/time-picker.css index 3bf65b29c8..feffe980a2 100644 --- a/lib/datetime-picker/src/style/time-picker.css +++ b/lib/datetime-picker/src/style/time-picker.css @@ -2,5 +2,5 @@ @apply -max-h-[inherit]; } .time-picker-menu .menu-item > a { - @apply -justify-center; + @apply -justify-center -px-0 -text-center; } diff --git a/lib/datetime-picker/src/types/date-picker-menu-props.ts b/lib/datetime-picker/src/types/date-picker-menu-props.ts index 85d5c474c4..24bcb41c57 100644 --- a/lib/datetime-picker/src/types/date-picker-menu-props.ts +++ b/lib/datetime-picker/src/types/date-picker-menu-props.ts @@ -14,4 +14,5 @@ export type DatePickerMenuProps = { actions?: ToolbarSetting; minDate?: Date | null; maxDate?: Date | null; + isAllowDate?: (date: Date) => boolean | {allow: boolean, hint?: string}; }; diff --git a/lib/datetime-picker/src/types/date-picker-options.ts b/lib/datetime-picker/src/types/date-picker-options.ts index eff9402596..5cf8a4b5a2 100644 --- a/lib/datetime-picker/src/types/date-picker-options.ts +++ b/lib/datetime-picker/src/types/date-picker-options.ts @@ -9,7 +9,7 @@ export interface DatePickerOptions extends PickOptions { readonly?: boolean; placeholder?: string; format?: string | ((date: Date) => string); - display?: (value: string, date: Date | null) => string; + display?: (value: string, date: Date | undefined | null) => string; icon?: IconType | boolean; weekNames?: string[]; monthNames?: string[]; @@ -19,6 +19,7 @@ export interface DatePickerOptions extends PickOptions { weekStart?: number; minDate?: DateLike | ((value?: string) => DateLike); maxDate?: DateLike | ((value?: string) => DateLike); + isAllowDate?: (date: Date) => boolean | {allow: boolean, hint?: string}; menu?: NavSetting; actions?: ToolbarSetting; allowInvalid?: boolean; diff --git a/lib/datetime-picker/src/types/mini-calendar-props.ts b/lib/datetime-picker/src/types/mini-calendar-props.ts index 5ec264d5d5..df38e6d3da 100644 --- a/lib/datetime-picker/src/types/mini-calendar-props.ts +++ b/lib/datetime-picker/src/types/mini-calendar-props.ts @@ -6,6 +6,7 @@ export type MiniCalendarProps = { monthNames?: string[]; minDate?: DateLike; maxDate?: DateLike; + isAllowDate?: (date: Date) => boolean | {allow: boolean, hint?: string}; year?: number; month?: number; selections?: DateLike | DateLike[]; diff --git a/lib/dropdown/dev.ts b/lib/dropdown/dev.ts index a9033bc816..9fea3d70ad 100644 --- a/lib/dropdown/dev.ts +++ b/lib/dropdown/dev.ts @@ -22,6 +22,9 @@ onPageUpdate(() => { { text: '导出', icon: 'icon-download-alt', + listProps: { + searchBox: true, + }, items: [ {text: '导出为 PDF'}, {text: '导出为 PNG'}, diff --git a/lib/dropdown/src/component/dropdown-menu.tsx b/lib/dropdown/src/component/dropdown-menu.tsx index af6753c96b..5443459df2 100644 --- a/lib/dropdown/src/component/dropdown-menu.tsx +++ b/lib/dropdown/src/component/dropdown-menu.tsx @@ -1,12 +1,14 @@ -import {$, mergeProps} from '@zui/core'; +import {$, mergeProps, parseSize} from '@zui/core'; import {flip, computePosition, shift, size, offset} from '@floating-ui/dom'; import {SearchMenu} from '@zui/menu/src/component'; import type {ClassNameLike} from '@zui/core'; +import type {SearchBoxOptions} from '@zui/search-box'; import type {ListItemsSetting, NestedItem, NestedListProps} from '@zui/list'; import {type ComponentChildren, type RenderableProps, type ComponentChild} from 'preact'; -import type {DropdownMenuOptions} from '../types/dropdown-menu-options'; import type {MouseEventInfo} from '@zui/list/src/component'; +import type {DropdownMenuOptions} from '../types/dropdown-menu-options'; +import type {Dropdown} from '../vanilla'; export class DropdownMenu extends SearchMenu { static defaultProps: Partial = { @@ -15,42 +17,64 @@ export class DropdownMenu e placement: 'right-start', defaultNestedShow: false, expandOnSearch: false, + nestedSearch: false, }; static inheritNestedProps = [...SearchMenu.inheritNestedProps, 'container', 'tree']; protected declare _nestedContextMenu: ComponentChild[]; + protected declare _searchFocused: boolean; + + protected declare _position: {left: number, top: number, width: number, height: number}; + get isHoverTrigger(): boolean { const {nestedTrigger, tree} = this.props; return nestedTrigger ? nestedTrigger === 'hover' : !tree; } + get dropdown(): Dropdown | undefined { + return this.props.dropdown; + } + protected layout() { if (this.props.tree || this.isRoot) { return; } const element = this.element?.parentElement; - const $menu = $(element).parent().children('.dropdown-menu'); + const $element = $(element); + if (element && this._searchFocused && this._position) { + $element.css(this._position); + } + + const $menu = $element.parent().children('.dropdown-menu'); const $trigger = $menu.children(`[z-key-path="${this.props.parentKey}"]`); const trigger = $trigger[0]; if (!element || !trigger) { return; } + let {maxHeight} = this.props; computePosition(trigger, element, { placement: this.props.placement, middleware: [flip(), shift(), offset(1), size({ apply({availableWidth, availableHeight}) { - $(element).css({maxHeight: availableHeight - 2, maxWidth: availableWidth - 2}); + if (maxHeight) { + const [maxHeightVal, unit] = parseSize(maxHeight); + maxHeight = Math.min(unit === '%' ? (maxHeightVal * window.innerHeight) : maxHeightVal, availableHeight - 2); + } else { + maxHeight = availableHeight; + } + $element.css({maxHeight, maxWidth: availableWidth - 2}); }, })], }).then(({x, y}) => { - $(element).css({ + $element.css({ left: x, top: y, }); + this._position = {left: x, top: y, width: element.offsetWidth, height: element.offsetHeight}; }); } @@ -118,6 +142,22 @@ export class DropdownMenu e return ; } + protected _handleSearchFocus = () => { + this._searchFocused = true; + }; + + protected _handleSearchBlur = () => { + this._searchFocused = false; + }; + + protected _getSearchBoxProps(props: RenderableProps): SearchBoxOptions { + return { + ...super._getSearchBoxProps(props), + onFocus: this._handleSearchFocus, + onBlur: this._handleSearchBlur, + }; + } + protected _beforeRender(props: RenderableProps): void | RenderableProps | undefined { this._nestedContextMenu = []; return super._beforeRender(props); diff --git a/lib/dropdown/src/style/dropdown.css b/lib/dropdown/src/style/dropdown.css index dcd3eff13c..0b598d5695 100644 --- a/lib/dropdown/src/style/dropdown.css +++ b/lib/dropdown/src/style/dropdown.css @@ -20,6 +20,9 @@ .dropdown > .dropdown-menu { @apply -relative; } +.popup .popup.search-menu { + @apply -bg-[--menu-bg]; +} .show > .menu-wrapper { @apply -flex; diff --git a/lib/dropdown/src/types/dropdown-menu-options.ts b/lib/dropdown/src/types/dropdown-menu-options.ts index cc287b5347..1ae0f968bd 100644 --- a/lib/dropdown/src/types/dropdown-menu-options.ts +++ b/lib/dropdown/src/types/dropdown-menu-options.ts @@ -1,8 +1,10 @@ import type {Placement} from '@floating-ui/dom'; import type {SearchMenuOptions} from '@zui/menu'; +import type {Dropdown} from '../vanilla'; export interface DropdownMenuOptions extends SearchMenuOptions { placement?: Placement; relativeTarget?: unknown; tree?: boolean; + dropdown?: Dropdown; } diff --git a/lib/dropdown/src/types/dropdown-options.ts b/lib/dropdown/src/types/dropdown-options.ts index 392ecd42dd..60ec4dec04 100644 --- a/lib/dropdown/src/types/dropdown-options.ts +++ b/lib/dropdown/src/types/dropdown-options.ts @@ -1,3 +1,4 @@ +import type {Comparator} from '@zui/core'; import type {PopoverOptions} from '@zui/popover'; import type {DropdownMenuOptions} from './dropdown-menu-options'; @@ -7,4 +8,5 @@ export type DropdownOptions = PopoverOptions & { items?: DropdownMenuOptions['items'], relativeTarget?: DropdownMenuOptions['relativeTarget'], onClickItem?: DropdownMenuOptions['onClickItem'], + notHideOnClick?: Comparator; }; diff --git a/lib/dropdown/src/vanilla/dropdown.ts b/lib/dropdown/src/vanilla/dropdown.ts index 69e3fe8353..018296644d 100644 --- a/lib/dropdown/src/vanilla/dropdown.ts +++ b/lib/dropdown/src/vanilla/dropdown.ts @@ -17,8 +17,18 @@ export class Dropdown extends Popov closeBtn: false, animation: 'fade', limitSize: true, + notHideOnClick: '.not-hide-menu,.form-control,input,label,.nested-toggle-icon', }; + handleClickTarget(event: MouseEvent): void | boolean { + const $target = $(event.target as HTMLElement); + const {notHideOnClick} = this.options; + if (!notHideOnClick || !$target.closest(notHideOnClick).length) { + this.hide(); + } + return true; + } + protected _getMenuOptions(): DropdownMenuOptions { const {items, placement, menu, tree, onClickItem, relativeTarget = this._triggerElement} = this.options; return { @@ -29,6 +39,7 @@ export class Dropdown extends Popov nestedToggle: '.item', accordion: true, relativeTarget: {target: relativeTarget, event: this.options.triggerEvent, dropdown: this}, + dropdown: this as Dropdown, popup: true, ...menu, }; @@ -46,13 +57,6 @@ export class Dropdown extends Popov } return options; } - - protected _onClickDoc = (event: MouseEvent) => { - const $target = $(event.target as HTMLElement); - if (!$target.closest('.not-hide-menu,.form-control,input,label,.nested-toggle-icon').length && (this._virtual || !$target.closest(this._triggerElement as HTMLElement).length)) { - this.hide(); - } - }; } Dropdown.toggle = { diff --git a/lib/dtable/dev.ts b/lib/dtable/dev.ts index a67345653b..8f506f1940 100644 --- a/lib/dtable/dev.ts +++ b/lib/dtable/dev.ts @@ -37,7 +37,7 @@ onPageUpdate(() => { const customColsTable = new DTable('#customColsTable', { cols: [ {name: 'id', title: 'ID', width: 80, fixed: 'left', sortType: 'desc', checkbox: true}, - {name: 'name', title: '项目名称', minWidth: 200, flex: 1, sortType: true, nestedToggle: true, childLabel: '子'}, + {name: 'name', title: '项目名称', minWidth: 200, fixed: 'left', flex: 1, sortType: true, nestedToggle: true, childLabel: '子'}, {name: 'manager', title: '负责人', sortType: true, border: true, width: 200}, {name: 'storyScale', title: '需求规模', sortType: true}, {name: 'executionCount', title: '执行数', sortType: true}, @@ -260,6 +260,7 @@ onPageUpdate(() => { recPerPage: 10, linkCreator: '#?page={page}&recPerPage={recPerPage}', }, + localPager: true, footer: ['checkbox', 'divider', 'checkedInfo', 'divider', 'flex', 'pager'], }); console.log('DataTable', datatable); diff --git a/lib/dtable/src/components/cell.tsx b/lib/dtable/src/components/cell.tsx index 2a3ff64ba5..72caba63bb 100644 --- a/lib/dtable/src/components/cell.tsx +++ b/lib/dtable/src/components/cell.tsx @@ -62,7 +62,7 @@ export function Cell(props: CellProps) { if (item.tagName && !item.outer) { contentTagName = item.tagName as string; } - } else { + } else if (typeof item !== 'object' || isValidElement(item)) { contentChildren.push(item); } }); diff --git a/lib/dtable/src/components/dtable.tsx b/lib/dtable/src/components/dtable.tsx index 26771b4bbb..92bd706d24 100644 --- a/lib/dtable/src/components/dtable.tsx +++ b/lib/dtable/src/components/dtable.tsx @@ -1,12 +1,12 @@ import {Component, createRef, h as _h} from 'preact'; -import {classes, $, i18n, CustomContent, nextGid, CustomRender} from '@zui/core'; +import {classes, $, i18n, CustomContent, nextGid, CustomRender, dom} from '@zui/core'; import {Scrollbar} from '@zui/scrollbar/src/component/scrollbar'; import {addPlugin, initPlugins, removePlugin} from '../helpers/shared-plugins'; import {getDefaultOptions} from '../helpers/default-options'; import {initColsLayout} from '../helpers/layout'; import {Block} from './block'; -import type {ComponentChildren} from 'preact'; +import type {ComponentChildren, ErrorInfo} from 'preact'; import type {ClassNameLike, CustomRenderResult, CustomRenderResultList} from '@zui/core'; import type {CellProps, CellRenderCallback} from '../types/cell'; import type {ColInfoLike, ColInfo, ColName} from '../types/col'; @@ -22,40 +22,42 @@ export class DTable extends Component { ref = createRef(); - #rafId = 0; + _rafId = 0; - #id: string; + _id: string; - #needRender = false; + _needRender = false; - #options?: DTableOptions; + _options?: DTableOptions; - #allPlugins: readonly DTablePlugin[]; + _allPlugins: readonly DTablePlugin[]; - #plugins: DTablePlugin[] = []; + _plugins: DTablePlugin[] = []; - #layout?: DTableLayout; + _lastUsedPlugins: Map = new Map(); - #events: Map = new Map(); + _layout?: DTableLayout; - #data: Record = {}; + _events: Map = new Map(); - #rob?: ResizeObserver; + _data: Record = {}; - #i18nMaps: Record>[] = []; + _rob?: ResizeObserver; - #hover: {in: boolean; row?: RowID; col?: ColName} = {in: false}; + _i18nMaps: Record>[] = []; + + _hover: {in: boolean; row?: RowID; col?: ColName} = {in: false}; _noAnimation?: number; constructor(props: DTableOptions) { super(props); - this.#id = props.id ?? `dtable-${nextGid()}`; + this._id = props.id ?? `dtable-${nextGid()}`; this.state = {scrollTop: 0, scrollLeft: 0, renderCount: 0}; - this.#allPlugins = Object.freeze(initPlugins(props.plugins)); - this.#allPlugins.forEach(plugin => { + this._allPlugins = Object.freeze(initPlugins(props.plugins)); + this._allPlugins.forEach(plugin => { const {methods, data, state} = plugin; if (methods) { Object.entries(methods).forEach(([methodName, method]) => { @@ -65,7 +67,7 @@ export class DTable extends Component { }); } if (data) { - Object.assign(this.#data, data.call(this)); + Object.assign(this._data, data.call(this)); } if (state) { Object.assign(this.state, state.call(this)); @@ -73,29 +75,29 @@ export class DTable extends Component { }); this.#initOptions(); - this.#plugins.forEach(plugin => { + this._plugins.forEach(plugin => { plugin.onCreate?.call(this, plugin); }); } get options() { - return this.#layout?.options || this.#options || getDefaultOptions() as DTableOptions; + return this._layout?.options || this._options || getDefaultOptions() as DTableOptions; } get plugins() { - return this.#plugins; + return this._plugins; } get layout(): DTableLayout { - return this.#layout as DTableLayout; + return this._layout as DTableLayout; } get id() { - return this.#id; + return this._id; } get data() { - return this.#data; + return this._data; } get element() { @@ -107,15 +109,19 @@ export class DTable extends Component { } get hoverInfo() { - return this.#hover; + return this._hover; } componentWillReceiveProps(): void { - this.#options = undefined; + this._options = undefined; + } + + shouldComponentUpdate() { + return true; } componentDidMount() { - if (this.#needRender) { + if (this._needRender) { this.forceUpdate(); } else { this.#afterRender(); @@ -139,7 +145,7 @@ export class DTable extends Component { if (typeof ResizeObserver !== 'undefined') { const responsiveEvents: string[] = []; const rob = new ResizeObserver(this.updateLayout); - this.#rob = rob; + this._rob = rob; const {parent} = this; responsiveSelectors.forEach(selector => { if (selector === 'window') { @@ -164,36 +170,23 @@ export class DTable extends Component { } } - this.#plugins.forEach(plugin => { - let {events} = plugin; - if (events) { - if (typeof events === 'function') { - events = events.call(this); - } - Object.entries(events).forEach(([eventType, callback]) => { - if (callback) { - this.on(eventType, callback as DTableEventListener); - } - }); - } - - plugin.onMounted?.call(this); - }); + this._checkPluginsState(); } componentDidUpdate() { this.#afterRender(); - this.#plugins.forEach(plugin => { + this._checkPluginsState(); + this._plugins.forEach(plugin => { plugin.onUpdated?.call(this); }); } componentWillUnmount() { - this.#rob?.disconnect(); + this._rob?.disconnect(); const {element} = this; if (element) { - for (const event of this.#events.keys()) { + for (const event of this._events.keys()) { if (event.startsWith('window_')) { window.removeEventListener(event.replace('window_', ''), this.#handleWindowEvent); } else if (event.startsWith('document_')) { @@ -204,29 +197,33 @@ export class DTable extends Component { } } - this.#plugins.forEach(plugin => { + this._plugins.forEach(plugin => { plugin.onUnmounted?.call(this); }); - this.#allPlugins.forEach(plugin => { + this._allPlugins.forEach(plugin => { plugin.onDestory?.call(this); }); - this.#data = {}; - this.#events.clear(); + this._data = {}; + this._events.clear(); if (this._noAnimation) { clearTimeout(this._noAnimation); } + + if (this._rafId) { + cancelAnimationFrame(this._rafId); + } } resetState(props?: DTableOptions, init?: boolean) { - this.#options = undefined; - this.#layout = undefined; + this._options = undefined; + this._layout = undefined; props = props || this.props; const newState: Partial = {}; - this.#plugins.forEach(plugin => { + this._plugins.forEach(plugin => { const {resetState, state: pluginState} = plugin; if (resetState) { if (typeof resetState === 'function') { @@ -245,11 +242,11 @@ export class DTable extends Component { if (target) { event = `${target}_${event}`; } - const eventCallbacks = this.#events.get(event); + const eventCallbacks = this._events.get(event); if (eventCallbacks) { eventCallbacks.push(callback); } else { - this.#events.set(event, [callback]); + this._events.set(event, [callback]); if (event.startsWith('window_')) { window.addEventListener(event.replace('window_', ''), this.#handleWindowEvent); } else if (event.startsWith('document_')) { @@ -264,7 +261,7 @@ export class DTable extends Component { if (target) { event = `${target}_${event}`; } - const eventCallbacks = this.#events.get(event); + const eventCallbacks = this._events.get(event); if (!eventCallbacks) { return; } @@ -273,7 +270,7 @@ export class DTable extends Component { eventCallbacks.splice(index, 1); } if (!eventCallbacks.length) { - this.#events.delete(event); + this._events.delete(event); if (event.startsWith('window_')) { window.removeEventListener(event.replace('window_', ''), this.#handleWindowEvent); } else if (event.startsWith('document_')) { @@ -404,7 +401,7 @@ export class DTable extends Component { } update(options: {dirtyType?: 'options' | 'layout', state?: Partial | ((prevState: Readonly) => void)} | (() => void) = {}, callback?: () => void) { - if (!this.#options) { + if (!this._options) { return; } if (typeof options === 'function') { @@ -413,15 +410,15 @@ export class DTable extends Component { } const {dirtyType, state} = options; if (dirtyType === 'layout') { - this.#layout = undefined; + this._layout = undefined; } else if (dirtyType === 'options') { - this.#options = undefined; - if (!this.#layout) { + this._options = undefined; + if (!this._layout) { return; } - this.#layout = undefined; + this._layout = undefined; } - this.setState(state ?? ((preState) => ({renderCount: preState.renderCount + 1})), callback); + this.setState(state || ((preState) => ({renderCount: preState.renderCount + 1})), callback); } getPointerInfo(event: Event): DTablePointerInfo | undefined { @@ -446,29 +443,69 @@ export class DTable extends Component { }; } + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error(`[ZUI] DTable ${this.id} Error:`, error, errorInfo); + } + updateLayout = () => { - if (this.#rafId) { - cancelAnimationFrame(this.#rafId); + if (this._rafId) { + cancelAnimationFrame(this._rafId); } - this.#rafId = requestAnimationFrame(() => { - this.update({dirtyType: 'layout'}); - this.#rafId = 0; + this._rafId = requestAnimationFrame(() => { + const {element} = this; + if (element && !dom.isElementDetached(element)) { + this.update({dirtyType: 'layout'}); + } + this._rafId = 0; }); }; i18n(key: string, defaultValue?: string): string; i18n(key: string, args?: (string | number)[] | Record, defaultValue?: string): string; i18n(key: string, args?: string | (string | number)[] | Record, defaultValue?: string): string { - return i18n(this.#i18nMaps, key, args as string, defaultValue, this.options.lang) ?? `{i18n:${key}}`; + return i18n(this._i18nMaps, key, args as string, defaultValue, this.options.lang) ?? `{i18n:${key}}`; } getPlugin(pluginName: string): DTablePlugin | undefined { return this.plugins.find(x => x.name === pluginName); } + _checkPluginsState() { + const lastUsedPluginsNames = new Set(this._lastUsedPlugins.keys()); + this._plugins.forEach(plugin => { + if (lastUsedPluginsNames.has(plugin.name)) { + lastUsedPluginsNames.delete(plugin.name); + return; + } + + let {events} = plugin; + if (events) { + if (typeof events === 'function') { + events = events.call(this); + } + Object.entries(events).forEach(([eventType, callback]) => { + if (callback) { + this.on(eventType, callback as DTableEventListener); + } + }); + } + + plugin.onMounted?.call(this); + this._lastUsedPlugins.set(plugin.name, plugin); + }); + + if (lastUsedPluginsNames.size) { + lastUsedPluginsNames.forEach(name => { + const plugin = this._lastUsedPlugins.get(name); + plugin?.onUnmounted?.call(this); + this._lastUsedPlugins.delete(name); + }); + } + } + #handleEvent = (event: Event, type?: string) => { type = type || event.type; - const callbacks = this.#events.get(type); + const callbacks = this._events.get(type); if (!callbacks?.length) { return; } @@ -608,8 +645,8 @@ export class DTable extends Component { } #afterRender() { - this.#needRender = false; - this.#plugins.forEach(plugin => plugin.afterRender?.call(this)); + this._needRender = false; + this._plugins.forEach(plugin => plugin.afterRender?.call(this)); this.options.afterRender?.call(this); } @@ -621,7 +658,7 @@ export class DTable extends Component { if (col.setting[renderCallbackName]) { result = (col.setting[renderCallbackName] as CellRenderCallback).call(this, result, data, cellProps, h); } - this.#plugins.forEach(plugin => { + this._plugins.forEach(plugin => { if (plugin[renderCallbackName]) { result = (plugin[renderCallbackName] as CellRenderCallback).call(this, result, data, cellProps, h); } @@ -649,7 +686,7 @@ export class DTable extends Component { if (rowID === 'HEADER') { if (cellElement) { this.options.onHeaderCellClick?.call(this, event, {colName, element: cellElement}); - this.#plugins.forEach(plugin => { + this._plugins.forEach(plugin => { plugin.onHeaderCellClick?.call(this, event, {colName, element: cellElement}); }); } @@ -659,7 +696,7 @@ export class DTable extends Component { if (this.options.onCellClick?.call(this, event, {colName, rowID, rowInfo, element: cellElement}) === true) { return; } - for (const plugin of this.#plugins) { + for (const plugin of this._plugins) { if (plugin.onCellClick?.call(this, event, {colName, rowID, rowInfo, element: cellElement}) === true) { return; } @@ -697,7 +734,7 @@ export class DTable extends Component { if (options.colHover === 'header' && newInfo.row !== 'HEADER') { newInfo.col = undefined; } - const oldInfo = this.#hover; + const oldInfo = this._hover; if (newInfo.in !== oldInfo.in) { $element.toggleClass('dtable-hover', newInfo.in); } @@ -713,25 +750,25 @@ export class DTable extends Component { $element.find(`.dtable-cell[data-col="${newInfo.col}"]`).addClass('is-hover-col'); } } - this.#hover = newInfo; + this._hover = newInfo; } #initOptions(): boolean { - if (this.#options) { + if (this._options) { return false; } const defaultOptions = getDefaultOptions(); - const options = {...defaultOptions, ...this.#allPlugins.reduce((currentOptions, plugin) => { + const options = {...defaultOptions, ...this._allPlugins.reduce((currentOptions, plugin) => { const {defaultOptions: pluginOptions} = plugin; if (pluginOptions) { Object.assign(currentOptions, pluginOptions); } return currentOptions; }, {}), ...this.props} as DTableOptions; - this.#options = options; + this._options = options; - this.#plugins = this.#allPlugins.reduce((list, plugin) => { + this._plugins = this._allPlugins.reduce((list, plugin) => { const {options: optionsModifier} = plugin; let pluginModifyOptions = options; if (optionsModifier) { @@ -747,7 +784,7 @@ export class DTable extends Component { return !when || when.call(this, options); }); - this.#i18nMaps = [this.options.i18n, ...this.plugins.map(x => x.i18n)].filter(Boolean) as Record>[]; + this._i18nMaps = [this.options.i18n, ...this.plugins.map(x => x.i18n)].filter(Boolean) as Record>[]; return true; } @@ -755,7 +792,7 @@ export class DTable extends Component { #initLayout() { const {plugins} = this; - let options = this.#options as DTableOptions; + let options = this._options as DTableOptions; const footerGenerators: Record> = { flex:
, divider:
, @@ -779,7 +816,7 @@ export class DTable extends Component { if (parentElement) { width = parentElement.clientWidth; } else { - this.#needRender = true; + this._needRender = true; return; } } @@ -787,10 +824,11 @@ export class DTable extends Component { /* Init columns. */ const cols = initColsLayout(this, options, plugins, width); - const {data, rowKey = 'id', rowHeight} = options; + const {data, rowKey = 'id', rowHeight = 35, rowConverter} = options; const allRows: RowInfo[] = []; const addRowItem = (id: string, index: number, item?: RowData) => { - const row: RowInfo = {data: item ?? {[rowKey]: id}, id, index: allRows.length, top: 0}; + const rowData = item ?? {[rowKey]: id}; + const row: RowInfo = {data: rowConverter ? rowConverter.call(this, rowData, index) : rowData, id, index: allRows.length, top: 0}; if (!item) { row.lazy = true; } @@ -862,7 +900,7 @@ export class DTable extends Component { height = parentElement.clientHeight; } else { height = 0; - this.#needRender = true; + this._needRender = true; return; } } else { @@ -902,11 +940,11 @@ export class DTable extends Component { } }); - this.#layout = layout; + this._layout = layout; } #getLayout(): DTableLayout | undefined { - if (this.#initOptions() || !this.#layout) { + if (this.#initOptions() || !this._layout) { this.#initLayout(); } @@ -963,7 +1001,8 @@ export class DTable extends Component { render() { let layout = this.#getLayout(); - const {className, rowHover, colHover, cellHover, bordered, striped, scrollbarHover, beforeRender, emptyTip, style = {}} = this.options; + const {className, rowHover, colHover, cellHover, bordered, striped, scrollbarHover, beforeRender, emptyTip, style} = this.options; + const finalStyle = {...style}; const classNames: ClassNameLike = ['dtable', className, { 'dtable-hover-row': rowHover, 'dtable-hover-col': colHover, @@ -984,17 +1023,17 @@ export class DTable extends Component { } } - this.#plugins.forEach(plugin => { + this._plugins.forEach(plugin => { const newLayout = plugin.beforeRender?.call(this, layout!); if (newLayout) { layout = newLayout; } }); - style.width = layout.width; - style.height = layout.height; - style['--dtable-row-height'] = `${layout.rowHeight}px`; - style['--dtable-header-height'] = `${layout.headerHeight}px`; + finalStyle.width = layout.width; + finalStyle.height = layout.height; + finalStyle['--dtable-row-height'] = `${layout.rowHeight}px`; + finalStyle['--dtable-header-height'] = `${layout.headerHeight}px`; classNames.push( layout.className, isEmpty ? 'dtable-is-empty' : '', @@ -1011,7 +1050,7 @@ export class DTable extends Component { children.push(...layout.children); } if (isEmpty && emptyTip) { - delete style.height; + delete finalStyle.height; children.push(
@@ -1028,13 +1067,13 @@ export class DTable extends Component { } } - this.#plugins.forEach(plugin => { + this._plugins.forEach(plugin => { const result = plugin.onRender?.call(this, layout!); if (!result) { return; } if (result.style) { - Object.assign(style, result.style); + Object.assign(finalStyle, result.style); } if (result.className) { classNames.push(result.className); @@ -1047,9 +1086,9 @@ export class DTable extends Component { return (
diff --git a/lib/dtable/src/helpers/layout.ts b/lib/dtable/src/helpers/layout.ts index f2b8dd879c..ca6f55c3c6 100644 --- a/lib/dtable/src/helpers/layout.ts +++ b/lib/dtable/src/helpers/layout.ts @@ -2,11 +2,19 @@ import {parseNumber, clamp} from './number'; import type {ColInfo, ColSetting, DTableColsLayout, DTableColsSectionLayout, DTableOptions, DTablePlugin} from '../types'; import type {DTable} from '../components'; -function initSectionColsLayout(cols: DTableColsSectionLayout, fixed = false) { +function initSectionColsLayout(cols: DTableColsSectionLayout, fixed = false, maxWidth = 0) { if (!cols.list.length) { return; } + if (fixed && cols.widthSetting) { + cols.widthSetting = Math.min(cols.widthSetting, cols.width); + } + + if (maxWidth && (!cols.widthSetting || cols.widthSetting > maxWidth) && cols.width > maxWidth) { + cols.widthSetting = maxWidth; + } + if (cols.widthSetting && cols.width !== cols.widthSetting) { cols.width = cols.widthSetting; const extraWidth = cols.width - cols.totalWidth; @@ -164,8 +172,9 @@ export function initColsLayout(dtable: DTable, options: DTableOptions, plugins: } /* Layout columns. */ - initSectionColsLayout(leftCols, true); initSectionColsLayout(rightCols, true); + const maxLeftWidth = width - rightCols.width - Math.max(40, minColWidth); + initSectionColsLayout(leftCols, true, maxLeftWidth); centerCols.widthSetting = width - leftCols.width - rightCols.width; initSectionColsLayout(centerCols); diff --git a/lib/dtable/src/helpers/shared-plugins.ts b/lib/dtable/src/helpers/shared-plugins.ts index 8c41cf0d9c..087fd87a65 100644 --- a/lib/dtable/src/helpers/shared-plugins.ts +++ b/lib/dtable/src/helpers/shared-plugins.ts @@ -82,5 +82,28 @@ export function initPlugins(pluginsLike: DTablePluginLike[] = [], includeBuildIn return []; } - return initPluginsInner([], pluginsLike, new Set()); + const plugins = initPluginsInner([], pluginsLike, new Set()); + const pluginRequireList: DTablePlugin[] = []; + const pluginOrder = plugins.reduce((order, plugin, index) => { + order.set(plugin.name, index * 1000); + if (plugin.requireAfter?.length) { + pluginRequireList.push(plugin); + } + return order; + }, new Map()); + if (pluginRequireList.length) { + pluginRequireList.forEach(plugin => { + const requireAfterOrders = plugin.requireAfter!.reduce((orders, name) => { + if (pluginOrder.has(name)) { + orders.push(pluginOrder.get(name)!); + } + return orders; + }, [] as number[]); + if (requireAfterOrders.length) { + pluginOrder.set(plugin.name, Math.max(...requireAfterOrders) + 1); + } + }); + plugins.sort((a, b) => pluginOrder.get(a.name)! - pluginOrder.get(b.name)!); + } + return plugins; } diff --git a/lib/dtable/src/plugins/autoscroll/index.ts b/lib/dtable/src/plugins/autoscroll/index.ts index 8f55ae6688..962b1df9f5 100644 --- a/lib/dtable/src/plugins/autoscroll/index.ts +++ b/lib/dtable/src/plugins/autoscroll/index.ts @@ -18,6 +18,8 @@ export interface ScrollToMouseOption { side?: ScrollSide | ScrollSide[], } +export type ScrollToMouseInfo = ScrollToMouseOption & {startTime: number, position?: {x: number; y: number}}; + export interface DTableAutoscrollTypes { methods: { scrollTo: (this: DTableAutoscroll, info: {col?: ColInfoLike, row?: RowInfoLike, extra?: number}) => boolean; @@ -26,7 +28,7 @@ export interface DTableAutoscrollTypes { }, data: { scrollToTimer?: number; - scrollToMouse?: ScrollToMouseOption & {startTime: number, position?: {x: number; y: number}}; + scrollToMouse?: ScrollToMouseInfo; } } @@ -137,14 +139,14 @@ const autoscrollPlugin: DTablePlugin; }; + const disableHideCol = (this.getColInfo(info.colName)?.setting.required as boolean) || !this.options.canSetColVisibility?.call(this, info.colName, false); return [ { icon: getIcon(border), @@ -91,8 +92,8 @@ const customColPlugin: DTablePlugin this.setColVisibility(info.colName, false), + disabled: disableHideCol, + onClick: disableHideCol ? undefined : (() => this.setColVisibility(info.colName, false)), }, ]; }, diff --git a/lib/dtable/src/plugins/group/index.tsx b/lib/dtable/src/plugins/group/index.tsx index 112a5fd4a6..de8c073de2 100644 --- a/lib/dtable/src/plugins/group/index.tsx +++ b/lib/dtable/src/plugins/group/index.tsx @@ -16,10 +16,10 @@ const applyGroupDivider = (cols: ColInfo[]) => { return; } cols.forEach((col, index) => { - if (!index || col.setting.border || col.setting.group === cols[index - 1].setting.group) { + if (!index || col.border !== undefined || col.setting.group === cols[index - 1].setting.group) { return; } - col.setting.border = 'left'; + col.border = 'left'; }); }; diff --git a/lib/dtable/src/plugins/index.ts b/lib/dtable/src/plugins/index.ts index 49919c5248..92c1dd417c 100644 --- a/lib/dtable/src/plugins/index.ts +++ b/lib/dtable/src/plugins/index.ts @@ -9,3 +9,4 @@ export * from './group'; export * from './header-group'; export * from './cellspan'; export * from './sortable'; +export * from './pager'; diff --git a/lib/dtable/src/plugins/mousemove/index.ts b/lib/dtable/src/plugins/mousemove/index.ts index f6120fd42f..5efcc7f711 100644 --- a/lib/dtable/src/plugins/mousemove/index.ts +++ b/lib/dtable/src/plugins/mousemove/index.ts @@ -36,28 +36,31 @@ const mousemovePlugin: DTablePlugin = { mousemove(event) { if (this.data.mmRafID) { cancelAnimationFrame(this.data.mmRafID); - this.data.mmRafID = 0; } this.data.mmRafID = requestAnimationFrame(() => { this.emitCustomEvent('mousemovesmooth', event); + this.data.mmRafID = 0; }); event.preventDefault(); }, document_mousemove(event) { if (this.data.dmmRafID) { cancelAnimationFrame(this.data.dmmRafID); - this.data.dmmRafID = 0; } this.data.dmmRafID = requestAnimationFrame(() => { this.emitCustomEvent('document_mousemovesmooth', event); + this.data.mmRafID = 0; }); }, }, methods: { ignoreNextClick(timeout = 10) { - window.setTimeout(() => { + if (this.data.ignoreNextClick) { + clearTimeout(this.data.ignoreNextClick); + } + this.data.ignoreNextClick = window.setTimeout(() => { this.data.ignoreNextClick = undefined; }, timeout); }, diff --git a/lib/dtable/src/plugins/nested/index.tsx b/lib/dtable/src/plugins/nested/index.tsx index 92d08d1704..365c4eb3f7 100644 --- a/lib/dtable/src/plugins/nested/index.tsx +++ b/lib/dtable/src/plugins/nested/index.tsx @@ -150,7 +150,7 @@ function isAllCollapsed(this: DTableNested): boolean { return true; } -function updateNestedMapOrders(map: Map, lastOrder = 0, ids?: string[], level = 0): number { +function updateNestedMapOrders(map: Map, lastOrder = 1, ids?: string[], level = 0): number { if (!ids) { ids = [...map.keys()]; } @@ -203,13 +203,14 @@ const nestedToggleClass = 'dtable-nested-toggle'; const nestedPlugin: DTablePlugin = { name: 'nested', plugins: [store], + requireAfter: ['sortable'], defaultOptions: { nested: 'auto', nestedParentKey: 'parent', asParentKey: 'asParent', nestedIndent: 20, canSortTo(from, to) { - const {nestedMap} = this.data; + const {nestedMap} = (this as unknown as DTableNested).data; const fromInfo = nestedMap.get(from.id); const toInfo = nestedMap.get(to.id); return fromInfo?.parent === toInfo?.parent; @@ -312,13 +313,18 @@ const nestedPlugin: DTablePlugin = } }); - rows = rows.filter(row => this.getNestedRowInfo(row.id).state !== NestedRowState.hidden); - updateNestedMapOrders(this.data.nestedMap); + const nestedStateMap = new Map(); + const undefinedOrder = rows.length * 100; + rows = rows.filter((row) => { + const info = this.getNestedRowInfo(row.id)!; + nestedStateMap.set(row.id, info); + return info.state !== NestedRowState.hidden; + }); + updateNestedMapOrders(nestedStateMap); rows.sort((rowA, rowB) => { - const infoA = this.getNestedRowInfo(rowA.id); - const infoB = this.getNestedRowInfo(rowB.id); - const result = (infoA.order ?? 0) - (infoB.order ?? 0); - return result === 0 ? (rowA.index - rowB.index) : result; + const infoA = nestedStateMap.get(rowA.id); + const infoB = nestedStateMap.get(rowB.id); + return (infoA?.order ?? (undefinedOrder + rowA.index)) - (infoB?.order ?? (undefinedOrder + rowB.index)); }); return rows; }, @@ -354,6 +360,7 @@ const nestedPlugin: DTablePlugin = result.push(
); } } + return result; }, onRenderHeaderCell(result, {row, col}) { diff --git a/lib/dtable/src/plugins/pager/index.tsx b/lib/dtable/src/plugins/pager/index.tsx index 4fd4854ae7..884f1cc7d5 100644 --- a/lib/dtable/src/plugins/pager/index.tsx +++ b/lib/dtable/src/plugins/pager/index.tsx @@ -1,4 +1,4 @@ -import {PagerOptions} from '@zui/pager/src/types'; +import type {PagerInfo, PagerOptions} from '@zui/pager'; import {Pager} from '@zui/pager/src/component'; import {definePlugin} from '../../helpers/shared-plugins'; @@ -7,17 +7,67 @@ import type {DTablePlugin, DTableWithPlugin} from '../../types/plugin'; export interface DTablePagerTypes { options: Partial<{ footPager: PagerOptions, + localPager: PagerInfo | boolean, }>, + state: { + pager?: PagerInfo; + }, } export type DTablePager = DTableWithPlugin; const pagerPlugin: DTablePlugin = { name: 'pager', + state() { + const localPager = this.props.localPager as DTablePagerTypes['options']['localPager']; + if (localPager) { + const {page = 1, recTotal = 0, recPerPage = 20, pageTotal = 1} = (this.props.footPager || {}) as Partial; + return { + pager: { + page, + recTotal, + recPerPage, + pageTotal, + ...(typeof localPager === 'object' ? localPager : null), + }, + }; + } + return {}; + }, footer: { pager() { - const {footPager} = this.options; + let {footPager} = this.options; + const {localPager} = this.options; if (footPager) { + footPager = { + items: [ + { + 'type': 'link', + 'page': 'first', + 'icon': 'icon-first-page', + }, + { + 'type': 'link', + 'page': 'prev', + 'icon': 'icon-angle-left', + }, + { + 'type': 'info', + 'text': '{page}/{pageTotal}', + }, + { + 'type': 'link', + 'page': 'next', + 'icon': 'icon-angle-right', + }, + { + 'type': 'link', + 'page': 'last', + 'icon': 'icon-last-page', + }, + ], + ...footPager, + }; if (Array.isArray(footPager.items)) { footPager.items.forEach(item => { if (item.type === 'size-menu' && item.caret === undefined) { @@ -25,11 +75,40 @@ const pagerPlugin: DTablePlugin = { } }); } + if (this.options.localPager) { + Object.assign(footPager, { + ...(typeof localPager === 'object' ? localPager : null), + ...this.state.pager, + recTotal: this.layout.allRows.length, + useState: true, + }); + footPager.onChangePageInfo = (newPager) => { + this.update({ + dirtyType: 'layout', + state: (prevState) => { + const pager = {...(prevState as unknown as DTablePagerTypes['state']).pager, ...newPager}; + return {pager}; + }, + }); + }; + } return []; } return []; }, }, + onAddRows(rows) { + const {localPager} = this.options; + if (localPager) { + const {page = 1, recPerPage = 20} = { + ...(typeof localPager === 'object' ? localPager : null), + ...this.state.pager, + }; + const start = (page - 1) * recPerPage; + const end = Math.min(page * recPerPage, rows.length); + return rows.slice(start, end); + } + }, }; export const pager = definePlugin(pagerPlugin); diff --git a/lib/dtable/src/plugins/resize/index.tsx b/lib/dtable/src/plugins/resize/index.tsx index 90994d4bc1..6401a8c43d 100644 --- a/lib/dtable/src/plugins/resize/index.tsx +++ b/lib/dtable/src/plugins/resize/index.tsx @@ -68,6 +68,14 @@ const resizePlugin: DTablePlugin = { name: 'resize', when: options => !!options.colResize, plugins: [mousemove], + resetState(props) { + return {colsSizes: props.cols?.reduce((sizes, col) => { + if (col.extraWidth !== undefined) { + sizes[col.name] = col.extraWidth as number; + } + return sizes; + }, {} as Record)}; + }, state() { return {colsSizes: this.props.cols?.reduce((sizes, col) => { if (col.extraWidth !== undefined) { @@ -145,7 +153,7 @@ const resizePlugin: DTablePlugin = { if (!col) { return; } - if (col.sideIndex === this.layout.cols[col.side].list.length - 1) { + if (col.side !== 'left' && col.sideIndex === this.layout.cols[col.side].list.length - 1) { return; } let colResize = col.setting.colResize ?? this.options.colResize; diff --git a/lib/dtable/src/plugins/sort-col/index.tsx b/lib/dtable/src/plugins/sort-col/index.tsx index 1f978f110b..15f02a354d 100644 --- a/lib/dtable/src/plugins/sort-col/index.tsx +++ b/lib/dtable/src/plugins/sort-col/index.tsx @@ -26,13 +26,13 @@ export type DTableSortColTypes = { onSortColEnd: (this: DTableSortCol, from: ColInfo, to: ColInfo | undefined, sortingSide: SortingColSide | undefined, orders: string[] | undefined) => void; onSortCol: (this: DTableSortCol, from: ColInfo, to: ColInfo, sortingSide: SortingColSide, orders: string[]) => void | false; }>; - state: Partial<{ - colOrders: Record; - sortColFrom: ColInfo; - sortingColPos: number; + state: { + colOrders?: Record; + sortColFrom?: ColInfo; + sortingColPos?: number; sortingColTo?: ColInfo; sortingColSide?: SortingColSide; - }>; + }; data: { sortColInfo?: {from: ColInfo, element: HTMLElement, offset: number, state?: SortingColState, startMouseX: number, lastMouseX: number, colOffsetMap?: Record}; }, @@ -67,6 +67,16 @@ const sortColPlugin: DTableSorColPlugin = { name: 'sort-col', when: options => !!options.sortCol, plugins: [mousemove, autoscroll], + resetState: true, + state() { + return { + colOrders: {}, + sortColFrom: undefined, + sortingColPos: undefined, + sortingColTo: undefined, + sortingColSide: undefined, + }; + }, events: { mousedown(event) { if (this.data.disableSortCol) { @@ -101,27 +111,30 @@ const sortColPlugin: DTableSorColPlugin = { let colOrders: Record | undefined; let orders: string[] | undefined; const {from, to, side} = sortingState; - if (to && side) { - const sideCols = this.layout.cols[from.side].list; - const fromIndex = from.sideIndex; - const toIndex = to.sideIndex; - const col = sideCols.splice(fromIndex, 1); - sideCols.splice(toIndex + (side === 'after' ? 1 : 0), 0, col[0]); - colOrders = {}; - orders = []; - sideCols.forEach(({name}, index) => { - colOrders![name] = index + 1; - orders!.push(name); - }); - - if (this.options.onSortCol?.call(this, from, to, side, orders) === false) { - colOrders = undefined; - orders = undefined; - } - } + if (to) { + if (from.name === to.name) { + colOrders = {}; + } else if (side) { + const sideCols = this.layout.cols[from.side].list; + const fromIndex = from.sideIndex; + const toIndex = to.sideIndex; + const col = sideCols.splice(fromIndex, 1); + sideCols.splice(toIndex + (side === 'after' ? 1 : 0), 0, col[0]); + colOrders = {}; + orders = []; + sideCols.forEach(({name}, index) => { + colOrders![name] = index + 1; + orders!.push(name); + }); - if (to || Math.abs(sortColInfo.lastMouseX - sortColInfo.startMouseX) > 4) { - this.ignoreNextClick(); + if (this.options.onSortCol?.call(this, from, to, side, orders) === false) { + colOrders = undefined; + orders = undefined; + } + } + if (Math.abs(sortColInfo.lastMouseX - sortColInfo.startMouseX) > 4) { + this.ignoreNextClick(); + } } this.disableAnimation(); @@ -173,21 +186,17 @@ const sortColPlugin: DTableSorColPlugin = { return; } - const {from, element, offset} = sortColInfo; + const {from, element} = sortColInfo; const $cells = $(element).closest('.dtable-cells'); const bounding = $cells[0]!.getBoundingClientRect(); - const width = bounding.width; - const pos = event.clientX - bounding.left - offset; - if ((pos + from.width) < 0 || (pos - from.width) > width) { - return sortColInfo.state; - } + const pos = event.clientX - bounding.left; const {cols, scrollLeft} = this.layout; const sideCols = cols[from.side].list; if (sideCols.length <= 1) { return sortColInfo.state; } const left = scrollLeft + pos; - const to = sideCols.find(col => col.name !== from.name && col.visible && col.left <= left && (col.left + col.width) > left); + const to = sideCols.find(col => col.visible && col.left <= left && (col.left + col.width) > left); if (!to) { return sortColInfo.state; } @@ -198,7 +207,7 @@ const sortColPlugin: DTableSorColPlugin = { from, pos: pos + scrollLeft, to, - side, + side: from.name !== to.name ? side : undefined, } : { from, pos, @@ -209,8 +218,8 @@ const sortColPlugin: DTableSorColPlugin = { }, }, onAddCol(col) { - const {colOrders} = this.state; - const order = colOrders?.[col.name]; + const {colOrders = {}} = this.state; + const order = colOrders[col.name]; if (order !== undefined) { col.order = order; } diff --git a/lib/dtable/src/plugins/sort-type/index.tsx b/lib/dtable/src/plugins/sort-type/index.tsx index d035f01100..8c6512f7c5 100644 --- a/lib/dtable/src/plugins/sort-type/index.tsx +++ b/lib/dtable/src/plugins/sort-type/index.tsx @@ -1,7 +1,7 @@ import {formatString} from '@zui/helpers'; import {definePlugin} from '../../helpers/shared-plugins'; -import type {JSX} from 'preact'; +import {isValidElement, type JSX} from 'preact'; import type {ColInfo} from '../../types/col'; import type {DTableWithPlugin, DTablePlugin} from '../../types/plugin'; import type {DTableSortTypes} from '../sort'; @@ -14,6 +14,7 @@ export type DTableSortTypeTypes = { sortLink?: string | ({url: string} & JSX.HTMLAttributes) | ((this: DTableSortType, col: ColInfo, sortType: string, currentSortType: string) => (string | ({url: string} & JSX.HTMLAttributes))), }, options: { + sortType?: boolean; sortLink?: string | ({url: string} & JSX.HTMLAttributes) | ((this: DTableSortType, col: ColInfo, sortType: string, currentSortType: string) => (string | ({url: string} & JSX.HTMLAttributes))), orderBy?: Record } @@ -23,6 +24,8 @@ export type DTableSortType = DTableWithPlugin = { name: 'sort-type', + defaultOptions: {sortType: true}, + when: options => !!options.sortType && !options.sort, onRenderHeaderCell(result, info) { const {col} = info; const {setting} = col; @@ -50,7 +53,7 @@ const sortTypePlugin: DTablePlugin = { sortLink = {url: sortLink}; } const {url, ...linkProps} = sortLink; - result[0] = {result[0]}{sortIcon}; + result[0] = {(typeof result[0] !== 'object' || isValidElement(result[0])) ? result[0] : col.name}{sortIcon}; } else { result.push(sortIcon); } diff --git a/lib/dtable/src/plugins/sort/index.tsx b/lib/dtable/src/plugins/sort/index.tsx index c478955af1..37fdf22579 100644 --- a/lib/dtable/src/plugins/sort/index.tsx +++ b/lib/dtable/src/plugins/sort/index.tsx @@ -56,7 +56,7 @@ const defaultSortFns: Record = { const sortPlugin: DTablePlugin = { name: 'sort', - defaultOptions: {sort: true}, + defaultOptions: {sort: false}, when: options => !!options.sort, onCreate() { const {sortBy} = this.options; diff --git a/lib/dtable/src/plugins/sortable/index.tsx b/lib/dtable/src/plugins/sortable/index.tsx index c585d95a9d..bec0489889 100644 --- a/lib/dtable/src/plugins/sortable/index.tsx +++ b/lib/dtable/src/plugins/sortable/index.tsx @@ -7,7 +7,6 @@ import './style.css'; import type {DTableWithPlugin, DTablePlugin} from '../../types/plugin'; import type {DTableMousemoveTypes} from '../mousemove'; import type {DTableAutoscrollTypes} from '../autoscroll'; -import type {DTableNestedTypes, DTableNested} from '../nested'; import type {RowInfo} from '../../types/row'; export type SortingSide = 'before' | 'after'; @@ -45,21 +44,25 @@ export type DTableSortableTypes = { } }; -export type DTableSortable = DTableWithPlugin; +export type DTableSortable = DTableWithPlugin; -const sortablePlugin: DTablePlugin = { +const sortablePlugin: DTablePlugin = { name: 'sortable', defaultOptions: { sortable: true, - canSortTo(this: DTableSortable, from: RowInfo, to: RowInfo) { - if (!this.options.nested) { - return true; - } - return (this as unknown as DTableNested).getNestedRowInfo(from.id).parent === (this as unknown as DTableNested).getNestedRowInfo(to.id).parent; - }, }, when: options => !!options.sortable, plugins: [mousemove, autoscroll], + resetState: true, + state() { + return { + rowOrders: undefined, + sortingFrom: undefined, + sortingPos: undefined, + sortingTo: undefined, + sortingSide: undefined, + }; + }, events: { click(event) { if ((event.target as HTMLElement).closest('.dtable-sort-link')) { @@ -84,9 +87,7 @@ const sortablePlugin: DTablePlugin row.id); + const oldOrders = [...ids]; const fromIndex = sortingFrom.index; const toIndex = sortingTo.index; - const row = rows.splice(fromIndex, 1); - rows.splice(toIndex, 0, row[0]); - rowOrders = {}; - orders = []; - rows.forEach(({id}, index) => { - rowOrders![id] = index; - orders!.push(id); - }); + if (!(fromIndex === (toIndex + 1) && sortingSide === 'after') && !(fromIndex === (toIndex - 1) && sortingSide === 'before')) { + const row = ids.splice(fromIndex, 1); + ids.splice(toIndex, 0, row[0]); + rowOrders = {}; + orders = []; + ids.forEach((id, index) => { + rowOrders![id] = index; + orders!.push(id); + }); - if (this.options.onSort?.call(this, sortingFrom, sortingTo, sortingSide, orders) === false) { - rowOrders = undefined; - orders = undefined; + if (oldOrders.join() === orders.join() || this.options.onSort?.call(this, sortingFrom, sortingTo, sortingSide, orders) === false) { + rowOrders = undefined; + orders = undefined; + } } } @@ -128,12 +132,17 @@ const sortablePlugin: DTablePlugin { + return $.extend({ + sortingFrom: undefined, + sortingPos: undefined, + sortingTo: undefined, + sortingSide: undefined, + }, rowOrders ? {rowOrders: { + ...(preState.rowOrders as Record), + ...rowOrders, + }} : null); + }, }, () => { this.options.onSortEnd?.call(this, sortingFrom, sortingTo, sortingSide, orders); setTimeout(() => { @@ -210,8 +219,11 @@ const sortablePlugin: DTablePlugin { - return rowOrders[row1.id] - rowOrders[row2.id]; + const order1 = rowOrders[row1.id] ?? (undefinedOrder + row1.index); + const order2 = rowOrders[row2.id] ?? (undefinedOrder + row2.index); + return order1 - order2; }); return rows; }, diff --git a/lib/dtable/src/plugins/toolbar/index.tsx b/lib/dtable/src/plugins/toolbar/index.tsx index a218a54c4f..1e9cdcb853 100644 --- a/lib/dtable/src/plugins/toolbar/index.tsx +++ b/lib/dtable/src/plugins/toolbar/index.tsx @@ -1,16 +1,18 @@ import {Toolbar} from '@zui/toolbar/src/component/toolbar'; import {definePlugin} from '../../helpers/shared-plugins'; -import type {ToolbarOptions, ToolbarItemOptions} from '@zui/toolbar/src/types'; -import type {DTablePlugin} from '../../types/plugin'; +import type {ToolbarSetting} from '@zui/toolbar/src/types'; +import type {DTablePlugin, DTableWithPlugin} from '../../types/plugin'; import type {DTableCheckable, DTableCheckableTypes} from '../checkable'; export type DTableToolbarTypes = { options: Partial<{ - footToolbar: ToolbarOptions | ToolbarItemOptions[], + footToolbar: ToolbarSetting<[DTableWithToolbar]>; showToolbarOnChecked: boolean, }> }; +export type DTableWithToolbar = DTableWithPlugin; + /** * @todo auto calculate column width by toolbar setting */ @@ -22,7 +24,9 @@ const toolbarPlugin: DTablePlugin = if (showToolbarOnChecked && !(this as DTableCheckable).getChecks().length) { return []; } - return [footToolbar ? : null]; + return [footToolbar ? Toolbar.render(footToolbar, [this], { + gap: 2, + }) : null]; }, }, }; diff --git a/lib/dtable/src/types/options.ts b/lib/dtable/src/types/options.ts index e4bee7b24b..bf0cb052fc 100644 --- a/lib/dtable/src/types/options.ts +++ b/lib/dtable/src/types/options.ts @@ -12,24 +12,25 @@ export interface DTableDataOptions { data: (RowData | string)[] | number; rowDataGetter?: (ids: string[]) => RowData[], cellValueGetter?: CellValueGetter, + rowConverter?: (row: RowData, index: number) => RowData; rowKey?: string; } export interface DTableLayoutOptions { - width: number | '100%' | ((this: DTable) => number | '100%'); - height: number | '100%' | 'auto' | {min: number, max: number} | ((this: DTable, actualHeight: number) => number | 'auto' | {min: number, max: number}); + width?: number | '100%' | ((this: DTable) => number | '100%'); + height?: number | '100%' | 'auto' | {min: number, max: number} | ((this: DTable, actualHeight: number) => number | 'auto' | {min: number, max: number}); fixedLeftWidth?: number | 'auto' | `${number}%` | ((this: DTable) => number); fixedRightWidth?: number | 'auto' | `${number}%` | ((this: DTable) => number); - rowHeight: number; - defaultColWidth: number; - minColWidth: number; - maxColWidth: number; + rowHeight?: number; + defaultColWidth?: number; + minColWidth?: number; + maxColWidth?: number; header?: boolean | CustomRenderResultList<[layout: DTableLayout], DTable> | CustomRenderResultGenerator<[layout: DTableLayout], DTable> | CustomRenderResultItem; footer?: boolean | CustomRenderResultList<[layout: DTableLayout], DTable> | ((this: DTable, layout: DTableLayout) => CustomRenderResultList<[layout: DTableLayout], DTable>); - headerHeight: number; - footerHeight: number; - responsive: boolean | string; - scrollbarHover: boolean; + headerHeight?: number; + footerHeight?: number; + responsive?: boolean | string; + scrollbarHover?: boolean; scrollbarSize?: number; horzScrollbarPos?: 'inside' | 'outside'; vertScrollbarPos?: 'inside' | 'outside'; diff --git a/lib/dtable/src/types/plugin.ts b/lib/dtable/src/types/plugin.ts index 8859aa0a38..4b8b24f201 100644 --- a/lib/dtable/src/types/plugin.ts +++ b/lib/dtable/src/types/plugin.ts @@ -59,6 +59,7 @@ export type DTablePlugin boolean, + requireAfter: DTablePluginName[], defaultOptions: Partial; colTypes: Record | PluginColSettingModifier>; events: DTablePluginEvents | ((this: PluginTable) => DTablePluginEvents); diff --git a/lib/form/src/style/form-horz.css b/lib/form/src/style/form-horz.css index 050d9deeb7..cfadf08488 100644 --- a/lib/form/src/style/form-horz.css +++ b/lib/form/src/style/form-horz.css @@ -3,7 +3,10 @@ } .form-horz .form-group { - @apply -flex -flex-row -items-start -relative -min-h-[32px] -flex-wrap -pl-[--form-horz-label-width] -min-w-0 -flex-grow; + @apply -flex -flex-row -items-start -relative -min-h-[32px] -flex-wrap -pl-[--form-horz-label-width] -min-w-0 -flex-none; +} +.form-horz .form-row .form-group { + @apply -flex-auto; } .form-horz .form-group[class^="w-"], .form-horz .form-group[class*=" w-"] { diff --git a/lib/helpers/src/object/deep-get.ts b/lib/helpers/src/object/deep-get.ts index 86850192ab..4d787e8953 100644 --- a/lib/helpers/src/object/deep-get.ts +++ b/lib/helpers/src/object/deep-get.ts @@ -78,3 +78,11 @@ export function deepGet(object: object, pathName: string | string[], defaultV return defaultValue; } } + +export function deepCall(object: object, pathName: string | string[], args?: unknown[], thisObj?: unknown): unknown { + const callback = deepGet(object, pathName); + if (typeof callback === 'function') { + return callback.apply(thisObj || object, args); + } + return callback; +} diff --git a/lib/kanban/src/component/kanban.tsx b/lib/kanban/src/component/kanban.tsx index 45a87ae5ca..78b11db4c9 100644 --- a/lib/kanban/src/component/kanban.tsx +++ b/lib/kanban/src/component/kanban.tsx @@ -774,11 +774,12 @@ export class Kanban

) { const {links = []} = this.data; - if (!links.length) { + const {editLinks} = props; + if (!editLinks && !links.length) { return; } - const {editLinks, showLinkOnHover, showLinkOnSelected} = props; + const {showLinkOnHover, showLinkOnSelected} = props; let filters: string[] | undefined; if (showLinkOnSelected || showLinkOnHover) { filters = []; diff --git a/lib/list/README.md b/lib/list/README.md index a804298f84..934e71fcd5 100644 --- a/lib/list/README.md +++ b/lib/list/README.md @@ -8,6 +8,12 @@

``` +### 仅根节点 + +```html:example +
+``` + ### 远程数据 ```html:example diff --git a/lib/list/src/component/list.tsx b/lib/list/src/component/list.tsx index 9c41a1b923..eb897c1717 100644 --- a/lib/list/src/component/list.tsx +++ b/lib/list/src/component/list.tsx @@ -249,6 +249,10 @@ export class List

): void | RenderableProps

| undefined { + return this.props.beforeRender?.call(this, props); + } + protected _getItems(props: RenderableProps

): Item[] { const {items} = props; const {items: stateItems} = this.state; @@ -268,9 +272,11 @@ export class List

): ClassNameLike { const {loading, loadFailed} = this.state; - return [super._getClassName(props), loading ? 'loading' : (loadFailed ? 'is-load-failed' : '')]; + return [super._getClassName(props), loading ? 'loading' : (loadFailed ? 'is-load-failed' : ''), props.hoverItemActions ? 'with-hover-actions' : '']; } protected _getProps(props: RenderableProps

): Record { diff --git a/lib/list/src/component/listitem.tsx b/lib/list/src/component/listitem.tsx index fbdcd842ae..0226c10315 100644 --- a/lib/list/src/component/listitem.tsx +++ b/lib/list/src/component/listitem.tsx @@ -11,6 +11,7 @@ export class Listitem

extends H protected _renderLeading(props: RenderableProps

): ComponentChild[] { const { icon, + iconClass, avatar, toggleIcon, leading, @@ -27,7 +28,7 @@ export class Listitem

extends H contents.push(); } if (icon) { - contents.push(); + contents.push(); } if (avatar) { const avatarProps = typeof avatar === 'function' ? avatar.call(this, props) : avatar; @@ -84,14 +85,15 @@ export class Listitem

extends H trailing, trailingClass, trailingIcon, + trailingIconClass, actions, } = props; const contents: ComponentChild[] = []; if (trailingIcon) { - contents.push(); + contents.push(); } if (actions) { - contents.push(Toolbar.render(actions, [props], {key: 'actions', relativeTarget: props, size: 'sm'}, this)); + contents.push(Toolbar.render(actions, [props], {key: 'actions', className: 'item-actions', relativeTarget: props, size: 'sm'}, this)); } const customTrailing = trailing ? : null; if (customTrailing) { @@ -122,6 +124,7 @@ export class Listitem

extends H subtitle, hint, selected, + command, } = props; const ComponentName = innerComponent || ((url && !actions) ? 'a' : 'div'); const asLink = ComponentName === 'a'; @@ -137,7 +140,7 @@ export class Listitem

extends H multiline: multiline ?? !!(title && subtitle), state: asLink && !disabled, }), - }, asLink ? {href: url || 'javascript:;', target} : null, extraAttrs, innerAttrs); + }, command ? {'zui-command': command} : null, asLink ? {href: url || 'javascript:;', target} : null, extraAttrs, innerAttrs); return ( {this._renderLeading(props)} diff --git a/lib/list/src/component/nested-list.tsx b/lib/list/src/component/nested-list.tsx index ad39a53b52..86456d5451 100644 --- a/lib/list/src/component/nested-list.tsx +++ b/lib/list/src/component/nested-list.tsx @@ -1,4 +1,4 @@ -import {Icon, classes, mergeProps, $} from '@zui/core'; +import {Icon, classes, mergeProps, $, isValidElement} from '@zui/core'; import {store} from '@zui/store'; import {List} from './list'; import '@zui/css-icons/src/icons/caret.css'; @@ -94,21 +94,19 @@ export class NestedList

; - static inheritNestedProps = ['component', 'name', 'itemName', 'itemKey', 'indent', 'hover', 'divider', 'multiline', 'toggleIcons', 'nestedToggle', 'accordion', 'itemRender', 'itemProps', 'beforeRenderItem', 'onToggle', 'checkbox', 'getItem', 'checkOnClick', 'selectOnChecked', 'checkedState', 'onClickItem', 'activeOnHover', 'multipleActive', 'onActive']; + static inheritNestedProps = ['component', 'name', 'itemName', 'itemKey', 'indent', 'hover', 'divider', 'multiline', 'toggleIcons', 'nestedToggle', 'accordion', 'itemRender', 'itemProps', 'onToggle', 'checkbox', 'getItem', 'getItems', 'checkOnClick', 'selectOnChecked', 'checkedState', 'onClickItem', 'activeOnHover', 'multipleActive', 'onActive', 'hoverItemActions']; protected declare _hasNestedItems: boolean; - protected declare _needHandleHover: boolean; - protected declare _storeID: string; protected declare _renderedItemMap: Map; protected declare _itemMap?: Map; - protected declare _needInitChecks?: boolean; + protected declare _itemMapCache: Map; - protected declare _hoverInfo?: {timer: number, info: MouseEventInfo}; + protected declare _needInitChecks?: boolean; constructor(props: P) { super(props); @@ -142,7 +140,6 @@ export class NestedList

x.key!), true); + } else if (items?.some((x) => x.checked)) { + this._needInitChecks = true; + this.forceUpdate(); } return state; } - getItemMap() { + getItemMap(useCache?: boolean) { + if (useCache && (this._itemMap || this._itemMapCache)) { + return this._itemMap || this._itemMapCache; + } if (!this._itemMap) { let needCheckRenderItems = false; const map: Map = reduceNestedItems(this._items, this.props.itemKey, (currentMap, info) => { @@ -208,6 +207,7 @@ export class NestedList

{ let newNestedShow: Record = { - ...prevState.nestedShow, + ...(reset ? {} : prevState.nestedShow), [keyPath]: toggle!, }; if (toggle && accordion) { @@ -270,16 +271,6 @@ export class NestedList

{ - if (!newNestedShow[key] || !key.startsWith(`${keyPath}:`)) { - return; - } - parentKeys(keyPath).forEach(k => { - newNestedShow[k] = true; - }); - }); - } return { nestedShow: newNestedShow, } as Partial; @@ -294,7 +285,7 @@ export class NestedList

((checks, {keyPath, data}) => { + return Array.from(this.getItemMap(true).values()).reduce((checks, {keyPath, data}) => { const checkState = this.state.checked[keyPath]; if ((checkState === true || (data.checked && checkState !== false)) === true) { checks.push(keyPath); @@ -336,11 +327,13 @@ export class NestedList

{ + await this.changeState(({checked: prevChecked, nestedShow: preNestedShow}) => { const isChecked = (item: ItemInfo) => { return change[item.keyPath] ?? prevChecked[item.keyPath] ?? item.data.checked ?? false; }; + const map = this.getItemMap(); + const nestedShow: Record = {}; + const {expandChildrenOnCheck} = this.props; Object.keys(change).forEach(key => { checked = change[key]; const item = map.get(key); @@ -363,12 +356,19 @@ export class NestedList

; }, () => { const checkState = this.state.checked; @@ -519,6 +519,9 @@ export class NestedList

) : (toggleIcons.collapsed || ); toggleClass = `state is-${isExpanded ? 'expanded' : 'collapsed'}`; + if (!isValidElement(toggleIcon)) { + toggleIcon = ; + } } else { toggleIcon = ; toggleClass = 'is-empty'; @@ -557,87 +560,79 @@ export class NestedList

, renderedItem: NestedItem, index: number): ComponentChildren { - if (this._hasNestedItems && renderedItem.type === 'item' && renderedItem.toggleIcon === undefined) { + if ((this._hasNestedItems || !this.isRoot) && renderedItem.type === 'item' && renderedItem.toggleIcon === undefined) { renderedItem.toggleIcon = this._renderNestedToggle(props, renderedItem.expanded as boolean | undefined); } const nestedListContent = renderedItem.items ? this._renderNestedList(props, renderedItem.items, renderedItem, renderedItem.expanded as boolean) : null; renderedItem = mergeProps(renderedItem, { 'z-parent': renderedItem.parentKey, 'z-key-path': renderedItem._keyPath, - }, this._needHandleHover ? { - onMouseEnter: this._handleHover, - onMouseLeave: this._handleHover, - } : null, nestedListContent ? {children: nestedListContent} : null); + }, nestedListContent ? {children: nestedListContent} : null); this._renderedItemMap.set(renderedItem._keyPath as string, renderedItem); return super._renderItem(props, renderedItem, index); } protected _getItemFromEvent(event: MouseEvent, target?: HTMLElement): MouseEventInfo | undefined { - const info = super._getItemFromEvent(event, target) as MouseEventInfo; + target = target || event.target as HTMLElement; + let info = super._getItemFromEvent(event, target) as MouseEventInfo; if (!info) { + const listEle = target.closest('[z-list]') as HTMLElement; + if (listEle) { + const listKey = listEle.getAttribute('z-list')!; + const item = this.getItem(listKey); + const renderedItem = this.getRenderedItem(listKey); + if (!item || !renderedItem) { + return; + } + info = { + target, + index: renderedItem._index as number, + item, + element: listEle, + event, + key: listKey, + keyPath: listKey, + renderedItem, + }; + } return; } - if (event.type === 'mouseenter' || event.type === 'mouseleave') { - info.hover = event.type === 'mouseenter'; + if (event.type === 'mouseenter' || event.type === 'mouseleave' || event.type === 'mouseover') { + info.hover = event.type !== 'mouseleave'; } const {parentKey} = this.props; - return {...info, parentKey, keyPath: `${parentKey !== undefined ? `${parentKey}:` : ''}${info.key}`, target: target || event.target as HTMLElement}; + return {...info, parentKey, keyPath: `${parentKey !== undefined ? `${parentKey}:` : ''}${info.key}`, target}; } - protected _toggleFromEvent(info: MouseEventInfo) { - const {item, hover, event, keyPath, target} = info; - const {nestedToggle} = this.props; - const {isHoverTrigger} = this; - if (!item.items || event.defaultPrevented || (isHoverTrigger && hover === undefined) || (!isHoverTrigger && event.type !== 'click') || target.closest('.not-nested-toggle') || (nestedToggle && !item.disabled && !target.closest(nestedToggle)) || (!nestedToggle && !item.disabled && target.closest('a,.btn,.item-checkbox,.open-url') && !target.closest('.nested-toggle-icon,.item-icon'))) { - return info; - } - const toggle = typeof hover === 'boolean' ? hover : undefined; - this.toggle(keyPath, toggle); - event.preventDefault(); - } - - protected _handleNestedToggle(key: ItemKey, toggle: boolean) { - this.toggle(key, toggle); + protected _handleNestedToggle(key: ItemKey, toggle: boolean, reset?: boolean) { + this.toggle(key, toggle, reset); } protected _handleClick(event: MouseEvent) { const info = super._handleClick(event); if (info) { - return this._toggleFromEvent(info as MouseEventInfo); - } - return info; - } - - protected _handleHover(event: MouseEvent) { - const info = this._getItemFromEvent(event); - if (!info) { - return; - } - this.props.onHoverItem?.call(this, info as {hover: boolean, item: NestedItem, index: number, event: MouseEvent}); - if (!this.isHoverTrigger) { - return; - } - const lastHover = this._hoverInfo; - if (lastHover) { - if (lastHover.info.keyPath === info.keyPath) { - clearTimeout(lastHover.timer); - } else { - this._toggleFromEvent(lastHover.info); + const {renderedItem: item, keyPath, target} = info as MouseEventInfo; + const {nestedToggle} = this.props; + if (!item.items || event.defaultPrevented || target.closest('.not-nested-toggle') || (nestedToggle && !item.disabled && !target.closest(nestedToggle)) || (!nestedToggle && !item.disabled && target.closest('a,.btn,.item-checkbox,.open-url,input,select,textarea') && !target.closest('.nested-toggle-icon,.item-icon'))) { + return info; } + this.toggle(keyPath); + event.preventDefault(); } - this._hoverInfo = { - info, - timer: window.setTimeout(() => { - this._hoverInfo = undefined; - this._toggleFromEvent(info); - }, info.hover ? 0 : 200), - }; + return info; } protected _handleNestedCheck(change: Record) { @@ -648,7 +643,7 @@ export class NestedList

): void | RenderableProps

| undefined { this._renderedItemMap.clear(); this._hasIcons = false; - this._hasNestedItems = !this.isRoot; - this._needHandleHover = !!(props.onHoverItem || this.isHoverTrigger); + this._hasNestedItems = false; return super._beforeRender(props); } } diff --git a/lib/list/src/style/list.css b/lib/list/src/style/list.css index 482a907bae..28325e6501 100644 --- a/lib/list/src/style/list.css +++ b/lib/list/src/style/list.css @@ -4,3 +4,13 @@ .list-heading-inner .item-title { @apply -font-bold -text-gray-500; } + +.with-hover-actions > .item { + @apply -relative +} +.with-hover-actions > .item > .item-inner .item-actions { + @apply -absolute -pointer-events-none -right-1 -top-0 -opacity-0 -transition-opacity; +} +.with-hover-actions > .item > .item-inner:hover .item-actions { + @apply -static -pointer-events-auto -flex --mt-1 -opacity-100; +} diff --git a/lib/list/src/types/list-props.ts b/lib/list/src/types/list-props.ts index 10c0cb60fb..7eee89f5e0 100644 --- a/lib/list/src/types/list-props.ts +++ b/lib/list/src/types/list-props.ts @@ -14,6 +14,7 @@ export interface ListProps extends CommonListProps active?: string | string[] | Record; multipleActive?: boolean; activeOnHover?: boolean; + hoverItemActions?: boolean; onActive?: (keys: string[], active: boolean) => void; onCheck?: (change: Record, checks: ItemKey[]) => void; onLoad?: (items: T[]) => void | T[]; diff --git a/lib/list/src/types/listitem-props.ts b/lib/list/src/types/listitem-props.ts index 11f40ee1ad..18c298a46d 100644 --- a/lib/list/src/types/listitem-props.ts +++ b/lib/list/src/types/listitem-props.ts @@ -17,6 +17,7 @@ export interface ListitemProps extends Item { divider?: boolean; toggleIcon?: CustomContentType; icon?: IconType; + iconClass?: ClassNameLike; selected?: boolean; avatar?: AvatarOptions | ((item: Item) => AvatarOptions); leading?: CustomContentType; @@ -33,9 +34,11 @@ export interface ListitemProps extends Item { trailing?: CustomContentType; trailingClass?: ClassNameLike; trailingIcon?: IconType; + trailingIconClass?: ClassNameLike; actions?: ToolbarSetting<[Item]>; contentClass?: ClassNameLike; content?: CustomContentType; contentAttrs?: Record; hint?: string; + command?: string; } diff --git a/lib/list/src/types/nested-list-props.ts b/lib/list/src/types/nested-list-props.ts index d4533ad61d..5854b8f9da 100644 --- a/lib/list/src/types/nested-list-props.ts +++ b/lib/list/src/types/nested-list-props.ts @@ -10,7 +10,6 @@ export interface NestedListProps extends indent?: number; level?: number; preserve?: string; - nestedTrigger?: 'click' | 'hover', accordion?: boolean; nestedShow?: boolean | Record; defaultNestedShow?: boolean | Record; @@ -18,7 +17,7 @@ export interface NestedListProps extends nestedToggle?: string; renderCollapsedList?: boolean; checkedState?: Record; + expandChildrenOnCheck?: boolean; toggleOnActive?: boolean; - onToggle?: (key: ItemKey, toggle: boolean) => false | void; - onHoverItem?: (info: {hover: boolean, item: T, index: number, event: MouseEvent}) => void; + onToggle?: (key: ItemKey, toggle: boolean, reset?: boolean) => false | void; } diff --git a/lib/menu/README.md b/lib/menu/README.md index e5e7f688d4..026b6e92d8 100644 --- a/lib/menu/README.md +++ b/lib/menu/README.md @@ -115,23 +115,23 @@ console.log('> menu', menu); ```html:example

- - - + + + ``` -## 选中 +## 激活与选中 ```html:example - - - - - - - + + + + + + + ``` diff --git a/lib/menu/dev.ts b/lib/menu/dev.ts index 539d52ebd0..cf4c1e8e9f 100644 --- a/lib/menu/dev.ts +++ b/lib/menu/dev.ts @@ -17,6 +17,7 @@ onPageUpdate(() => { { text: '导入', icon: 'icon-upload-alt', + listProps: {compact: true, searchBox: true}, items: [ {text: '从本地导入'}, {text: '从网络导入'}, @@ -29,6 +30,7 @@ onPageUpdate(() => { items: [ {text: '保存到云端'}, { + listProps: {searchBox: true}, text: '下载到本地', items: [ {text: '下载为 PDF'}, @@ -45,12 +47,14 @@ onPageUpdate(() => { searchPlacement: 'bottom', underlineKeys: true, items: searchMenuItems, + nestedSearch: false, maxHeight: 200, onClickItem: (info) => { console.log('> menu.onClickItem', info); }, }); console.log('> searchMenu1', searchMenu1); + const searchMenu2 = new SearchMenu('#searchMenu2', { popup: true, searchBox: true, diff --git a/lib/menu/package.json b/lib/menu/package.json index 44b915789e..e9d9ae3c51 100644 --- a/lib/menu/package.json +++ b/lib/menu/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@zui/core": "workspace:^0.0.1", + "@zui/helpers": "workspace:^0.0.1", "@zui/search-box": "workspace:^0.0.1", "@zui/css-icons": "workspace:^0.0.1", "@zui/common-list": "workspace:^0.0.1", diff --git a/lib/menu/src/component/menu.tsx b/lib/menu/src/component/menu.tsx index 3a77e38e53..a8e336f055 100644 --- a/lib/menu/src/component/menu.tsx +++ b/lib/menu/src/component/menu.tsx @@ -23,6 +23,17 @@ export class Menu): ClassNameLike { return [super._getClassName(props), this._hasNestedItems ? 'menu-nested' : '', props.className, props.wrap ? {'scrollbar-thin': props.scrollbarThin, 'scrollbar-hover': props.scrollbarHover} : {popup: props.popup, compact: props.compact}]; } @@ -32,8 +43,17 @@ export class Menu): Record { - const {wrapAttrs, height, maxHeight} = props; - const wrapProps = mergeProps({}, wrapAttrs, (height || maxHeight) ? {style: {height, maxHeight}} : null); + const {wrapAttrs, height, maxHeight, parentKey} = props; + const wrapProps = mergeProps( + {'z-list-wrapper': parentKey}, + wrapAttrs, + (height || maxHeight) ? {style: {height, maxHeight}} : null, + this.isRoot && this.isHoverTrigger ? { + onMouseEnter: this._handleHover, + onMouseLeave: this._handleHover, + onMouseOver: this._handleHover, + } : null, + ); wrapProps.className = classes(this._getWrapClass(props), wrapProps.className as ClassNameLike); return wrapProps; } @@ -46,6 +66,59 @@ export class Menu; } + protected _handleHover(event: MouseEvent) { + const target = event.target; + if (!(target instanceof HTMLElement) || !this.isHoverTrigger) { + return; + } + + let keyPath: string | null | undefined; + if (event.type !== 'mouseleave') { + const itemEle = target.closest('[z-item]'); + if (itemEle) { + keyPath = itemEle.getAttribute('z-key-path') as string; + if (!itemEle.classList.contains('is-nested')) { + keyPath = itemEle.getAttribute('z-parent') as string; + } + } else { + const listEle = target.closest('[z-list-wrapper]'); + keyPath = listEle?.getAttribute('z-list-wrapper'); + } + } + + const lastInfo = this._hoverInfo; + const lastKeyPath = lastInfo?.keyPath; + if (lastKeyPath === keyPath) { + return; + } + if (lastInfo?.timer) { + clearTimeout(lastInfo.timer); + } + const hasKey = typeof keyPath === 'string'; + const lastHasKey = typeof lastKeyPath === 'string'; + const delay = hasKey ? ((lastHasKey && lastInfo?.shown) ? 50 : 200) : (lastInfo?.shown ? 100 : 200); + this._hoverInfo = { + keyPath, + timer: window.setTimeout(() => { + if (hasKey) { + this.toggle(keyPath!, true, true); + this._hoverInfo!.shown = true; + } else { + this.toggleAll(false); + this._hoverInfo = undefined; + } + }, delay), + }; + } + + componentWillUnmount(): void { + super.componentWillUnmount(); + const timer = this._hoverInfo?.timer; + if (timer) { + clearTimeout(timer); + } + } + render(props: RenderableProps) { const menuView = super.render(props); if (props.wrap) { @@ -57,7 +130,7 @@ export class Menu ); } - return super.render(props); + return menuView; } static render(this: unknown, setting: MenuSetting | undefined, args: T, defaultProps?: Partial & Attributes, thisObject?: unknown) { diff --git a/lib/menu/src/component/search-menu.tsx b/lib/menu/src/component/search-menu.tsx index 5c3297318a..fd11ed95e5 100644 --- a/lib/menu/src/component/search-menu.tsx +++ b/lib/menu/src/component/search-menu.tsx @@ -1,4 +1,5 @@ -import {$, classes} from '@zui/core'; +import {$, classes, mergeProps} from '@zui/core'; +import {formatString} from '@zui/helpers'; import {SearchBox} from '@zui/search-box/src/components'; import {Menu} from './menu'; @@ -10,12 +11,14 @@ import type {SearchBoxOptions} from '@zui/search-box'; import type {SearchMenuOptions, SearchMenuState} from '../types'; export class SearchMenu extends Menu { - static inheritNestedProps = [...Menu.inheritNestedProps, 'isItemMatch', 'search', 'underlineKeys']; + static inheritNestedProps = [...Menu.inheritNestedProps, 'isItemMatch', 'search', 'underlineKeys', 'nestedSearch']; static defaultProps: Partial = { ...Menu.defaultProps, defaultNestedShow: true, wrap: true, + nestedSearch: true, + underlineKeys: true, }; protected declare _searchKeys: string[]; @@ -72,9 +75,9 @@ export class SearchMenu extends }; protected _isItemMatch(props: RenderableProps, item: NestedItem, index: number, parentKey: ItemKey | undefined) { - const {isItemMatch} = props; + const {isItemMatch, nestedSearch} = props; const isMatch = isItemMatch ? isItemMatch.call(this, item, this._searchKeys, index, parentKey) : (this.constructor as typeof SearchMenu).isItemMatch(item, this._searchKeys, props.searchProps); - if (this.isRoot && isMatch && parentKey !== undefined) { + if ((nestedSearch && this.isRoot) && isMatch && parentKey !== undefined) { let key = ''; String(parentKey).split(':').forEach(x => { key += `${key.length ? ':' : ''}${x}`; @@ -90,9 +93,11 @@ export class SearchMenu extends protected _getNestedProps(props: RenderableProps, items: ListItemsSetting, item: NestedItem, expanded: boolean): NestedListProps { const nestedProps = super._getNestedProps(props, items, item, expanded) as SearchMenuOptions; - if (this.isRoot) { + if (this.isRoot && props.nestedSearch) { nestedProps.isItemMatch = this._isNestedItemMatch; nestedProps.search = this._searchKeys.join(' '); + } else if (!props.nestedSearch) { + mergeProps(nestedProps as Record, {search: undefined, defaultSearch: undefined}, item.listProps); } return nestedProps; } @@ -129,35 +134,38 @@ export class SearchMenu extends return classes(super._getWrapClass(props), 'search-menu', props.searchBox ? `search-menu-on-${props.searchPlacement || 'top'}` : '', isSearchMode ? 'is-search-mode' : '', isSearchMode && props.expandOnSearch ? 'no-toggle-on-search' : ''); } - protected _renderSearchBox(props: RenderableProps): ComponentChildren { + protected _getSearchBoxProps(props: RenderableProps): SearchBoxOptions { const {searchBox} = props; - if (!searchBox || !this.isRoot) { - return null; - } const searchOptions: SearchBoxOptions = { compact: true, + className: 'not-nested-toggle', onChange: this._handleSearchChange, }; if (typeof searchBox === 'object') { - $.extend(searchOptions, searchBox); + mergeProps(searchOptions, searchBox); } if (props.search !== undefined) { searchOptions.value = this._searchKeys.join(' '); searchOptions.disabled = true; } - return ; + return searchOptions; + } + + protected _renderSearchBox(props: RenderableProps): ComponentChildren { + const searchBoxOptions = this._getSearchBoxProps(props); + return ; } protected _renderWrapperHeader(props: RenderableProps): ComponentChildren { const hasHeader = props.header; - const hasTopSearchBox = this.isRoot && props.searchBox && props.searchPlacement !== 'bottom'; - const {noMatchHint} = props; + const {noMatchHint, searchBox, searchPlacement, nestedSearch, headerClass} = props; + const hasTopSearchBox = (!nestedSearch || this.isRoot) && searchBox && searchPlacement !== 'bottom'; if (!hasHeader && !hasTopSearchBox && !noMatchHint) { return null; } return [ noMatchHint ?
{noMatchHint}
: null, - (hasHeader || hasTopSearchBox) ? (
+ (hasHeader || hasTopSearchBox) ? (
{hasHeader ? super._renderWrapperHeader(props) : null} {hasTopSearchBox ? this._renderSearchBox(props) : null}
) : null, @@ -166,14 +174,17 @@ export class SearchMenu extends protected _renderWrapperFooter(props: RenderableProps): ComponentChildren { const hasFooter = props.footer; - const hasBottomSearchBox = this.isRoot && props.searchBox && props.searchPlacement === 'bottom'; - if (!hasFooter && !hasBottomSearchBox) { + const {searchBox, searchPlacement, nestedSearch, footerClass, exceedLimitHint, limit} = props; + const hasBottomSearchBox = (!nestedSearch || this.isRoot) && searchBox && searchPlacement === 'bottom'; + const hasExceedLimitHint = exceedLimitHint && limit && this._items.length > limit; + if (!hasFooter && !hasBottomSearchBox && !hasExceedLimitHint) { return null; } return ( -