[{"data":1,"prerenderedAt":1179},["ShallowReactive",2],{"blog-\u002Fblog\u002F2026\u002Ftabela-jak-arkusz":3,"blog-project-\u002Fblog\u002F2026\u002Ftabela-jak-arkusz":1039},{"id":4,"title":5,"art":6,"body":7,"date":1024,"description":1025,"draft":1026,"extension":1027,"image":1028,"kind":1029,"meta":1030,"navigation":108,"ogImage":1031,"path":1032,"project":1033,"seo":1034,"slug":1028,"stem":1035,"tags":1036,"updated":1028,"__hash__":1038},"blog_en\u002Fblog\u002F2026\u002Ftabela-jak-arkusz.md","A table like a spreadsheet: Shift‑click and Enter","\u002Fart\u002Fblog\u002Ftabela-jak-arkusz.svg",{"type":8,"value":9,"toc":1019},"minimark",[10,14,19,22,33,42,45,71,74,564,666,676,680,683,690,697,993,996,1000,1015],[11,12,13],"p",{},"The AgrestApp panel has many long lists: raw materials, products, packaging, prices, notifications. With lists like these, clicking row by row takes the most time. The client asked for two shortcuts everyone knows from a spreadsheet or an email inbox: Shift-click selects a range of rows, and Enter moves to the same field in the row below. The idea was to select a few items and edit them at once instead of product by product, and to update prices in one table, like in Excel. The screenshots below come from a local version with made-up data. The code in this post shows simplified examples I wrote for this text – it isn't the code from AgrestApp.",[15,16,18],"h2",{"id":17},"shift-click-selecting-a-range","Shift-click: selecting a range",[11,20,21],{},"It works like in an email inbox: you click the checkbox in one row, then click another one while holding Shift. Both rows and everything between them get selected. If the second click unchecks a row, the whole range gets unchecked. In the panel it works in 9 lists.",[11,23,24,25,29,30,32],{},"I had to hook into the existing code, and every table was built a bit differently. That's why the bulk action button doesn't take only an array with the ",[26,27,28],"code",{},"id","s of the selected rows – depending on the place, I pass it either the ",[26,31,28],{}," or the whole object and only then prepare the data to send.",[11,34,35],{},[36,37],"img",{"alt":38,"height":39,"src":40,"width":41},"Left: after the first click – 1 row selected; right: after Shift-click on row 8 – 7 rows selected",836,"\u002Fblog\u002F2026\u002Ftabela-jak-arkusz\u002Fshift-klik.webp",840,[11,43,44],{},"The simplest version has three traps:",[46,47,48,59,65],"ol",{},[49,50,51,55,56,58],"li",{},[52,53,54],"strong",{},"The row number instead of a key."," It's easy to remember the number of the last clicked row. It works until someone sorts the list, changes a filter or loads more rows. Then row 3 is a different row and the wrong range gets selected. It's safer to remember the row's ",[26,57,28],{}," and look for its position only when the click happens.",[49,60,61,64],{},[52,62,63],{},"The order from the data instead of the screen."," After sorting and filtering, the order on the screen is different from the data from the server. The range has to match what the user sees.",[49,66,67,70],{},[52,68,69],{},"Selected text."," In a browser, Shift-click also selects the text between the clicks. You can block this only while Shift is pressed – then normal text selection in the table still works.",[11,72,73],{},"A simplified version as a composable, without these traps:",[75,76,81],"pre",{"className":77,"code":78,"language":79,"meta":80,"style":80},"language-js shiki shiki-themes github-light github-dark","import { ref, toValue } from 'vue'\n\nexport function useRangeSelection(rows, key = 'id') {\n  const selected = ref(new Set())\n  \u002F\u002F the key of the last clicked row – not its number\n  const anchor = ref(null)\n\n  function toggle(row, event) {\n    const id = row[key]\n    const select = !selected.value.has(id)\n    const list = toValue(rows) \u002F\u002F rows in the order you see on the screen\n    const from = list.findIndex((r) => r[key] === anchor.value)\n    const to = list.findIndex((r) => r[key] === id)\n\n    const ids = event?.shiftKey && from !== -1\n      ? list.slice(Math.min(from, to), Math.max(from, to) + 1).map((r) => r[key])\n      : [id]\n\n    const next = new Set(selected.value)\n    for (const rowId of ids) {\n      if (select) next.add(rowId)\n      else next.delete(rowId)\n    }\n    selected.value = next\n    anchor.value = id\n  }\n\n  return { selected, toggle }\n}\n","js","",[26,82,83,103,110,145,171,178,198,203,224,238,260,279,316,345,350,378,427,436,441,459,480,495,509,515,527,538,544,549,558],{"__ignoreMap":80},[84,85,88,92,96,99],"span",{"class":86,"line":87},"line",1,[84,89,91],{"class":90},"szBVR","import",[84,93,95],{"class":94},"sVt8B"," { ref, toValue } ",[84,97,98],{"class":90},"from",[84,100,102],{"class":101},"sZZnC"," 'vue'\n",[84,104,106],{"class":86,"line":105},2,[84,107,109],{"emptyLinePlaceholder":108},true,"\n",[84,111,113,116,119,123,126,130,133,136,139,142],{"class":86,"line":112},3,[84,114,115],{"class":90},"export",[84,117,118],{"class":90}," function",[84,120,122],{"class":121},"sScJk"," useRangeSelection",[84,124,125],{"class":94},"(",[84,127,129],{"class":128},"siNPP","rows",[84,131,132],{"class":94},", ",[84,134,135],{"class":128},"key",[84,137,138],{"class":90}," =",[84,140,141],{"class":101}," 'id'",[84,143,144],{"class":94},") {\n",[84,146,148,151,155,157,160,162,165,168],{"class":86,"line":147},4,[84,149,150],{"class":90},"  const",[84,152,154],{"class":153},"sj4cs"," selected",[84,156,138],{"class":90},[84,158,159],{"class":121}," ref",[84,161,125],{"class":94},[84,163,164],{"class":90},"new",[84,166,167],{"class":121}," Set",[84,169,170],{"class":94},"())\n",[84,172,174],{"class":86,"line":173},5,[84,175,177],{"class":176},"sqY6-","  \u002F\u002F the key of the last clicked row – not its number\n",[84,179,181,183,186,188,190,192,195],{"class":86,"line":180},6,[84,182,150],{"class":90},[84,184,185],{"class":153}," anchor",[84,187,138],{"class":90},[84,189,159],{"class":121},[84,191,125],{"class":94},[84,193,194],{"class":153},"null",[84,196,197],{"class":94},")\n",[84,199,201],{"class":86,"line":200},7,[84,202,109],{"emptyLinePlaceholder":108},[84,204,206,209,212,214,217,219,222],{"class":86,"line":205},8,[84,207,208],{"class":90},"  function",[84,210,211],{"class":121}," toggle",[84,213,125],{"class":94},[84,215,216],{"class":128},"row",[84,218,132],{"class":94},[84,220,221],{"class":128},"event",[84,223,144],{"class":94},[84,225,227,230,233,235],{"class":86,"line":226},9,[84,228,229],{"class":90},"    const",[84,231,232],{"class":153}," id",[84,234,138],{"class":90},[84,236,237],{"class":94}," row[key]\n",[84,239,241,243,246,248,251,254,257],{"class":86,"line":240},10,[84,242,229],{"class":90},[84,244,245],{"class":153}," select",[84,247,138],{"class":90},[84,249,250],{"class":90}," !",[84,252,253],{"class":94},"selected.value.",[84,255,256],{"class":121},"has",[84,258,259],{"class":94},"(id)\n",[84,261,263,265,268,270,273,276],{"class":86,"line":262},11,[84,264,229],{"class":90},[84,266,267],{"class":153}," list",[84,269,138],{"class":90},[84,271,272],{"class":121}," toValue",[84,274,275],{"class":94},"(rows) ",[84,277,278],{"class":176},"\u002F\u002F rows in the order you see on the screen\n",[84,280,282,284,287,289,292,295,298,301,304,307,310,313],{"class":86,"line":281},12,[84,283,229],{"class":90},[84,285,286],{"class":153}," from",[84,288,138],{"class":90},[84,290,291],{"class":94}," list.",[84,293,294],{"class":121},"findIndex",[84,296,297],{"class":94},"((",[84,299,300],{"class":128},"r",[84,302,303],{"class":94},") ",[84,305,306],{"class":90},"=>",[84,308,309],{"class":94}," r[key] ",[84,311,312],{"class":90},"===",[84,314,315],{"class":94}," anchor.value)\n",[84,317,319,321,324,326,328,330,332,334,336,338,340,342],{"class":86,"line":318},13,[84,320,229],{"class":90},[84,322,323],{"class":153}," to",[84,325,138],{"class":90},[84,327,291],{"class":94},[84,329,294],{"class":121},[84,331,297],{"class":94},[84,333,300],{"class":128},[84,335,303],{"class":94},[84,337,306],{"class":90},[84,339,309],{"class":94},[84,341,312],{"class":90},[84,343,344],{"class":94}," id)\n",[84,346,348],{"class":86,"line":347},14,[84,349,109],{"emptyLinePlaceholder":108},[84,351,353,355,358,360,363,366,369,372,375],{"class":86,"line":352},15,[84,354,229],{"class":90},[84,356,357],{"class":153}," ids",[84,359,138],{"class":90},[84,361,362],{"class":94}," event?.shiftKey ",[84,364,365],{"class":90},"&&",[84,367,368],{"class":94}," from ",[84,370,371],{"class":90},"!==",[84,373,374],{"class":90}," -",[84,376,377],{"class":153},"1\n",[84,379,381,384,386,389,392,395,398,401,404,407,410,413,416,418,420,422,424],{"class":86,"line":380},16,[84,382,383],{"class":90},"      ?",[84,385,291],{"class":94},[84,387,388],{"class":121},"slice",[84,390,391],{"class":94},"(Math.",[84,393,394],{"class":121},"min",[84,396,397],{"class":94},"(from, to), Math.",[84,399,400],{"class":121},"max",[84,402,403],{"class":94},"(from, to) ",[84,405,406],{"class":90},"+",[84,408,409],{"class":153}," 1",[84,411,412],{"class":94},").",[84,414,415],{"class":121},"map",[84,417,297],{"class":94},[84,419,300],{"class":128},[84,421,303],{"class":94},[84,423,306],{"class":90},[84,425,426],{"class":94}," r[key])\n",[84,428,430,433],{"class":86,"line":429},17,[84,431,432],{"class":90},"      :",[84,434,435],{"class":94}," [id]\n",[84,437,439],{"class":86,"line":438},18,[84,440,109],{"emptyLinePlaceholder":108},[84,442,444,446,449,451,454,456],{"class":86,"line":443},19,[84,445,229],{"class":90},[84,447,448],{"class":153}," next",[84,450,138],{"class":90},[84,452,453],{"class":90}," new",[84,455,167],{"class":121},[84,457,458],{"class":94},"(selected.value)\n",[84,460,462,465,468,471,474,477],{"class":86,"line":461},20,[84,463,464],{"class":90},"    for",[84,466,467],{"class":94}," (",[84,469,470],{"class":90},"const",[84,472,473],{"class":153}," rowId",[84,475,476],{"class":90}," of",[84,478,479],{"class":94}," ids) {\n",[84,481,483,486,489,492],{"class":86,"line":482},21,[84,484,485],{"class":90},"      if",[84,487,488],{"class":94}," (select) next.",[84,490,491],{"class":121},"add",[84,493,494],{"class":94},"(rowId)\n",[84,496,498,501,504,507],{"class":86,"line":497},22,[84,499,500],{"class":90},"      else",[84,502,503],{"class":94}," next.",[84,505,506],{"class":121},"delete",[84,508,494],{"class":94},[84,510,512],{"class":86,"line":511},23,[84,513,514],{"class":94},"    }\n",[84,516,518,521,524],{"class":86,"line":517},24,[84,519,520],{"class":94},"    selected.value ",[84,522,523],{"class":90},"=",[84,525,526],{"class":94}," next\n",[84,528,530,533,535],{"class":86,"line":529},25,[84,531,532],{"class":94},"    anchor.value ",[84,534,523],{"class":90},[84,536,537],{"class":94}," id\n",[84,539,541],{"class":86,"line":540},26,[84,542,543],{"class":94},"  }\n",[84,545,547],{"class":86,"line":546},27,[84,548,109],{"emptyLinePlaceholder":108},[84,550,552,555],{"class":86,"line":551},28,[84,553,554],{"class":90},"  return",[84,556,557],{"class":94}," { selected, toggle }\n",[84,559,561],{"class":86,"line":560},29,[84,562,563],{"class":94},"}\n",[75,565,569],{"className":566,"code":567,"language":568,"meta":80,"style":80},"language-vue shiki shiki-themes github-light github-dark","\u003Cinput\n  type=\"checkbox\"\n  :checked=\"selected.has(row.id)\"\n  @mousedown=\"$event.shiftKey && $event.preventDefault()\"\n  @click=\"toggle(row, $event)\"\n>\n","vue",[26,570,571,580,590,614,642,661],{"__ignoreMap":80},[84,572,573,576],{"class":86,"line":87},[84,574,575],{"class":94},"\u003C",[84,577,579],{"class":578},"s9eBZ","input\n",[84,581,582,585,587],{"class":86,"line":105},[84,583,584],{"class":121},"  type",[84,586,523],{"class":94},[84,588,589],{"class":101},"\"checkbox\"\n",[84,591,592,595,598,600,603,606,608,611],{"class":86,"line":112},[84,593,594],{"class":94},"  :",[84,596,597],{"class":121},"checked",[84,599,523],{"class":94},[84,601,602],{"class":101},"\"",[84,604,605],{"class":94},"selected.",[84,607,256],{"class":121},[84,609,610],{"class":94},"(row.id)",[84,612,613],{"class":101},"\"\n",[84,615,616,619,622,624,626,629,631,634,637,640],{"class":86,"line":147},[84,617,618],{"class":94},"  @",[84,620,621],{"class":121},"mousedown",[84,623,523],{"class":94},[84,625,602],{"class":101},[84,627,628],{"class":94},"$event.shiftKey ",[84,630,365],{"class":90},[84,632,633],{"class":94}," $event.",[84,635,636],{"class":121},"preventDefault",[84,638,639],{"class":94},"()",[84,641,613],{"class":101},[84,643,644,646,649,651,653,656,659],{"class":86,"line":173},[84,645,618],{"class":94},[84,647,648],{"class":121},"click",[84,650,523],{"class":94},[84,652,602],{"class":101},[84,654,655],{"class":121},"toggle",[84,657,658],{"class":94},"(row, $event)",[84,660,613],{"class":101},[84,662,663],{"class":86,"line":180},[84,664,665],{"class":94},">\n",[11,667,668,669,672,673,675],{},"A ",[26,670,671],{},"Set"," checks whether a row is selected without searching the whole array. On every change I create a new ",[26,674,671],{},", so Vue surely notices the change.",[15,677,679],{"id":678},"enter-the-field-in-the-row-below","Enter: the field in the row below",[11,681,682],{},"In \"Offer management\" a supplier sets the prices of all their items: the net base price, the default markup and their own net price. Suppliers update their prices from time to time and wanted to do it in one table, like in Excel. Now Enter moves to the same field in the row below, and Shift + Enter to the row above. The value of the new field is already selected, so you can just type the new price. The individual price dialog works the same way.",[11,684,685],{},[36,686],{"alt":687,"height":39,"src":688,"width":689},"Top: after Enter the cursor is in the same field one row down, and the price is already selected. Bottom: after Shift + Enter the cursor goes back one row up","\u002Fblog\u002F2026\u002Ftabela-jak-arkusz\u002Fenter.webp",1168,[11,691,692,693,696],{},"In the example, each field gets a row number and a column name in ",[26,694,695],{},"data-*"," attributes. After Enter, the function looks for the field with the same column and the next row number:",[75,698,700],{"className":566,"code":699,"language":568,"meta":80,"style":80},"\u003Cscript setup>\nfunction moveOnEnter(event, row) {\n  \u002F\u002F Enter normally submits a form – here it should only move the cursor\n  event.preventDefault()\n\n  const column = event.currentTarget.dataset.column\n  const next = row + (event.shiftKey ? -1 : 1)\n  const input = document.querySelector(\n    `[data-row=\"${next}\"][data-column=\"${column}\"] input`,\n  )\n\n  input?.focus()\n  input?.select()\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv\n    v-for=\"(item, index) in items\"\n    :key=\"item.id\"\n    :data-row=\"index\"\n    data-column=\"price\"\n    @keydown.enter=\"moveOnEnter($event, index)\"\n  >\n    \u003Cinput v-model=\"item.price\" inputmode=\"decimal\">\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[26,701,702,714,732,737,747,751,763,794,812,832,837,841,851,860,864,873,877,886,894,904,914,924,934,944,949,975,985],{"__ignoreMap":80},[84,703,704,706,709,712],{"class":86,"line":87},[84,705,575],{"class":94},[84,707,708],{"class":578},"script",[84,710,711],{"class":121}," setup",[84,713,665],{"class":94},[84,715,716,719,722,724,726,728,730],{"class":86,"line":105},[84,717,718],{"class":90},"function",[84,720,721],{"class":121}," moveOnEnter",[84,723,125],{"class":94},[84,725,221],{"class":128},[84,727,132],{"class":94},[84,729,216],{"class":128},[84,731,144],{"class":94},[84,733,734],{"class":86,"line":112},[84,735,736],{"class":176},"  \u002F\u002F Enter normally submits a form – here it should only move the cursor\n",[84,738,739,742,744],{"class":86,"line":147},[84,740,741],{"class":94},"  event.",[84,743,636],{"class":121},[84,745,746],{"class":94},"()\n",[84,748,749],{"class":86,"line":173},[84,750,109],{"emptyLinePlaceholder":108},[84,752,753,755,758,760],{"class":86,"line":180},[84,754,150],{"class":90},[84,756,757],{"class":153}," column",[84,759,138],{"class":90},[84,761,762],{"class":94}," event.currentTarget.dataset.column\n",[84,764,765,767,769,771,774,776,779,782,784,787,790,792],{"class":86,"line":200},[84,766,150],{"class":90},[84,768,448],{"class":153},[84,770,138],{"class":90},[84,772,773],{"class":94}," row ",[84,775,406],{"class":90},[84,777,778],{"class":94}," (event.shiftKey ",[84,780,781],{"class":90},"?",[84,783,374],{"class":90},[84,785,786],{"class":153},"1",[84,788,789],{"class":90}," :",[84,791,409],{"class":153},[84,793,197],{"class":94},[84,795,796,798,801,803,806,809],{"class":86,"line":205},[84,797,150],{"class":90},[84,799,800],{"class":153}," input",[84,802,138],{"class":90},[84,804,805],{"class":94}," document.",[84,807,808],{"class":121},"querySelector",[84,810,811],{"class":94},"(\n",[84,813,814,817,820,823,826,829],{"class":86,"line":226},[84,815,816],{"class":101},"    `[data-row=\"${",[84,818,819],{"class":94},"next",[84,821,822],{"class":101},"}\"][data-column=\"${",[84,824,825],{"class":94},"column",[84,827,828],{"class":101},"}\"] input`",[84,830,831],{"class":94},",\n",[84,833,834],{"class":86,"line":240},[84,835,836],{"class":94},"  )\n",[84,838,839],{"class":86,"line":262},[84,840,109],{"emptyLinePlaceholder":108},[84,842,843,846,849],{"class":86,"line":281},[84,844,845],{"class":94},"  input?.",[84,847,848],{"class":121},"focus",[84,850,746],{"class":94},[84,852,853,855,858],{"class":86,"line":318},[84,854,845],{"class":94},[84,856,857],{"class":121},"select",[84,859,746],{"class":94},[84,861,862],{"class":86,"line":347},[84,863,563],{"class":94},[84,865,866,869,871],{"class":86,"line":352},[84,867,868],{"class":94},"\u003C\u002F",[84,870,708],{"class":578},[84,872,665],{"class":94},[84,874,875],{"class":86,"line":380},[84,876,109],{"emptyLinePlaceholder":108},[84,878,879,881,884],{"class":86,"line":429},[84,880,575],{"class":94},[84,882,883],{"class":578},"template",[84,885,665],{"class":94},[84,887,888,891],{"class":86,"line":438},[84,889,890],{"class":94},"  \u003C",[84,892,893],{"class":578},"div\n",[84,895,896,899,901],{"class":86,"line":443},[84,897,898],{"class":121},"    v-for",[84,900,523],{"class":94},[84,902,903],{"class":101},"\"(item, index) in items\"\n",[84,905,906,909,911],{"class":86,"line":461},[84,907,908],{"class":121},"    :key",[84,910,523],{"class":94},[84,912,913],{"class":101},"\"item.id\"\n",[84,915,916,919,921],{"class":86,"line":482},[84,917,918],{"class":121},"    :data-row",[84,920,523],{"class":94},[84,922,923],{"class":101},"\"index\"\n",[84,925,926,929,931],{"class":86,"line":497},[84,927,928],{"class":121},"    data-column",[84,930,523],{"class":94},[84,932,933],{"class":101},"\"price\"\n",[84,935,936,939,941],{"class":86,"line":511},[84,937,938],{"class":121},"    @keydown.enter",[84,940,523],{"class":94},[84,942,943],{"class":101},"\"moveOnEnter($event, index)\"\n",[84,945,946],{"class":86,"line":517},[84,947,948],{"class":94},"  >\n",[84,950,951,954,957,960,962,965,968,970,973],{"class":86,"line":529},[84,952,953],{"class":94},"    \u003C",[84,955,956],{"class":578},"input",[84,958,959],{"class":121}," v-model",[84,961,523],{"class":94},[84,963,964],{"class":101},"\"item.price\"",[84,966,967],{"class":121}," inputmode",[84,969,523],{"class":94},[84,971,972],{"class":101},"\"decimal\"",[84,974,665],{"class":94},[84,976,977,980,983],{"class":86,"line":540},[84,978,979],{"class":94},"  \u003C\u002F",[84,981,982],{"class":578},"div",[84,984,665],{"class":94},[84,986,987,989,991],{"class":86,"line":546},[84,988,868],{"class":94},[84,990,883],{"class":578},[84,992,665],{"class":94},[11,994,995],{},"Thanks to the column name in the attribute, one function is enough for every column, and for both the table and the individual price dialog. On the last row Enter does nothing – there's no field below, so the cursor stays where it is.",[15,997,999],{"id":998},"what-came-out-of-it","What came out of it",[1001,1002,1003,1006,1009,1012],"ul",{},[49,1004,1005],{},"Shift-click in 9 lists in the panel.",[49,1007,1008],{},"Enter and Shift + Enter in the price table and in the individual price dialog – you type new prices without reaching for the mouse.",[49,1010,1011],{},"Both are just a little code, but they save the most clicking where users go through a whole list.",[49,1013,1014],{},"When you select a range, remember the row's key, not its number – then sorting and filters don't break the range.",[1016,1017,1018],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .siNPP, html code.shiki .siNPP{--shiki-default:#CA3500;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sqY6-, html code.shiki .sqY6-{--shiki-default:#6A737D;--shiki-dark:#90A1B9}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":80,"searchDepth":105,"depth":105,"links":1020},[1021,1022,1023],{"id":17,"depth":105,"text":18},{"id":678,"depth":105,"text":679},{"id":998,"depth":105,"text":999},"2026-03-14","Two shortcuts known from spreadsheets in the AgrestApp panel – selecting a range of rows with Shift-click and Enter, which moves to the same field in the row below.",false,"md",null,"case-study",{},"\u002Fog\u002Fblog\u002Ftabela-jak-arkusz-en.jpg","\u002Fblog\u002F2026\u002Ftabela-jak-arkusz","\u002Fprojects\u002F2026\u002Fagrestapp",{"title":5,"description":1025},"blog\u002F2026\u002Ftabela-jak-arkusz",[568,1037],"javascript","TJ9WcESTO7mnPEyuJyu2D0MuJWDailn_L6XEyeflByw",{"id":1040,"title":1041,"agency":1042,"body":1043,"compare":1028,"cover":1105,"date":1111,"description":1112,"draft":1026,"extension":1027,"facts":1113,"featured":1026,"gallery":1127,"galleryLead":1148,"highlights":1149,"image":1165,"links":1166,"meta":1168,"navigation":108,"note":1169,"ogImage":1170,"path":1033,"seo":1171,"stack":1172,"stem":1176,"tags":1177,"__hash__":1178},"projects_en\u002Fprojects\u002F2026\u002Fagrestapp\u002Findex.md","AgrestApp","Pesi",{"type":8,"value":1044,"toc":1102},[1045,1048,1052],[11,1046,1047],{},"AgrestApp is a purchasing system for food businesses: orders and tenders for raw materials, price monitoring, supplier offers and savings analytics. I've been working on it in the Pesi team since October 2025. Several people build the front-end in Vue 3 and Inertia, so below I only describe the parts where the front-end is mine. What I like most is that I can write many things my own way. I change the app in small steps: a new table here, a new button there, a header somewhere else. Each new element replaces a few older versions, so over time the project has fewer and fewer unnecessary components.",[15,1049,1051],{"id":1050},"what-i-did","What I did",[1001,1053,1054,1060,1066,1072,1078,1084,1090,1096],{},[49,1055,1056,1059],{},[52,1057,1058],{},"Panel header"," – a new header with the \"Log in as\" bar, an account menu and a shared dropdown component",[49,1061,1062,1065],{},[52,1063,1064],{},"UI component set"," – buttons, an accordion, tooltips, number fields and 69 icons as Vue components; moving the app's buttons to one component and a shared theme toggle",[49,1067,1068,1071],{},[52,1069,1070],{},"Company website"," – the \"About the system\" page, a set of sections used to build the pages for bakeries, confectioneries, ice cream shops and HoReCa, price tiles and a pricing page with a payment toggle",[49,1073,1074,1077],{},[52,1075,1076],{},"Analytics"," – the main chart, the date range picker with a compare mode, top suppliers, the savings summary and product profitability",[49,1079,1080,1083],{},[52,1081,1082],{},"Supplier offer management"," – extending an existing screen: the individual price dialog, adding items and moving between fields with Enter",[49,1085,1086,1089],{},[52,1087,1088],{},"Range selection"," – Shift-click in 9 lists in the panel (raw materials, products, prices, notifications and more)",[49,1091,1092,1095],{},[52,1093,1094],{},"Shop"," (now closed) – a basket with each supplier's minimum order, buying without an account, order history and complaints",[49,1097,1098,1101],{},[52,1099,1100],{},"Migration to Tailwind CSS v4"," for the whole project",{"title":80,"searchDepth":105,"depth":105,"links":1103},[1104],{"id":1050,"depth":105,"text":1051},{"desktop":1106,"phone":1107,"width":1108,"height":1109,"alt":1110},"\u002Fprojects\u002F2026\u002Fagrestapp\u002Fhome-top.webp","\u002Fprojects\u002F2026\u002Fagrestapp\u002Fhome-phone-top.webp",1440,900,"AgrestApp – the first screen of the home page","2026-10-01","A purchasing system for bakeries, confectioneries, ice cream shops and HoReCa. In the Pesi team I work on the front-end: UI components, the company website, analytics and supplier offer management.",[1114,1118,1121,1124],{"value":1115,"suffix":1116,"text":1117},"12"," mo.","in the project – since October 2025",{"value":1119,"text":1120},"69","icons as Vue components in the new UI set",{"value":1122,"text":1123},"4","industry pages – bakeries, confectioneries, ice cream shops and HoReCa – built from the same sections",{"value":1125,"text":1126},"296","classes in templates left unchanged when moving to Tailwind v4",[1128,1132,1136,1140,1144],{"src":1129,"width":1108,"height":1109,"alt":1130,"caption":1131},"\u002Fprojects\u002F2026\u002Fagrestapp\u002Fanalytics-top.webp","Analytics in compare mode","Analytics – a date range with a slider and fields, two periods compared, savings and a chart (made-up data)",{"src":1133,"full":108,"alt":1134,"caption":1135},"\u002Fprojects\u002F2026\u002Fagrestapp\u002Fhome-full.webp","AgrestApp home page","Home page – from the first screen to the newsletter",{"src":1137,"full":108,"alt":1138,"caption":1139},"\u002Fprojects\u002F2026\u002Fagrestapp\u002Fabout-system-full.webp","The \"About the system\" page","\"About the system\" – sections describing the modules",{"src":1141,"full":108,"alt":1142,"caption":1143},"\u002Fprojects\u002F2026\u002Fagrestapp\u002Findustry-ice-cream-full.webp","The page for ice cream shops","The industry page for ice cream shops – built from the same sections as the others",{"src":1145,"full":108,"alt":1146,"caption":1147},"\u002Fprojects\u002F2026\u002Fagrestapp\u002Findustry-confectionery-full.webp","The page for confectioneries","The industry page for confectioneries","The company website and the app panel – analytics with made-up data. Click a screenshot to enlarge it.",[1150,1153,1156,1159,1162],{"title":1151,"text":1152},"A panel header without hard-coded numbers","I rebuilt the panel header. The \"Log in as\" bar is now part of it, and the header measures its own height and saves it in a CSS variable. The sidebar and the sticky page bars use this variable instead of 75 and 125 px written in several files. You can use the account menu with the keyboard and close it with Esc.",{"title":1154,"text":1155},"One date range in three places","In analytics, the slider, the date fields and the page address show the same range. A change in one place updates the others right away, and the data reloads only a moment after the last change. The calendar won't let you set the start after the end, and the default range starts on the day the company registered.",{"title":1157,"text":1158},"A price table like a spreadsheet","In supplier offer management, Enter moves to the same field in the row below, and Shift + Enter to the row above. The field's value is already selected, ready to overwrite. In 9 lists in the panel, Shift-click selects a whole range of rows, like in an email inbox.",{"title":1160,"text":1161},"An individual price without mistakes","The individual price dialog checks the minimum net price, doesn't accept zero or an empty value, and suggests a default value for a single item. The price turns green when it's lower than or equal to the lowest price.",{"title":1163,"text":1164},"Tailwind v4 without rewriting templates","Instead of changing hundreds of classes, I brought back three v3 values in the config. Then I compared the output CSS with the previous version, went through the pages and fixed regressions the build didn't report – for example dialogs that hid under their own backdrop after the migration.","\u002Fprojects\u002F2026\u002Fagrestapp\u002Fthumbnail.webp",{"live":1167},"https:\u002F\u002Fagrestapp.pl",{},"I work in a team – I only describe the parts where the front-end is mine. The app panel is not public.","\u002Fog\u002Fprojects\u002Fagrestapp-en.jpg",{"title":1041,"description":1112},[1173,1174,1175],"Vue 3","Inertia","Tailwind CSS v4","projects\u002F2026\u002Fagrestapp\u002Findex",[568],"b4XUsEjdYl75DhF_L-GLaNcD-DFiu_gZ517SGy9jGjM",1792358720070]