diff --git a/app/client/components/ColumnFilters.css b/app/client/components/ColumnFilters.css index 1588657e..8f97d51d 100644 --- a/app/client/components/ColumnFilters.css +++ b/app/client/components/ColumnFilters.css @@ -6,7 +6,8 @@ /* Make visible if open or in column header hover */ .g-column-menu-btn.open, .g-column-menu-btn.active, -.column_name:hover .g-column-menu-btn { +.column_name:hover .g-column-menu-btn, +.column_name .g-column-menu-btn.weasel-popup-open { visibility: visible; } @@ -14,35 +15,6 @@ visibility: hidden; } -.g-column-menu-btn > span.glyphicon { - padding: 1px; - margin-left: 2px; - margin-right: 2px; - background-color: #fff; - color: #999; - border: 1px solid #999; - border-radius: 3px; - font-size: 1rem; -} - -.g-column-menu-btn.left-btn > span.glyphicon { - margin: 0 0 0 2px; -} - -.g-column-menu-btn.right-btn > span.glyphicon { - margin: 0 2px 0 0; -} - -.g-column-menu-btn:hover > span.glyphicon { - color: #333; - border: 1px solid #333; -} - -.g-column-menu-btn.active > span.glyphicon { - color: #33f; - border-color: #33f; -} - .g-column-menu { position: absolute; min-width: 180px; diff --git a/app/client/components/DetailView.css b/app/client/components/DetailView.css index 402e6a31..22026796 100644 --- a/app/client/components/DetailView.css +++ b/app/client/components/DetailView.css @@ -52,15 +52,32 @@ } .detail_row_num { - text-align: right; font-size: var(--grist-x-small-font-size); font-weight: normal; color: var(--grist-color-slate); padding: 8px; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.detail_row_num .menu_toggle { + margin-left: 0.5rem; +} + +.detail_row_num:hover .menu_toggle, +.detail_row_num .menu_toggle.weasel-popup-open { + color: var(--color-link-default); +} + +/* hide menu on layout editor */ +.detailview_layout_editor .menu_toggle { + visibility: hidden !important; } .detail_row_num::before { content: "ROW "; + margin-right: 2px; } .detail-left.disabled, .detail-right.disabled, .detail-add-btn.disabled { @@ -200,6 +217,10 @@ margin-right: -1px; /* allow labels to overflow into the padding */ } +.detail_theme_record_compact .menu_toggle { + transform: translateY(-1px); +} + /*** form theme ***/ .detail_theme_field_form { diff --git a/app/client/components/DetailView.js b/app/client/components/DetailView.js index 11c3d16e..cfcc923c 100644 --- a/app/client/components/DetailView.js +++ b/app/client/components/DetailView.js @@ -13,6 +13,7 @@ var BaseView = require('./BaseView'); var CopySelection = require('./CopySelection'); var RecordLayout = require('./RecordLayout'); var commands = require('./commands'); +const {RowContextMenu} = require('../ui/RowContextMenu'); /** * DetailView component implements a list of record layouts. @@ -28,6 +29,7 @@ function DetailView(gristDoc, viewSectionModel) { this.recordLayout = this.autoDispose(RecordLayout.create({ viewSection: this.viewSection, buildFieldDom: this.buildFieldDom.bind(this), + buildContextMenu : this.buildContextMenu.bind(this), resizeCallback: () => { if (!this._isSingle) { this.scrolly().updateSize(); @@ -205,6 +207,15 @@ DetailView.prototype.getSelection = function() { ); }; +DetailView.prototype.buildContextMenu = function(row, options) { + const defaults = { + disableInsert: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand()), + disableDelete: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || row._isAddRow()), + isViewSorted: this.viewSection.activeSortSpec.peek().length > 0, + }; + return RowContextMenu(options ? Object.assign(defaults, options) : defaults); +} + /** * Builds the DOM for the given field of the given row. * @param {MetaRowModel|String} field: Model for the field to render. For a new field being added, @@ -262,6 +273,8 @@ DetailView.prototype.buildDom = function() { // Add .detailview_single when showing a single card or while editing layout. kd.toggleClass('detailview_single', () => this._isSingle || this.recordLayout.isEditingLayout()), + // Add a marker class that editor is active - used for hiding context menu toggle. + kd.toggleClass('detailview_layout_editor', this.recordLayout.isEditingLayout), kd.maybe(this.recordLayout.isEditingLayout, () => { const rowId = this.viewData.getRowId(this.recordLayout.editIndex.peek()); const record = this.getRenderedRowModel(rowId); diff --git a/app/client/components/GridView.css b/app/client/components/GridView.css index 1b30feb0..90a4a4aa 100644 --- a/app/client/components/GridView.css +++ b/app/client/components/GridView.css @@ -83,6 +83,21 @@ cursor: pointer; } +/* Menu toggle on a row */ +.gridview_data_row_num .menu_toggle { + visibility: hidden; + position: absolute; + top: 2px; + right: 0px; +} + +/* Show on hover or when menu is opened */ +.gridview_data_row_num:hover .menu_toggle, +.gridview_data_row_num .menu_toggle.weasel-popup-open { + visibility: visible; +} + + @media print { /* For printing, !important tag is needed for background colors to be respected; but normally, * do not want !important, as it interferes with row selection. @@ -339,8 +354,8 @@ .g-column-main-menu { position: absolute; - top: 0; - right: 0; + top: 3px; + right: 2px; } diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index ab765b00..72d1f9fe 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -29,9 +29,13 @@ const {onDblClickMatchElem} = require('app/client/lib/dblclick'); const {Holder} = require('grainjs'); const {menu} = require('../ui2018/menus'); const {calcFieldsCondition} = require('../ui/GridViewMenus'); -const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu, freezeAction} = require('../ui/GridViewMenus'); +const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, freezeAction} = require('../ui/GridViewMenus'); +const {RowContextMenu} = require('../ui/RowContextMenu'); + const {setPopupToCreateDom} = require('popweasel'); const {testId} = require('app/client/ui2018/cssVars'); +const {menuToggle} = require('app/client/ui/MenuToggle'); + // A threshold for interpreting a motionless click as a click rather than a drag. // Anything longer than this time (in milliseconds) should be interpreted as a drag @@ -901,8 +905,9 @@ GridView.prototype.buildDom = function() { kf.editableLabel(field.displayLabel, isEditingLabel, renameCommands), dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true) ), - this.isPreview ? null : dom('div.g-column-main-menu.g-column-menu-btn.right-btn', - dom('span.glyphicon.glyphicon-triangle-bottom'), + this.isPreview ? null : menuToggle(null, + kd.cssClass('g-column-main-menu'), + kd.cssClass('g-column-menu-btn'), // Prevent mousedown on the dropdown triangle from initiating column drag. dom.on('mousedown', () => false), // Select the column if it's not part of a multiselect. @@ -993,17 +998,26 @@ GridView.prototype.buildDom = function() { ); } }), + dom.on('contextmenu', ev => { + // This is a little hack to position the menu the same way as with a click, + // the same hack as on a column menu. + ev.preventDefault(); + ev.currentTarget.querySelector('.menu_toggle').click(); + }), + menuToggle(null, + dom.on('click', ev => self.maybeSelectRow(ev.currentTarget.parentNode, row.getRowId())), + menu(() => RowContextMenu({ + disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()), + disableDelete: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.getSelection().onlyAddRowSelected()), + isViewSorted: self.viewSection.activeSortSpec.peek().length > 0, + }), { trigger: ['click'] }), + // Prevent mousedown on the dropdown triangle from initiating row drag. + dom.on('mousedown', () => false), + testId('row-menu-trigger'), + ), kd.toggleClass('selected', () => !row._isAddRow() && self.cellSelector.isRowSelected(row._index())), - dom.on('contextmenu', ev => self.maybeSelectRow(ev.currentTarget, row.getRowId())), - menu(ctl => RowContextMenu({ - disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()), - disableDelete: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.getSelection().onlyAddRowSelected()), - isViewSorted: self.viewSection.activeSortSpec.peek().length > 0, - }), { trigger: ['contextmenu'] }), ), - - dom('div.record', kd.toggleClass('record-add', row._isAddRow), kd.style('borderLeftWidth', v.borderWidthPx), @@ -1379,6 +1393,8 @@ GridView.prototype._columnFilterMenu = function(ctl, field) { }; GridView.prototype.maybeSelectColumn = function (elem, field) { + // Change focus before running command so that the correct viewsection's cursor is moved. + this.viewSection.hasFocus(true); const selectedColIds = this.getSelection().colIds; if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) { return; // No need to select the column because it's included in the multi-selection @@ -1387,6 +1403,8 @@ GridView.prototype.maybeSelectColumn = function (elem, field) { }; GridView.prototype.maybeSelectRow = function(elem, rowId) { + // Change focus before running command so that the correct viewsection's cursor is moved. + this.viewSection.hasFocus(true); // If the clicked row was not already in the selection, move the selection to the row. if (!this.getSelection().rowIds.includes(rowId)) { this.assignCursor(elem, selector.ROW); diff --git a/app/client/components/RecordLayout.js b/app/client/components/RecordLayout.js index aab1e259..178e62da 100644 --- a/app/client/components/RecordLayout.js +++ b/app/client/components/RecordLayout.js @@ -32,9 +32,12 @@ var dispose = require('../lib/dispose'); var dom = require('../lib/dom'); var {Delay} = require('../lib/Delay'); var kd = require('../lib/koDom'); - var Layout = require('./Layout'); var RecordLayoutEditor = require('./RecordLayoutEditor'); +var commands = require('./commands'); +var {menuToggle} = require('app/client/ui/MenuToggle'); +var {menu} = require('../ui2018/menus'); +var {testId} = require('app/client/ui2018/cssVars'); /** * Construct a RecordLayout. @@ -47,6 +50,7 @@ var RecordLayoutEditor = require('./RecordLayoutEditor'); function RecordLayout(options) { this.viewSection = options.viewSection; this.buildFieldDom = options.buildFieldDom; + this.buildContextMenu = options.buildContextMenu; this.isEditingLayout = ko.observable(false); this.editIndex = ko.observable(0); this.layoutEditor = ko.observable(null); // RecordLayoutEditor when one is active. @@ -328,7 +332,23 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) { this.layoutEditor.peek().dispose(); this.layoutEditor(null); }) : null, - dom('div.detail_row_num', kd.text(() => (row._index() + 1))), + dom('div.detail_row_num', + kd.text(() => (row._index() + 1)), + dom.on('contextmenu', ev => { + // This is a little hack to position the menu the same way as with a click, + // the same hack as on a column menu. + ev.preventDefault(); + ev.currentTarget.querySelector('.menu_toggle').click(); + }), + menuToggle(null, + dom.on('click', () => { + this.viewSection.hasFocus(true); + commands.allCommands.setCursor.run(row); + }), + menu(() => this.buildContextMenu(row)), + testId('card-menu-trigger') + ) + ), dom('div.g_record_detail_inner', layout.rootElem) ); }; diff --git a/app/client/components/ViewLayout.css b/app/client/components/ViewLayout.css index 114c84a4..aeb7af22 100644 --- a/app/client/components/ViewLayout.css +++ b/app/client/components/ViewLayout.css @@ -3,6 +3,10 @@ flex: 1 1 0px; } +.viewsection_buttons { + margin-left: 4px; +} + .viewsection_title { flex-shrink: 0; align-items: baseline; diff --git a/app/client/ui/GridViewMenus.ts b/app/client/ui/GridViewMenus.ts index aaa1253b..6c747877 100644 --- a/app/client/ui/GridViewMenus.ts +++ b/app/client/ui/GridViewMenus.ts @@ -31,42 +31,6 @@ export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) { }, `Show column ${col.label()}`)) ]; } - -interface IRowContextMenu { - disableInsert: boolean; - disableDelete: boolean; - isViewSorted: boolean; -} - -export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: IRowContextMenu) { - const result: Element[] = []; - if (isViewSorted) { - // When the view is sorted, any newly added records get shifts instantly at the top or - // bottom. It could be very confusing for users who might expect the record to stay above or - // below the active row. Thus in this case we show a single `insert row` command. - result.push( - menuItemCmd(allCommands.insertRecordAfter, 'Insert row', - dom.cls('disabled', disableInsert)), - ); - } else { - result.push( - menuItemCmd(allCommands.insertRecordBefore, 'Insert row above', - dom.cls('disabled', disableInsert)), - menuItemCmd(allCommands.insertRecordAfter, 'Insert row below', - dom.cls('disabled', disableInsert)), - ); - } - result.push( - menuDivider(), - menuItemCmd(allCommands.deleteRecords, 'Delete', - dom.cls('disabled', disableDelete)), - ); - result.push( - menuDivider(), - menuItemCmd(allCommands.copyLink, 'Copy anchor link')); - return result; -} - interface IMultiColumnContextMenu { // For multiple selection, true/false means the value applies to all columns, 'mixed' means it's // true for some columns, but not all. diff --git a/app/client/ui/MenuToggle.ts b/app/client/ui/MenuToggle.ts new file mode 100644 index 00000000..6013f423 --- /dev/null +++ b/app/client/ui/MenuToggle.ts @@ -0,0 +1,34 @@ +import { dom, DomArg, IDisposableOwner, styled } from "grainjs"; +import { icon } from "app/client/ui2018/icons"; +import { colors } from "app/client/ui2018/cssVars"; + +/** + * Creates a toggle button - little square button with a dropdown icon inside, used + * by a context menu for a row inside a grid, a card inside a cardlist and column name. + */ +export function menuToggle(obs: IDisposableOwner, ...args: DomArg[]) { + const contextMenu = cssMenuToggle( + icon('Dropdown', dom.cls('menu_toggle_icon')), + ...args + ); + return contextMenu; +} + +const cssMenuToggle = styled('div.menu_toggle', ` + background: white; + cursor: pointer; + --icon-color: ${colors.slate}; + border: 1px solid ${colors.slate}; + border-radius: 4px; + &:hover { + --icon-color: ${colors.darkGreen}; + border-color: ${colors.darkGreen}; + } + &:active { + --icon-color: ${colors.darkerGreen}; + border-color: ${colors.darkerGreen}; + } + & > .menu_toggle_icon { + display: block; /* don't create a line */ + } +`); diff --git a/app/client/ui/RowContextMenu.ts b/app/client/ui/RowContextMenu.ts new file mode 100644 index 00000000..97fb33f3 --- /dev/null +++ b/app/client/ui/RowContextMenu.ts @@ -0,0 +1,38 @@ +import { allCommands } from 'app/client/components/commands'; +import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus'; +import { dom } from 'grainjs'; + +interface IRowContextMenu { + disableInsert: boolean; + disableDelete: boolean; + isViewSorted: boolean; +} + +export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: IRowContextMenu) { + const result: Element[] = []; + if (isViewSorted) { + // When the view is sorted, any newly added records get shifts instantly at the top or + // bottom. It could be very confusing for users who might expect the record to stay above or + // below the active row. Thus in this case we show a single `insert row` command. + result.push( + menuItemCmd(allCommands.insertRecordAfter, 'Insert row', + dom.cls('disabled', disableInsert)), + ); + } else { + result.push( + menuItemCmd(allCommands.insertRecordBefore, 'Insert row above', + dom.cls('disabled', disableInsert)), + menuItemCmd(allCommands.insertRecordAfter, 'Insert row below', + dom.cls('disabled', disableInsert)), + ); + } + result.push( + menuDivider(), + menuItemCmd(allCommands.deleteRecords, 'Delete', + dom.cls('disabled', disableDelete)), + ); + result.push( + menuDivider(), + menuItemCmd(allCommands.copyLink, 'Copy anchor link')); + return result; +} diff --git a/app/client/ui/ViewLayoutMenu.ts b/app/client/ui/ViewLayoutMenu.ts index 7b78f977..b04e7197 100644 --- a/app/client/ui/ViewLayoutMenu.ts +++ b/app/client/ui/ViewLayoutMenu.ts @@ -8,8 +8,30 @@ import {dom} from 'grainjs'; * Returns a list of menu items for a view section. */ export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionRec, isReadonly: boolean) { - const gristDoc = viewSection.viewInstance.peek()!.gristDoc; + const viewInstance = viewSection.viewInstance.peek()!; + const gristDoc = viewInstance.gristDoc; + + // get current row index from cursor + const cursorRow = viewInstance.cursor.rowIndex.peek(); + // get row id from current data + // rowId can be string - it is wrongly typed in cursor and in viewData + const rowId = (cursorRow !== null ? viewInstance.viewData.getRowId(cursorRow) : null) as string|null|number; + const isAddRow = rowId === 'new'; + + const contextMenu = [ + menuItemCmd(allCommands.deleteRecords, + 'Delete record', + testId('section-delete-card'), + dom.cls('disabled', isReadonly || isAddRow)), + menuItemCmd(allCommands.copyLink, + 'Copy anchor link', + testId('section-card-link'), + ), + menuDivider(), + ]; + return [ + dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu), menuItemCmd(allCommands.printSection, 'Print widget', testId('print-section')), menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}, 'Download as CSV', testId('download-section')),