diff --git a/README.md b/README.md index 08e4d7419e..d30bbb24ec 100644 --- a/README.md +++ b/README.md @@ -1378,7 +1378,7 @@ To reduce the time for info panel data to appear, data can be prefetched. | Parameter | Type | Optional | Default | Example | Description | |--------------------------------|---------|----------|---------|---------|-----------------------------------------------------------------------------------------------------------------------------------| -| `infoPanel[*].prefetchObjects` | Number | yes | `0` | `2` | Number of next rows to prefetch when browsing sequential rows. For example, `2` means the next 2 rows will be fetched in advance. | +| `infoPanel[*].prefetchObjects` | Number | yes | `0` | `2` | Number of navigation steps to prefetch ahead when browsing sequential rows. For example, `2` means data for the next 2 navigation steps will be fetched in advance. When using multi-panel mode with batch navigation enabled, each navigation step corresponds to a full batch of panels, so the total number of prefetched objects will be `prefetchObjects × panelCount`. | | `infoPanel[*].prefetchStale` | Number | yes | `0` | `10` | Duration in seconds after which prefetched data is discarded as stale. | | `infoPanel[*].prefetchImage` | Boolean | yes | `true` | `false` | Whether to prefetch image content when prefetching objects. Only applies when `prefetchObjects` is enabled. | | `infoPanel[*].prefetchVideo` | Boolean | yes | `true` | `false` | Whether to prefetch video content when prefetching objects. Only applies when `prefetchObjects` is enabled. | diff --git a/src/components/Toolbar/Toolbar.react.js b/src/components/Toolbar/Toolbar.react.js index e5e6642534..9193d96692 100644 --- a/src/components/Toolbar/Toolbar.react.js +++ b/src/components/Toolbar/Toolbar.react.js @@ -158,19 +158,35 @@ const Toolbar = props => { {props.classwiseCloudFunctions && props.classwiseCloudFunctions[`${props.appId}${props.appName}`] && props.classwiseCloudFunctions[`${props.appId}${props.appName}`][props.className] && ( - + )} + )} - + + )} ); diff --git a/src/components/Toolbar/Toolbar.scss b/src/components/Toolbar/Toolbar.scss index 88f7261171..5453305442 100644 --- a/src/components/Toolbar/Toolbar.scss +++ b/src/components/Toolbar/Toolbar.scss @@ -126,19 +126,25 @@ body:global(.expanded) { background-color: white; } -.btn { +.panelButtons { position: absolute; right: 5px; bottom: 5px; + display: flex; + gap: 5px; +} + +.btn { + position: relative; border: none; - padding: 6px 3px; - width: 110px; + padding: 6px 8px; background: none; color: white; display: flex; align-items: center; gap: 5px; cursor: pointer; + white-space: nowrap; svg { @@ -152,4 +158,15 @@ body:global(.expanded) { fill: #ffffff; } } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + + &:hover { + svg { + fill: #797592; + } + } + } } diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index 9a322592d8..f57018c7fb 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -80,6 +80,9 @@ const BrowserToolbar = ({ togglePanel, isPanelVisible, + addPanel, + removePanel, + panelCount, classwiseCloudFunctions, appId, appName, @@ -87,6 +90,12 @@ const BrowserToolbar = ({ toggleScrollToTop, autoLoadFirstRow, toggleAutoLoadFirstRow, + syncPanelScroll, + toggleSyncPanelScroll, + batchNavigate, + toggleBatchNavigate, + showPanelCheckbox, + toggleShowPanelCheckbox, }) => { const selectionLength = Object.keys(selection).length; const isPendingEditCloneRows = editCloneRows && editCloneRows.length > 0; @@ -282,6 +291,9 @@ const BrowserToolbar = ({ selectedData={selectedData} togglePanel={togglePanel} isPanelVisible={isPanelVisible} + addPanel={addPanel} + removePanel={removePanel} + panelCount={panelCount} classwiseCloudFunctions={classwiseCloudFunctions} appId={appId} appName={appName} @@ -409,6 +421,63 @@ const BrowserToolbar = ({ toggleAutoLoadFirstRow(); }} /> + + {syncPanelScroll && ( + + )} + Sync panel scrolling + + } + onClick={() => { + toggleSyncPanelScroll(); + }} + /> + + {batchNavigate && ( + + )} + Batch-navigate panels + + } + onClick={() => { + toggleBatchNavigate(); + }} + /> + + {showPanelCheckbox && ( + + )} + Show panel selection + + } + onClick={() => { + toggleShowPanelCheckbox(); + }} + />
diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 8311bf1d14..aeeddc3c29 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -23,6 +23,9 @@ const BROWSER_SHOW_ROW_NUMBER = 'browserShowRowNumber'; const AGGREGATION_PANEL_VISIBLE = 'aggregationPanelVisible'; const BROWSER_SCROLL_TO_TOP = 'browserScrollToTop'; const AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW = 'aggregationPanelAutoLoadFirstRow'; +const AGGREGATION_PANEL_SYNC_SCROLL = 'aggregationPanelSyncScroll'; +const AGGREGATION_PANEL_BATCH_NAVIGATE = 'aggregationPanelBatchNavigate'; +const AGGREGATION_PANEL_SHOW_CHECKBOX = 'aggregationPanelShowCheckbox'; function formatValueForCopy(value, type) { if (value === undefined) { @@ -89,6 +92,12 @@ export default class DataBrowser extends React.Component { window.localStorage?.getItem(BROWSER_SCROLL_TO_TOP) !== 'false'; const storedAutoLoadFirstRow = window.localStorage?.getItem(AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW) === 'true'; + const storedSyncPanelScroll = + window.localStorage?.getItem(AGGREGATION_PANEL_SYNC_SCROLL) !== 'false'; + const storedBatchNavigate = + window.localStorage?.getItem(AGGREGATION_PANEL_BATCH_NAVIGATE) !== 'false'; + const storedShowPanelCheckbox = + window.localStorage?.getItem(AGGREGATION_PANEL_SHOW_CHECKBOX) !== 'false'; const hasAggregation = props.classwiseCloudFunctions?.[ `${props.app.applicationId}${props.appName}` @@ -115,8 +124,15 @@ export default class DataBrowser extends React.Component { showRowNumber: storedRowNumber, scrollToTop: storedScrollToTop, autoLoadFirstRow: storedAutoLoadFirstRow, + syncPanelScroll: storedSyncPanelScroll, + batchNavigate: storedBatchNavigate, + showPanelCheckbox: storedShowPanelCheckbox, prefetchCache: {}, selectionHistory: [], + displayedObjectIds: [], // Array of object IDs currently displayed in the panel + panelCount: 1, // Number of panels to display + multiPanelData: {}, // Object mapping objectId to panel data + _objectsToFetch: [], // Temporary field for async fetch handling }; this.handleResizeDiv = this.handleResizeDiv.bind(this); @@ -140,9 +156,18 @@ export default class DataBrowser extends React.Component { this.setShowRowNumber = this.setShowRowNumber.bind(this); this.toggleScrollToTop = this.toggleScrollToTop.bind(this); this.toggleAutoLoadFirstRow = this.toggleAutoLoadFirstRow.bind(this); + this.toggleSyncPanelScroll = this.toggleSyncPanelScroll.bind(this); + this.toggleBatchNavigate = this.toggleBatchNavigate.bind(this); + this.toggleShowPanelCheckbox = this.toggleShowPanelCheckbox.bind(this); this.handleCellClick = this.handleCellClick.bind(this); + this.addPanel = this.addPanel.bind(this); + this.removePanel = this.removePanel.bind(this); + this.handlePanelScroll = this.handlePanelScroll.bind(this); + this.handleWrapperWheel = this.handleWrapperWheel.bind(this); this.saveOrderTimeout = null; this.aggregationPanelRef = React.createRef(); + this.panelColumnRefs = []; + this.multiPanelWrapperRef = React.createRef(); } componentWillReceiveProps(props) { @@ -204,11 +229,19 @@ export default class DataBrowser extends React.Component { componentDidMount() { document.body.addEventListener('keydown', this.handleKey); window.addEventListener('resize', this.updateMaxWidth); + // Add wheel event listener for multi-panel scrolling + if (this.multiPanelWrapperRef.current && this.state.panelCount > 1 && this.state.syncPanelScroll) { + this.multiPanelWrapperRef.current.addEventListener('wheel', this.handleWrapperWheel, { passive: false }); + } } componentWillUnmount() { document.body.removeEventListener('keydown', this.handleKey); window.removeEventListener('resize', this.updateMaxWidth); + // Remove wheel event listener + if (this.multiPanelWrapperRef.current) { + this.multiPanelWrapperRef.current.removeEventListener('wheel', this.handleWrapperWheel); + } } componentDidUpdate(prevProps, prevState) { @@ -260,6 +293,34 @@ export default class DataBrowser extends React.Component { this.aggregationPanelRef.current.scrollTop = 0; } } + + // Store the fetched panel data in multiPanelData when it changes + if ( + this.props.AggregationPanelData !== prevProps.AggregationPanelData && + this.state.selectedObjectId && + Object.keys(this.props.AggregationPanelData).length > 0 + ) { + this.setState(prev => ({ + multiPanelData: { + ...prev.multiPanelData, + [this.state.selectedObjectId]: this.props.AggregationPanelData + } + })); + } + + // Manage wheel event listener based on state changes + const prevNeedsListener = prevState.panelCount > 1 && prevState.syncPanelScroll; + const nowNeedsListener = this.state.panelCount > 1 && this.state.syncPanelScroll; + + if (prevNeedsListener !== nowNeedsListener && this.multiPanelWrapperRef.current) { + if (nowNeedsListener) { + // Add listener + this.multiPanelWrapperRef.current.addEventListener('wheel', this.handleWrapperWheel, { passive: false }); + } else { + // Remove listener + this.multiPanelWrapperRef.current.removeEventListener('wheel', this.handleWrapperWheel); + } + } } handleResizeStart() { @@ -570,7 +631,9 @@ export default class DataBrowser extends React.Component { // Up - standalone (move to the previous row) // or with ctrl/meta (excel style - move to the first row) const prevObjectID = this.state.selectedObjectId; - const newRow = e.ctrlKey || e.metaKey ? 0 : Math.max(this.state.current.row - 1, 0); + // Calculate step size based on batch navigation mode + const stepSize = this.state.panelCount > 1 && this.state.batchNavigate ? this.state.panelCount : 1; + const newRow = e.ctrlKey || e.metaKey ? 0 : Math.max(this.state.current.row - stepSize, 0); this.setState({ current: { row: newRow, @@ -613,10 +676,12 @@ export default class DataBrowser extends React.Component { // Down - standalone (move to the next row) // or with ctrl/meta (excel style - move to the last row) const prevObjectID = this.state.selectedObjectId; + // Calculate step size based on batch navigation mode + const stepSizeDown = this.state.panelCount > 1 && this.state.batchNavigate ? this.state.panelCount : 1; const newRow = e.ctrlKey || e.metaKey ? this.props.data.length - 1 - : Math.min(this.state.current.row + 1, this.props.data.length - 1); + : Math.min(this.state.current.row + stepSizeDown, this.props.data.length - 1); this.setState({ current: { row: newRow, @@ -720,9 +785,67 @@ export default class DataBrowser extends React.Component { if (history.length > 3) { history.shift(); } - return { selectedObjectId, selectionHistory: history }; + + // Check if the new object is already displayed in the panel + let newDisplayedObjectIds = [...prevState.displayedObjectIds]; + const newMultiPanelData = { ...prevState.multiPanelData }; + const objectsToFetch = []; + + if (prevState.panelCount > 1 && selectedObjectId) { + // If the selected object is not in the displayed list, update displayed objects + if (!newDisplayedObjectIds.includes(selectedObjectId)) { + const currentIndex = this.props.data?.findIndex(obj => obj.id === selectedObjectId); + if (currentIndex !== -1) { + const { prefetchCache } = prevState; + const { prefetchStale } = this.getPrefetchSettings(); + + // Calculate the starting index for the new batch + // Always position the selected object at the START of the batch for consistency + const startIndex = currentIndex; + + // Build the new batch of displayed objects + newDisplayedObjectIds = []; + for (let i = 0; i < prevState.panelCount && startIndex + i < this.props.data.length; i++) { + const objectId = this.props.data[startIndex + i].id; + newDisplayedObjectIds.push(objectId); + + // Check if data is already available + if (!newMultiPanelData[objectId]) { + const cached = prefetchCache[objectId]; + if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { + // Use cached data immediately + newMultiPanelData[objectId] = cached.data; + } else { + // Mark for fetching + objectsToFetch.push({ objectId, delay: i * 100 }); + } + } + } + } + } + } + + return { + selectedObjectId, + selectionHistory: history, + displayedObjectIds: newDisplayedObjectIds, + multiPanelData: newMultiPanelData, + _objectsToFetch: objectsToFetch // Temporary field to handle after setState + }; }, - () => this.handlePrefetch() + () => { + // Fetch any objects that weren't in cache + if (this.state._objectsToFetch && this.state._objectsToFetch.length > 0) { + this.state._objectsToFetch.forEach(({ objectId, delay }) => { + setTimeout(() => { + this.fetchDataForMultiPanel(objectId); + }, delay); + }); + // Clean up temporary field + this.setState({ _objectsToFetch: [] }); + } + this.handlePrefetch(); + } ); } } @@ -760,6 +883,177 @@ export default class DataBrowser extends React.Component { }); } + toggleSyncPanelScroll() { + this.setState(prevState => { + const newSyncPanelScroll = !prevState.syncPanelScroll; + window.localStorage?.setItem(AGGREGATION_PANEL_SYNC_SCROLL, newSyncPanelScroll); + return { syncPanelScroll: newSyncPanelScroll }; + }); + } + + toggleBatchNavigate() { + this.setState(prevState => { + const newBatchNavigate = !prevState.batchNavigate; + window.localStorage?.setItem(AGGREGATION_PANEL_BATCH_NAVIGATE, newBatchNavigate); + return { batchNavigate: newBatchNavigate }; + }); + } + + toggleShowPanelCheckbox() { + this.setState(prevState => { + const newShowPanelCheckbox = !prevState.showPanelCheckbox; + window.localStorage?.setItem(AGGREGATION_PANEL_SHOW_CHECKBOX, String(newShowPanelCheckbox)); + return { showPanelCheckbox: newShowPanelCheckbox }; + }); + } + + handlePanelScroll(event, index) { + if (!this.state.syncPanelScroll || this.state.panelCount <= 1) { + return; + } + + // Sync scroll position to all other panel columns + const scrollTop = event.target.scrollTop; + this.panelColumnRefs.forEach((ref, i) => { + if (i !== index && ref && ref.current) { + ref.current.scrollTop = scrollTop; + } + }); + } + + handleWrapperWheel(event) { + if (!this.state.syncPanelScroll || this.state.panelCount <= 1) { + return; + } + + // Prevent default scrolling + event.preventDefault(); + + // Apply scroll to all columns + const delta = event.deltaY; + this.panelColumnRefs.forEach((ref) => { + if (ref && ref.current) { + ref.current.scrollTop += delta; + } + }); + } + + fetchDataForMultiPanel(objectId) { + // Fetch data for a specific object and store it in both prefetchCache and multiPanelData + const { className, app } = this.props; + const { prefetchCache } = this.state; + const { prefetchStale } = this.getPrefetchSettings(); + + const cached = prefetchCache[objectId]; + if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { + // Use cached data + this.setState(prev => ({ + multiPanelData: { + ...prev.multiPanelData, + [objectId]: cached.data + } + })); + } else { + // Fetch fresh data + const cloudCodeFunction = + this.props.classwiseCloudFunctions?.[ + `${app.applicationId}${this.props.appName}` + ]?.[className]?.[0]?.cloudCodeFunction; + + if (!cloudCodeFunction) { + return; + } + + const params = { + object: Parse.Object.extend(className) + .createWithoutData(objectId) + .toPointer(), + }; + const options = { useMasterKey: true }; + + Parse.Cloud.run(cloudCodeFunction, params, options).then(result => { + // Store in both prefetchCache and multiPanelData + this.setState(prev => ({ + prefetchCache: { + ...prev.prefetchCache, + [objectId]: { data: result, timestamp: Date.now() } + }, + multiPanelData: { + ...prev.multiPanelData, + [objectId]: result + } + })); + }).catch(error => { + console.error(`Failed to fetch panel data for ${objectId}:`, error); + }); + } + } + + addPanel() { + const currentIndex = this.props.data?.findIndex(obj => obj.id === this.state.selectedObjectId); + const newPanelCount = this.state.panelCount + 1; + const newDisplayedObjectIds = []; + + if (currentIndex !== -1 && currentIndex !== undefined) { + // First, ensure current object data is in multiPanelData + const currentObjectData = { ...this.state.multiPanelData }; + if (this.state.selectedObjectId && !currentObjectData[this.state.selectedObjectId] && + Object.keys(this.props.AggregationPanelData).length > 0) { + currentObjectData[this.state.selectedObjectId] = this.props.AggregationPanelData; + } + + const { prefetchCache } = this.state; + const { prefetchStale } = this.getPrefetchSettings(); + const objectsToFetch = []; + + for (let i = 0; i < newPanelCount && currentIndex + i < this.props.data.length; i++) { + const objectId = this.props.data[currentIndex + i].id; + newDisplayedObjectIds.push(objectId); + + // Check if data is already available + if (!currentObjectData[objectId]) { + const cached = prefetchCache[objectId]; + if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { + // Use cached data immediately + currentObjectData[objectId] = cached.data; + } else { + // Mark for fetching + objectsToFetch.push(objectId); + } + } + } + + // Update state with all available data + this.setState({ + panelCount: newPanelCount, + displayedObjectIds: newDisplayedObjectIds, + multiPanelData: currentObjectData + }); + + // Fetch missing data asynchronously + objectsToFetch.forEach((objectId, i) => { + setTimeout(() => { + this.fetchDataForMultiPanel(objectId); + }, i * 100); + }); + } + } + + removePanel() { + this.setState(prevState => { + if (prevState.panelCount <= 1) { + return {}; + } + const newPanelCount = prevState.panelCount - 1; + // Remove the last displayed object + const newDisplayedObjectIds = prevState.displayedObjectIds.slice(0, -1); + return { + panelCount: newPanelCount, + displayedObjectIds: newDisplayedObjectIds + }; + }); + } + getPrefetchSettings() { const config = this.props.classwiseCloudFunctions?.[ @@ -798,15 +1092,36 @@ export default class DataBrowser extends React.Component { return; } const [a, b, c] = history.slice(-3); - if (a + 1 === b && b + 1 === c) { + // Detect step size from the last two selections + const stepAB = b - a; + const stepBC = c - b; + // Check if we have a consistent navigation pattern (same step size) + if (stepAB === stepBC && stepAB > 0) { + // Prefetch ahead based on the detected step size + const stepSize = stepAB; + const panelCount = this.state.panelCount; + + // When in multi-panel mode, prefetch all objects in the upcoming batches for ( let i = 1; - i <= prefetchObjects && c + i < this.props.data.length; + i <= prefetchObjects && c + (i * stepSize) < this.props.data.length; i++ ) { - const objId = this.props.data[c + i].id; - if (!cache[objId]) { - this.prefetchObject(objId); + // For each step ahead, prefetch the main object + const mainObjId = this.props.data[c + (i * stepSize)].id; + if (!Object.prototype.hasOwnProperty.call(cache, mainObjId)) { + this.prefetchObject(mainObjId); + } + + // If in multi-panel mode, also prefetch the other objects that would be displayed in the batch + if (panelCount > 1) { + const batchStartIndex = c + (i * stepSize); + for (let j = 1; j < panelCount && batchStartIndex + j < this.props.data.length; j++) { + const batchObjId = this.props.data[batchStartIndex + j].id; + if (!Object.prototype.hasOwnProperty.call(cache, batchObjId)) { + this.prefetchObject(batchObjId); + } + } } } } @@ -923,6 +1238,13 @@ export default class DataBrowser extends React.Component { ) { this.props.setAggregationPanelData(cached.data); this.props.setLoadingInfoPanel(false); + // Also store in multiPanelData for multi-panel display + this.setState(prev => ({ + multiPanelData: { + ...prev.multiPanelData, + [objectId]: cached.data + } + })); } else { if (cached) { this.setState(prev => { @@ -1089,18 +1411,79 @@ export default class DataBrowser extends React.Component { className={styles.aggregationPanelContainer} ref={this.aggregationPanelRef} > - + {this.state.panelCount > 1 ? ( +
+ {(() => { + // Initialize refs array if needed + if (this.panelColumnRefs.length !== this.state.displayedObjectIds.length) { + this.panelColumnRefs = this.state.displayedObjectIds.map(() => React.createRef()); + } + return this.state.displayedObjectIds.map((objectId, index) => { + const panelData = this.state.multiPanelData[objectId] || {}; + const isLoading = objectId === this.state.selectedObjectId && this.props.isLoadingCloudFunction; + const isRowSelected = this.props.selection[objectId]; + return ( + +
this.handlePanelScroll(e, index)} + > + {this.state.showPanelCheckbox && ( +
{ + this.props.selectRow(objectId, !isRowSelected); + }} + onMouseDown={(e) => { + e.preventDefault(); + }} + > + +
+ )} + +
+ {index < this.state.displayedObjectIds.length - 1 && ( +
+ )} + + ); + }); + })()} +
+ ) : ( + + )}
)} @@ -1133,12 +1516,21 @@ export default class DataBrowser extends React.Component { allClassesSchema={this.state.allClassesSchema} togglePanel={this.togglePanelVisibility} isPanelVisible={this.state.isPanelVisible} + addPanel={this.addPanel} + removePanel={this.removePanel} + panelCount={this.state.panelCount} appId={this.props.app.applicationId} appName={this.props.appName} scrollToTop={this.state.scrollToTop} toggleScrollToTop={this.toggleScrollToTop} autoLoadFirstRow={this.state.autoLoadFirstRow} toggleAutoLoadFirstRow={this.toggleAutoLoadFirstRow} + syncPanelScroll={this.state.syncPanelScroll} + toggleSyncPanelScroll={this.toggleSyncPanelScroll} + batchNavigate={this.state.batchNavigate} + toggleBatchNavigate={this.toggleBatchNavigate} + showPanelCheckbox={this.state.showPanelCheckbox} + toggleShowPanelCheckbox={this.toggleShowPanelCheckbox} {...other} /> diff --git a/src/dashboard/Data/Browser/Databrowser.scss b/src/dashboard/Data/Browser/Databrowser.scss index 5b90920add..5ffe4a0658 100644 --- a/src/dashboard/Data/Browser/Databrowser.scss +++ b/src/dashboard/Data/Browser/Databrowser.scss @@ -23,3 +23,68 @@ overflow: auto; background-color: #fefafb; } + +.multiPanelWrapper { + display: flex; + flex-direction: row; + height: 100%; + width: 100%; +} + +.panelColumn { + flex: 1; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; + + /* Custom narrow scrollbar for webkit browsers */ + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar:hover { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.4); + } + + /* Firefox scrollbar support */ + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +} + +.panelSeparator { + width: 0px; + background-color: #ebebeb; + flex-shrink: 0; +} + +.panelHeader { + position: sticky; + top: 0; + z-index: 10; + background-color: #67637a; + border-left: 1px solid #878396; + padding: 8px 8px 6px 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + input[type="checkbox"] { + pointer-events: none; + width: 16px; + height: 16px; + } +}