diff --git a/app/client/components/GridView.css b/app/client/components/GridView.css index 819234da..1b30feb0 100644 --- a/app/client/components/GridView.css +++ b/app/client/components/GridView.css @@ -149,10 +149,21 @@ cursor: pointer; } +/* Left most shadow - displayed next to row numbers or when columns are frozen - after last frozen column */ .scroll_shadow_left { - height: 100%; /* Just needs to be tall enough to flow off the bottom*/ + height: 100%; width: 0px; - left: 4rem; + /* Unfortunately we need to calculate this using scroll position. + We could use sticky position here, but we would need to move this component inside the + scroll pane. We don't want to do this, because we want the scroll shadow to be render + on top of the scroll bar. Fortunately it doesn't jitter on firefox - where scroll event is asynchronous. + Variables used here: + - frozen-width : total width of frozen columns plus row numbers width + - scroll-offset: current left offset of the scroll pane + - frozen-offset: when frozen columns are wider then the screen, we want them to move left initially, + this value is the position where this movement should stop. + */ + left: calc(4em + (var(--frozen-width, 0) - min(var(--frozen-scroll-offset, 0), var(--frozen-offset, 0))) * 1px); box-shadow: -6px 0 6px 6px #444; /* shadow should only show to the right of it (10px should be enough) */ -webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%); @@ -160,6 +171,33 @@ z-index: 3; } +/* Right shadow - normally not displayed - activated when grid has frozen columns */ +.scroll_shadow_frozen { + height: 100%; + width: 0px; + left: 4em; + box-shadow: -8px 0 14px 4px #444; + -webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%); + clip-path: polygon(0 0, 28px 0, 24px 100%, 0 100%); + z-index: 3; + position: absolute; +} + +/* line that indicates where the frozen columns end */ +.frozen_line { + position:absolute; + height: 100%; + width: 2px; + /* this value is the same as for the left shadow - but doesn't need to really on the scroll offset + as this component will be hidden when the scroll starts + */ + left: calc(4em + var(--frozen-width, 0) * 1px); + background-color: #999999; + z-index: 3; + user-select: none; + pointer-events: none +} + .scroll_shadow_top { left: 0; height: 0; @@ -181,6 +219,17 @@ border-right: 1px solid lightgray; } +.gridview_left_border { + position: absolute; + width: 0px; /* Matches rowid width (+border) */ + height: 100%; + z-index: 3; + left: calc(4rem); + border-right: 1px solid var(--grist-color-dark-grey) !important; + user-select: none; + pointer-events: none +} + .gridview_header_backdrop_top { width: 100%; height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */ @@ -243,6 +292,49 @@ pointer-events: none; /* prevents row drag shadow from stealing row headers clicks */ } +/* ================ Freezing columns */ + +/* style header and a data field */ +.record .field.frozen { + position: sticky; + left: calc(4em + 1px + (var(--frozen-position, 0) - var(--frozen-offset, 0)) * 1px); /* 4em for row number + total width of cells + 1px for border*/ + z-index: 1; +} + +/* for data field we need to reuse color from record (add-row and zebra stripes) */ +.gridview_row .record .field.frozen { + background-color: inherit; +} + +/* HACK: add box shadow to fix outline overflow from active cursor */ +.gridview_row .record .field.frozen { + box-shadow: 0px 1px 0px white; +} + +.gridview_row .record.record-hlines .field.frozen { + box-shadow: 0px 1px 0px var(--grist-color-dark-grey); +} + +/* selected field has a transparent color - with frozen fields we can't do it */ +.gridview_row .field.frozen.selected { + background-color: var(--grist-color-selection-opaque); +} + +/* make room for a frozen line by adding margin to first not frozen field - in header and in data */ +.field.frozen + .field:not(.frozen) { + margin-left: 1px; +} + +/* printing frozen fields is straightforward - just need to remove transparency */ +@media print { + .field.frozen { + background: white !important; + } + .column_names .column_name.frozen { + background: var(--grist-color-light-grey) !important; + } +} + /* Etc */ .g-column-main-menu { diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 040e69ce..ab765b00 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -29,7 +29,7 @@ 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} = require('../ui/GridViewMenus'); +const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu, freezeAction} = require('../ui/GridViewMenus'); const {setPopupToCreateDom} = require('popweasel'); const {testId} = require('app/client/ui2018/cssVars'); @@ -41,6 +41,10 @@ const {testId} = require('app/client/ui2018/cssVars'); // it was. const SHORT_CLICK_IN_MS = 500; +// size of the plus width () +const PLUS_WIDTH = 40; +// size of the row number field (we assume 4rem) +const ROW_NUMBER_WIDTH = 52; /** * GridView component implements the view of a grid of cells. @@ -59,7 +63,10 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { this.rowShadowAdjust = 0; // pixel dist from mouse click y-coord and the clicked row's top offset this.colShadowAdjust = 0; // ^ for x-coord and clicked col's left offset this.scrollLeft = ko.observable(0); + this.isScrolledLeft = this.autoDispose(ko.computed(() => this.scrollLeft() > 0)); this.scrollTop = ko.observable(0); + this.isScrolledTop = this.autoDispose(ko.computed(() => this.scrollTop() > 0)); + this.cellSelector = this.autoDispose(selector.CellSelector.create(this, { // This is a bit of a hack to prevent dragging when there's an open column menu isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty()) @@ -85,7 +92,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { const leftEdge = this.scrollPane.scrollLeft; const rightEdge = leftEdge + viewWidth; - //If cell doesnt fit onscreen, scroll to fit + //If cell doesn't fit onscreen, scroll to fit const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth); this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift; })); @@ -94,14 +101,69 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { // Some observables for the scroll markers that show that the view is cut off on a side. this.scrollShadow = { - left: ko.observable(false), - top: ko.observable(false), + left: this.isScrolledLeft, + top: this.isScrolledTop }; //-------------------------------------------------- // Set up row and column context menus. this.ctxMenuHolder = Holder.create(this); + //-------------------------------------------------- + // Set frozen columns variables + + // keep track of the width for this component + this.width = ko.observable(0); + // helper for clarity + this.numFrozen = this.viewSection.numFrozen; + // calculate total width of all frozen columns + this.frozenWidth = this.autoDispose(ko.pureComputed(() => this.colRightOffsets().getSumTo(this.numFrozen()))); + // show frozenLine when have some frozen columns and not scrolled left + this.frozenLine = this.autoDispose(ko.pureComputed(() => this.numFrozen() && !this.isScrolledLeft())); + // even if some columns are frozen, we still want to move them left + // when screen is too narrow - here we will calculate how much space + // is needed to move all the frozen columns left in order to show some + // unfrozen columns to user (by default we will try to show at least one not + // frozen column and a plus button) + this.frozenOffset = this.autoDispose(ko.computed(() => { + // get the last field + const fields = this.viewSection.viewFields().all(); + const lastField = fields[fields.length-1]; + // get the last field width (or zero - grid can have zero columns) + const revealWidth = lastField ? lastField.widthDef() : 0; + // calculate the offset: start from zero, then move all left to hide frozen columns, + // then to right to fill whole width, then to left to reveal last column and plus button + const initialOffset = -this.frozenWidth() - ROW_NUMBER_WIDTH + this.width() - revealWidth - PLUS_WIDTH; + // Final check - we actually don't want to have + // the split (between frozen and normal columns) be moved left too far, + // it should stop at the middle of the available grid space (whole width - row number width). + // This can happen when last column is too wide, and we are not able to show it in a full width. + // To calculate the middle point: hide all frozen columns (by moving them maximum to the left) + // and then move them to right by half width of the section. + const middleOffset = -this.frozenWidth() - ROW_NUMBER_WIDTH + this.width() / 2; + // final offset is the bigger number of those two (offsets are negative - so take + // the number that is closer to 0) + const offset = Math.floor(Math.max(initialOffset, middleOffset)); + // offset must be negative (we are moving columns left), if we ended up moving + // frozen columns to the right, don't move them at all + return offset > 0 ? 0 : Math.abs(offset); + })); + // observable for left scroll - but return left only when columns are frozen + // this will be used to move frozen border alongside with the scrollpane + this.frozenScrollOffset = this.autoDispose(ko.computed(() => this.numFrozen() ? this.scrollLeft() : 0)); + // observable that will indicate if shadow is needed on top of frozen columns + this.frozenShadow = this.autoDispose(ko.computed(() => { + return this.numFrozen() && this.frozenOffset() && this.isScrolledLeft(); + })); + // calculate column right offsets + this.frozenPositions = this.autoDispose(this.viewSection.viewFields().map(function(field){ + return ko.pureComputed(() => this.colRightOffsets().getSumTo(field._index())); + }, this)); + // calculate frozen state for all columns + this.frozenMap = this.autoDispose(this.viewSection.viewFields().map(function(field){ + return ko.pureComputed(() => field._index() < this.numFrozen()); + }, this)); + //-------------------------------------------------- // Create and attach the DOM for the view. @@ -110,6 +172,8 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { }, this)); this.header = null; this._cornerDom = null; + // dom for adding new column - used by freeze calculation + this._modField = null; this.scrollPane = null; this.viewPane = this.autoDispose(this.buildDom()); this.attachSelectorHandlers(); @@ -202,6 +266,23 @@ GridView.gridCommands = { }, addSortDesc: function() { addToSort(this.viewSection.activeSortSpec, -this.currentColumn().getRowId()); + }, + toggleFreeze: function() { + // get column selection + const selection = this.getSelection(); + // convert it menu option + const options = this._getColumnMenuOptions(selection); + // generate action that is available for freeze toggle + const action = freezeAction(options); + // if no action, do nothing + if (!action) { return; } + // if grist document is in readonly - simply change the value + // without saving + if (this.gristDoc.isReadonly.get()) { + this.viewSection.rawNumFrozen(action.numFrozen); + return; + } + this.viewSection.rawNumFrozen.setAndSave(action.numFrozen); } }; @@ -252,7 +333,7 @@ GridView.prototype.paste = function(data, cutCallback) { // as frozen (and get marked as unsorted if necessary) for any update even if the update comes // from a different peer. - // convert row-wise data to column-wise so that it better resembles a useraction + // convert row-wise data to column-wise so that it better resembles a user action let pasteData = _.unzip(data); let pasteHeight = pasteData[0].length; let pasteWidth = pasteData.length; @@ -701,10 +782,9 @@ GridView.prototype.domToColModel = function(elem, elemType) { //TODO : is this necessary? make passive. Also this could be removed soon I think GridView.prototype.onScroll = function() { var pane = this.scrollPane; - this.scrollShadow.left(pane.scrollLeft > 0); - this.scrollShadow.top(pane.scrollTop > 0); this.scrollLeft(pane.scrollLeft); this.scrollTop(pane.scrollTop); + this.width(pane.clientWidth); }; @@ -733,7 +813,10 @@ GridView.prototype.buildDom = function() { return dom( 'div.gridview_data_pane.flexvbox', this.gristDoc.app.addNewUIClass(), - + // offset for frozen columns - how much move them to the left + kd.style('--frozen-offset', this.frozenOffset), + // total width of frozen columns + kd.style('--frozen-width', this.frozenWidth), // Corner, bars and shadows // Corner and shadows (so it's fixed to the grid viewport) self._cornerDom = dom( @@ -741,9 +824,16 @@ GridView.prototype.buildDom = function() { dom.on('click', () => this.selectAll()), ), dom('div.scroll_shadow_top', kd.show(this.scrollShadow.top)), - dom('div.scroll_shadow_left', kd.show(this.scrollShadow.left)), + dom('div.scroll_shadow_left', + kd.show(this.scrollShadow.left), + // pass current scroll position + kd.style('--frozen-scroll-offset', this.frozenScrollOffset)), + dom('div.frozen_line', kd.show(this.frozenLine)), dom('div.gridview_header_backdrop_left'), //these hide behind the actual headers to keep them from flashing dom('div.gridview_header_backdrop_top'), + dom('div.gridview_left_border'), //these hide behind the actual headers to keep them from flashing + // left shadow that will be visible on top of frozen columns + dom('div.scroll_shadow_frozen', kd.show(this.frozenShadow)), // Drag indicators self.colLine = dom( @@ -794,6 +884,8 @@ GridView.prototype.buildDom = function() { let filterTriggerCtl; return dom( 'div.column_name.field', + kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))), + kd.toggleClass("frozen", () => ko.unwrap(this.frozenMap.at(field._index()))), dom.autoDispose(isEditingLabel), dom.testId("GridView_columnLabel"), kd.style('width', field.widthPx), @@ -829,8 +921,9 @@ GridView.prototype.buildDom = function() { ); }), this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => ( - dom('div.column_name.mod-add-column.field', + this._modField = dom('div.column_name.mod-add-column.field', '+', + kd.style("width", PLUS_WIDTH + 'px'), dom.on('click', ev => { // If there are no hidden columns, clicking the plus just adds a new column. // If there are hidden columns, display a dropdown menu. @@ -880,6 +973,7 @@ GridView.prototype.buildDom = function() { // rowid dom dom('div.gridview_data_row_num', + kd.style("width", ROW_NUMBER_WIDTH + 'px'), dom('div.gridview_data_row_info', kd.toggleClass('linked_dst', () => { // Must ensure that linkedRowId is not null to avoid drawing on rows whose @@ -948,6 +1042,8 @@ GridView.prototype.buildDom = function() { }); return dom( 'div.field', + kd.style('--frozen-position', () => ko.unwrap(self.frozenPositions.at(field._index()))), + kd.toggleClass("frozen", () => ko.unwrap(self.frozenMap.at(field._index()))), kd.toggleClass('scissors', isCopyActive), dom.autoDispose(isCopyActive), dom.autoDispose(isCellSelected), @@ -979,6 +1075,7 @@ GridView.prototype.onResize = function() { } else { this.scrolly.scheduleUpdateSize(); } + this.width(this.scrollPane.clientWidth) }; /** @inheritdoc */ @@ -1265,7 +1362,10 @@ GridView.prototype.columnContextMenu = function(ctl, copySelection, field, filte GridView.prototype._getColumnMenuOptions = function(copySelection) { return { + columnIndices: copySelection.fields.map(f => f._index()), + totalColumnCount : this.viewSection.viewFields.peek().peekLength, numColumns: copySelection.fields.length, + numFrozen: this.viewSection.numFrozen.peek(), disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()), isReadonly: this.gristDoc.isReadonly.get(), isFiltered: this.isFiltered(), diff --git a/app/client/components/commandList.js b/app/client/components/commandList.js index b69b1fa6..45884de6 100644 --- a/app/client/components/commandList.js +++ b/app/client/components/commandList.js @@ -331,6 +331,10 @@ exports.groups = [{ name: 'hideField', keys: ['Alt+Shift+-'], desc: 'Hide the currently selected column' + }, { + name: 'toggleFreeze', + keys: [], + desc: 'Freeze or unfreeze selected columns' }, { name: 'deleteFields', keys: ['Alt+-'], diff --git a/app/client/components/viewCommon.css b/app/client/components/viewCommon.css index eb50098b..ee29e830 100644 --- a/app/client/components/viewCommon.css +++ b/app/client/components/viewCommon.css @@ -1,3 +1,6 @@ +/* + record class is used for grid view header and rows + */ .record { display: -webkit-flex; display: flex; @@ -10,6 +13,13 @@ border-color: var(--grist-color-dark-grey); border-left-style: solid; /* left border, against rownumbers div, always on */ border-bottom-width: 1px; /* style: none, set by record-hlines*/ + /* Record background is white by default. + It gets overridden by the add row, zebra stripes. + It also gets overridden by selecting rows - but in that case background comes from + selected fields - this still remains white. + TODO: consider making this color the single source + */ + background: white; } .record.record-hlines { /* Overwrites style, width set on element */ diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 4922064d..8d4ad437 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -89,6 +89,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> { isSorted: ko.Computed; disableDragRows: ko.Computed; activeFilterBar: modelUtil.CustomComputed; + // Number of frozen columns + rawNumFrozen: modelUtil.CustomComputed; + // Number for frozen columns to display. + // We won't freeze all the columns on a grid, it will leave at least 1 column unfrozen. + numFrozen: ko.Computed; // Save all filters of fields in the section. saveFilters(): Promise; @@ -133,6 +138,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): zebraStripes: false, customView: '', filterBar: false, + numFrozen: 0 }; this.optionsObj = modelUtil.jsonObservable(this.options, (obj: any) => defaults(obj || {}, defaultOptions)); @@ -276,4 +282,17 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort()); this.activeFilterBar = modelUtil.customValue(this.optionsObj.prop('filterBar')); + + // Number of frozen columns + this.rawNumFrozen = modelUtil.customValue(this.optionsObj.prop('numFrozen')); + // Number for frozen columns to display + this.numFrozen = ko.pureComputed(() => + Math.max( + 0, + Math.min( + this.rawNumFrozen(), + this.viewFields().all().length - 1 + ) + ) + ); } diff --git a/app/client/ui/GridViewMenus.ts b/app/client/ui/GridViewMenus.ts index 7f6bb9b2..aaa1253b 100644 --- a/app/client/ui/GridViewMenus.ts +++ b/app/client/ui/GridViewMenus.ts @@ -71,10 +71,14 @@ 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. numColumns: number; + numFrozen: number; disableModify: boolean|'mixed'; // If the columns are read-only. isReadonly: boolean; isFiltered: boolean; // If this view shows a proper subset of all rows in the table. isFormula: boolean|'mixed'; + columnIndices: number[]; + totalColumnCount: number; + disableFrozenMenu: boolean; } interface IColumnContextMenu extends IMultiColumnContextMenu { @@ -94,6 +98,7 @@ export function ColumnContextMenu(options: IColumnContextMenu) { const disableForReadonlyView = dom.cls('disabled', isReadonly); const addToSortLabel = getAddToSortLabel(sortSpec, colId); + return [ menuItemCmd(allCommands.fieldTabOpen, 'Column Options'), menuItem(filterOpenFunc, 'Filter Data'), @@ -142,9 +147,9 @@ export function ColumnContextMenu(options: IColumnContextMenu) { ] : null, menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn), menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView), - + freezeMenuItemCmd(options), menuDivider(), - MultiColumnMenu(options), + MultiColumnMenu((options.disableFrozenMenu = true, options)), testId('column-menu'), ]; } @@ -165,10 +170,11 @@ export function MultiColumnMenu(options: IMultiColumnContextMenu) { (num > 1 ? `Clear ${num} entire columns` : 'Clear entire column') : (num > 1 ? `Clear ${num} columns` : 'Clear column'); const nameDeleteColumns = num > 1 ? `Delete ${num} columns` : 'Delete column'; + const frozenMenu = options.disableFrozenMenu ? null : freezeMenuItemCmd(options); return [ // TODO This should be made to work too for multiple columns. // menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView), - + frozenMenu ? [frozenMenu, menuDivider()]: null, // Offered only when selection includes formula columns, and converts only those. (options.isFormula ? menuItemCmd(allCommands.convertFormulasToData, 'Convert formula to data', @@ -183,10 +189,119 @@ export function MultiColumnMenu(options: IMultiColumnContextMenu) { menuDivider(), menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', disableForReadonlyView), - menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right', disableForReadonlyView), + menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right', disableForReadonlyView) ]; } +export function freezeAction(options: IMultiColumnContextMenu): { text: string; numFrozen: number; } | null { + /** + * When user clicks last column - don't offer freezing + * When user clicks on a normal column - offer him to freeze all the columns to the + * left (inclusive). + * When user clicks on a frozen column - offer him to unfreeze all the columns to the + * right (inclusive) + * When user clicks on a set of columns then: + * - If the set of columns contains the last columns that are frozen - offer unfreezing only those columns + * - If the set of columns is right after the frozen columns or spans across - offer freezing only those columns + * + * All of the above are a single command - toggle freeze + */ + + const length = options.numColumns; + + // make some assertions - number of columns selected should always be > 0 + if (length === 0) { return null; } + + const indices = options.columnIndices; + const firstColumnIndex = indices[0]; + const lastColumnIndex = indices[indices.length - 1]; + const numFrozen = options.numFrozen; + + // if set has last column in it - don't offer freezing + if (lastColumnIndex == options.totalColumnCount - 1) { + return null; + } + + const isNormalColumn = length === 1 && (firstColumnIndex + 1) > numFrozen; + const isFrozenColumn = length === 1 && (firstColumnIndex+ 1) <= numFrozen; + const isSet = length > 1; + const isLastFrozenSet = isSet && lastColumnIndex + 1 === numFrozen; + const isFirstNormalSet = isSet && firstColumnIndex === numFrozen; + const isSpanSet = isSet && firstColumnIndex <= numFrozen && lastColumnIndex >= numFrozen; + + let text = ''; + + if (!isSet) { + if (isNormalColumn) { + // text to show depends on what user selected and how far are we from + // last frozen column + + // if user clicked the first column or a column just after frozen set + if (firstColumnIndex === 0 || firstColumnIndex === numFrozen) { + text = 'Freeze this column'; + } else { + // else user clicked any other column that is farther, offer to freeze + // proper number of column + const properNumber = firstColumnIndex - numFrozen + 1; + text = `Freeze ${properNumber} ${numFrozen ? 'more ' : ''}columns`; + } + return { + text, + numFrozen : firstColumnIndex + 1 + }; + } else if (isFrozenColumn) { + // when user clicked last column in frozen set - offer to unfreeze this column + if (firstColumnIndex + 1 === numFrozen) { + text = `Unfreeze this column`; + } else { + // else user clicked column that is not the last in a frozen set + // offer to unfreeze proper number of columns + const properNumber = numFrozen - firstColumnIndex; + text = `Unfreeze ${properNumber === numFrozen ? 'all' : properNumber} columns`; + } + return { + text, + numFrozen : indices[0] + }; + } else { + return null; + } + } else { + if (isLastFrozenSet) { + text = `Unfreeze ${length} columns`; + return { + text, + numFrozen : numFrozen - length + }; + } else if (isFirstNormalSet) { + text = `Freeze ${length} columns`; + return { + text, + numFrozen : numFrozen + length + }; + } else if (isSpanSet) { + const toFreeze = lastColumnIndex + 1 - numFrozen; + text = `Freeze ${toFreeze == 1 ? 'one more column' : (`${toFreeze} more columns`)}`; + return { + text, + numFrozen : numFrozen + toFreeze + }; + } else { + return null; + } + } +} + +function freezeMenuItemCmd(options: IMultiColumnContextMenu) { + // calculate action available for this options + const toggle = freezeAction(options); + // if we can't offer freezing - don't create a menu at all + // this shouldn't happen - as current design offers some action on every column + if (!toggle) { return null; } + // create menu item if we have something to offer + return menuItemCmd(allCommands.toggleFreeze, toggle.text); +} + // Returns 'Add to sort' is there are columns in the sort spec but colId is not part of it. Returns // undefined if colId is the only column in the spec. Otherwise returns `Sorted (#N)` where #N is // the position (1 based) of colId in the spec. diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 1ddc7582..2770a49c 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -43,6 +43,8 @@ export const colors = { cursor: new CustomProp('color-cursor', '#16B378'), // cursor is lightGreen selection: new CustomProp('color-selection', 'rgba(22,179,120,0.15)'), + selectionOpaque: new CustomProp('color-selection-opaque', '#DCF4EB'), + inactiveCursor: new CustomProp('color-inactive-cursor', '#A2E1C9'), hover: new CustomProp('color-hover', '#bfbfbf'), diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index ffbfe467..b7ad1dcc 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1479,6 +1479,14 @@ export async function addColumn(name: string) { await waitForServer(); } +// Select a range of columns, clicking on col1 and dragging to col2. +export async function selectColumnRange(col1: string, col2: string) { + await getColumnHeader({col: col1}).mouseMove(); + await driver.mouseDown(); + await getColumnHeader({col: col2}).mouseMove(); + await driver.mouseUp(); +} + /** * Changes browser window dimension to FullHd for a test suit. */