blob: b0a3a1a29fb4cbd2084ebc30ee78c93691cdf389 [file] [log] [blame] [edit]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {classNames} from '../base/classnames';
import {Icons} from '../base/semantic_icons';
import {Button} from './button';
import {MenuItem, PopupMenu} from './menu';
import {MithrilEvent} from '../base/mithril_utils';
export interface GridAttrs {
// If true, the grid will fill the height of its parent container.
readonly fillHeight?: boolean;
// An optional class name to add to the root element of the grid.
readonly className?: string;
}
// The top-level container. It creates the main `<table>` element and expects
// `GridHeader` and `GridBody` as children.
export class Grid implements m.ClassComponent<GridAttrs> {
view({attrs, children}: m.Vnode<GridAttrs>) {
const {fillHeight = false, className} = attrs;
return m(
'.pf-grid',
{
className: classNames(fillHeight && 'pf-grid--fill-height', className),
},
m('.pf-grid__table', m('table', children)),
);
}
}
// Renders the `<thead>` element. It's designed to contain `GridRow` components.
export class GridHeader implements m.ClassComponent {
view({children}: m.Vnode) {
return m('thead', children);
}
}
// Renders the `<tbody>` element. It will also contain `GridRow` components.
export class GridBody implements m.ClassComponent {
view({children}: m.Vnode) {
return m('tbody', children);
}
}
// Renders a `<tr>` element. It expects a list of cells.
export class GridRow implements m.ClassComponent {
view({children}: m.Vnode) {
return m('tr', children);
}
}
export type SortDirection = 'ASC' | 'DESC';
export type CellAlignment = 'left' | 'center' | 'right';
export type ReorderPosition = 'before' | 'after';
export interface GridHeaderCellAttrs extends m.Attributes {
// The current sort direction, if any.
readonly sort?: SortDirection;
// Callback invoked when the user clicks the sort button.
readonly onSort?: (direction: SortDirection) => void;
// An array of Mithril children (e.g., MenuItem, MenuDivider) for the
// context menu.
readonly menuItems?: m.Children;
// Horizontal alignment of the cell content.
readonly aggregation?: {
readonly left: m.Children;
readonly right: m.Children;
};
// A handle to identify a group of reorderable columns. Columns can only be
// reordered within the same group.
readonly reorderable?: {
readonly handle: string;
};
// Called when a column is dragged and dropped onto another column.
// The first argument is the `key` of the column being dragged, the second
// is the `key` of the column being dropped on.
readonly onReorder?: (
from: string | number | undefined,
to: string | number | undefined,
position: ReorderPosition,
) => void;
// If true, the cell will have a thick right border, useful for separating
// groups of columns.
readonly thickRightBorder?: boolean;
}
// Renders a `<th>` element, for use inside a `GridRow` within a `GridHeader`.
export class GridHeaderCell implements m.ClassComponent<GridHeaderCellAttrs> {
private dragOverState: {count: number; position: ReorderPosition} = {
count: 0,
position: 'after',
};
view({attrs, children, key}: m.Vnode<GridHeaderCellAttrs>) {
const {
sort,
onSort,
menuItems,
aggregation,
reorderable,
onReorder,
thickRightBorder,
...rest
} = attrs;
const renderSortButton = () => {
if (!onSort) return null;
const nextDirection: SortDirection = (() => {
if (!sort) return 'ASC';
if (sort === 'ASC') return 'DESC';
if (sort === 'DESC') return 'ASC';
// Default to ascending if no sort is defined.
return 'ASC';
})();
return m(Button, {
className: classNames(
!sort && 'pf-grid-cell__hint',
!sort && 'pf-visible-on-hover',
),
rounded: true,
icon: sort === 'DESC' ? Icons.SortDesc : Icons.SortAsc,
onclick: (e: MouseEvent) => {
onSort(nextDirection);
e.stopPropagation();
},
});
};
const renderMenu = () => {
if (menuItems === undefined) return null;
return m(
PopupMenu,
{
trigger: m(Button, {
className: 'pf-visible-on-hover pf-grid-cell__menu-button',
icon: Icons.ContextMenuAlt,
rounded: true,
}),
},
menuItems,
);
};
const hasAggregation = aggregation !== undefined;
const reorderHandle = reorderable?.handle;
return m(
'th',
{
...rest,
draggable: reorderable !== undefined,
className: classNames(
this.dragOverState.count > 0 && 'pf-drag-over',
this.dragOverState.count > 0 &&
`pf-drag-over--${this.dragOverState.position}`,
thickRightBorder && 'pf-grid-cell--thick-right-border',
),
ondragstart: (e: MithrilEvent<DragEvent>) => {
e.redraw = false;
e.dataTransfer!.setData(reorderable!.handle, JSON.stringify({key}));
},
ondragenter: (e: MithrilEvent<DragEvent>) => {
if (reorderHandle && e.dataTransfer!.types.includes(reorderHandle)) {
++this.dragOverState.count;
}
},
ondragleave: (e: MithrilEvent<DragEvent>) => {
if (reorderHandle && e.dataTransfer!.types.includes(reorderHandle)) {
--this.dragOverState.count;
}
},
ondragover: (e: MithrilEvent<DragEvent>) => {
e.preventDefault();
if (reorderHandle && e.dataTransfer!.types.includes(reorderHandle)) {
e.dataTransfer!.dropEffect = 'move';
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
this.dragOverState.position =
e.clientX < rect.left + rect.width / 2 ? 'before' : 'after';
} else {
e.dataTransfer!.dropEffect = 'none';
}
},
ondrop: (e: MithrilEvent<DragEvent>) => {
this.dragOverState.count = 0;
if (reorderHandle) {
const data = e.dataTransfer!.getData(reorderHandle);
if (data) {
e.preventDefault();
const {key: from} = JSON.parse(data);
const to = attrs.key as string | number | undefined;
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const position =
e.clientX < rect.left + rect.width / 2 ? 'before' : 'after';
onReorder?.(from, to, position);
}
}
},
},
m('.pf-grid-cell-header', [
m('.pf-grid-cell', [
m('.pf-grid-cell__content-container', [
m('.pf-grid-cell__content', children),
renderSortButton(),
]),
renderMenu(),
]),
hasAggregation &&
m(
'.pf-grid-cell__aggregation',
m('.pf-grid-cell__aggregation-left', aggregation.left),
m('.pf-grid-cell__aggregation-right', aggregation.right),
),
]),
);
}
}
export interface GridDataCellAttrs extends m.Attributes {
// An array of Mithril children (e.g., MenuItem, MenuDivider) for the
// context menu.
readonly menuItems?: m.Children;
// Horizontal alignment of the cell content.
readonly align?: CellAlignment;
// If true, the cell will be styled to indicate missing data.
readonly isMissing?: boolean;
// If true, the cell will have a thick right border.
readonly thickRightBorder?: boolean;
}
// Renders a `<td>` element, for use inside a `GridRow` within a `GridBody`.
export class GridDataCell implements m.ClassComponent<GridDataCellAttrs> {
view({attrs, children}: m.Vnode<GridDataCellAttrs>) {
const {menuItems, align, isMissing, thickRightBorder, className, ...rest} =
attrs;
const renderMenu = () => {
if (menuItems === undefined) return null;
return m(
'.pf-grid-cell__actions',
m(
PopupMenu,
{
trigger: m(Button, {
className: 'pf-grid-cell__menu-button pf-visible-on-hover',
icon: Icons.ContextMenuAlt,
rounded: true,
}),
},
menuItems,
),
);
};
return m(
'td',
{
...rest,
className: classNames(
className,
thickRightBorder && 'pf-grid-cell--thick-right-border',
),
},
m('.pf-grid-cell', [
m(
'.pf-grid-cell__content-container',
{
className: classNames(
align && `pf-grid-cell--align-${align}`,
isMissing && 'pf-grid-cell--missing',
),
},
m('.pf-grid-cell__content', children),
),
renderMenu(),
]),
);
}
}
export function renderSortMenuItems(
sorted: SortDirection | undefined,
sort: (direction: SortDirection | undefined) => void,
) {
return [
sorted !== 'DESC' &&
m(MenuItem, {
label: 'Sort: highest first',
icon: Icons.SortedDesc,
onclick: () => sort('DESC'),
}),
sorted !== 'ASC' &&
m(MenuItem, {
label: 'Sort: lowest first',
icon: Icons.SortedAsc,
onclick: () => sort('ASC'),
}),
sorted !== undefined &&
m(MenuItem, {
label: 'Unsort',
icon: Icons.Close,
onclick: () => sort(undefined),
}),
];
}
export interface PageControlAttrs {
readonly from: number;
readonly to: number;
readonly of: number;
nextPageClick(): void;
prevPageClick(): void;
firstPageClick(): void;
lastPageClick(): void;
}
import {Stack} from './stack';
export class PageControl implements m.ClassComponent<PageControlAttrs> {
view({attrs}: m.Vnode<PageControlAttrs>) {
const {
from,
to,
of,
firstPageClick,
prevPageClick,
nextPageClick,
lastPageClick,
} = attrs;
const isFirstPage = from === 1;
const isLastPage = to === of;
return m(Stack, {className: 'pf-page-control', orientation: 'horizontal'}, [
m('span', `${from} - ${to} of ${of}`),
m(Button, {
icon: Icons.FirstPage,
disabled: isFirstPage,
title: 'First Page',
onclick: firstPageClick,
}),
m(Button, {
icon: Icons.PrevPage,
disabled: isFirstPage,
title: 'Previous Page',
onclick: prevPageClick,
}),
m(Button, {
icon: Icons.NextPage,
disabled: isLastPage,
title: 'Next Page',
onclick: nextPageClick,
}),
m(Button, {
icon: Icons.LastPage,
disabled: isLastPage,
title: 'Last Page',
onclick: lastPageClick,
}),
]);
}
}