oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1{{define "head"}}
2<link rel="stylesheet" href="{{asset "static/vendor/jsuites.css"}}">
3<link rel="stylesheet" href="{{asset "static/vendor/jspreadsheet.css"}}">
4{{/* Vendored, in dependency order, and deferred so neither blocks the
5 first paint. defer preserves document order, so each module below
6 is defined before the one that calls it: live.js registers
7 window.SheetsLive and paint.js window.SheetsPaint; grid.js calls
8 both once the window has loaded; menubar.js and commands.js wire
9 the chrome and read window.SheetsGrid, which grid.js publishes. */}}
10<script defer src="{{asset "static/vendor/jsuites.js"}}"></script>
11<script defer src="{{asset "static/vendor/jspreadsheet.js"}}"></script>
12<script defer src="{{asset "static/live.js"}}"></script>
13<script defer src="{{asset "static/paint.js"}}"></script>
14<script defer src="{{asset "static/sizes.js"}}"></script>
15<script defer src="{{asset "static/autofit.js"}}"></script>
16<script defer src="{{asset "static/grid.js"}}"></script>
17<script defer src="{{asset "static/menubar.js"}}"></script>
18<script defer src="{{asset "static/formulabar.js"}}"></script>
19<script defer src="{{asset "static/formulapick.js"}}"></script>
20<script defer src="{{asset "static/note.js"}}"></script>
21<script defer src="{{asset "static/tsv.js"}}"></script>
22<script defer src="{{asset "static/cellqueue.js"}}"></script>
23<script defer src="{{asset "static/clipboard.js"}}"></script>
24<script defer src="{{asset "static/commands.js"}}"></script>
25{{end}}
26
27{{/* The open sheet's title, and for anyone who may edit it the rename,
28 are the bar's (estilo's editable title slot, chrome.TitleEdit, built
29 in handleSheetPage). It is still a real form posting to
30 /sheets/{id}/rename, so Enter renames with scripting off, and the
31 Rename button is visually hidden until the field has focus. Escape
32 restores the title and leaves the field (estilo chrome.js), which
33 fixes the sticky focus look the audit found. A reader sees the
34 title read-only. */}}
35
36{{define "main"}}
37{{/* The grid gets its own <main>, without rst-page's padding and max
38 width: a spreadsheet is the one screen in this app that should fill
39 the viewport. Everything else uses the layout's default. */}}
40<main data-sheet-surface id="main"
41 data-sheet-id="{{.Data.Sheet.ID}}"
42 data-tab-id="{{.Data.Sheet.TabID}}"
43 data-rows="{{.Data.Rows}}"
44 data-cols="{{.Data.Cols}}"
45 {{/* Set when the sheet reaches past the widest grid this build
46 can paint. grid.js reads it and says so in the status line
47 rather than the page shouting at somebody who may never
48 scroll that far — but it is said, which is the point: the
49 window used to be a constant 100 rows and a cell on row 151
50 was simply not on this screen, with nothing anywhere
51 admitting a window existed. */}}
52 {{if .Data.Truncated}}data-window-truncated="1"{{end}}
53 {{if .Data.MayWrite}}data-may-write="1"{{end}}
54 {{/* The chords clipboard.js names when a browser refuses a menu
55 copy, cut or paste ("Press ⌘C instead"), in this request's
56 platform's spelling, so its status line agrees with the menu. */}}
57 data-key-copy="{{.Frame.Keys.copy}}" data-key-cut="{{.Frame.Keys.cut}}" data-key-paste="{{.Frame.Keys.paste}}">
58{{template "content" .}}
59</main>
60{{end}}
61
62{{/* --- Glyphs -------------------------------------------------------
63
64 Written here rather than fetched with {{icon}}. rastrillo vendors
65 twelve Lucide slugs — alert-triangle, check, check-circle,
66 chevron-down, help-circle, info, kebab, menu, plus, search, x,
67 x-circle — and rastrillo.Icon returns an EMPTY STRING for a slug it
68 does not have, with no error. So {{icon "bold"}} is not a missing
69 icon, it is a button with no label and no complaint, which is the
70 same silent-failure shape as an invented CSS token.
71
72 Bold and italic are letters, because that is what they are on every
73 spreadsheet anybody has used. The three alignments are rules,
74 because "left" as a word is longer than the button. */}}
75
76{{define "glyph-align-left"}}<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2 3.5h12M2 6.5h7M2 9.5h12M2 12.5h7"/></svg>{{end}}
77{{define "glyph-align-center"}}<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2 3.5h12M4.5 6.5h7M2 9.5h12M4.5 12.5h7"/></svg>{{end}}
78{{define "glyph-align-right"}}<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2 3.5h12M7 6.5h7M2 9.5h12M7 12.5h7"/></svg>{{end}}
79{{define "glyph-undo"}}<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 8h7a3 3 0 0 1 0 6H6M3 8l3-3M3 8l3 3"/></svg>{{end}}
80
81{{define "content"}}
82
83{{/* Row B — the menu bar and the toolbar share one row.
84 Six short words are about 300px; a row holding only them wastes
85 two-thirds of itself. They wrap onto separate rows below ~900px,
86 which is also what keeps the chrome inside the 320px reflow
87 budget. */}}
88<div data-chrome>
89
90<nav estilo-menubar data-menubar aria-label="Sheet menu">
91
92{{/* estilo's menu bar ([estilo-menubar], estilo menu.css): square titles
93 in the page flow, 9px floating panels with inset rows, a shortcut
94 column, and the platform's own labels (⌘Z on a Mac, Ctrl+Z
95 elsewhere) from estilo's menu.Shortcut, formatted server-side.
96
97 Every top-level menu is in the one group, name="estilo-menus", with
98 the bar's Menu, switcher and account: opening File closes the
99 account menu and the other way round, which is the behaviour this
100 app chose on purpose and TestTheMenusShareOneGroup pins. estilo's
101 chrome.js closes the group on Escape and an outside click.
102
103 There are no submenus. A <details> nested inside a grouped one
104 closes its parent when it opens (TestNoNestedExclusiveDetails), and
105 inline accordions made Format a panel that scrolled with its
106 swatches cut off. Format lost Number, Fill colour and Text colour,
107 which the toolbar beside it already carries; Insert's functions
108 are rows under a heading. Hidden items are hidden until
109 commands.js can run them: an item appears only if it works. */}}
110
111<details estilo-menubar-menu name="estilo-menus">
112<summary estilo-menubar-title>File</summary>
113<div estilo-menu>
114<form method="post" action="/sheets"><button estilo-menu-item type="submit"><span estilo-menu-label>New sheet</span></button></form>
115{{/* A page, not a file input in this menu. A hidden input behind a
116 menu-shaped label never submits itself, so the item would have
117 needed script to import anything at all — see handleImportPage. */}}
118<a estilo-menu-item href="/sheets/import"><span estilo-menu-label>Import a spreadsheet…</span></a>
119<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/export.xlsx"><span estilo-menu-label>Download as .xlsx</span></a>
120<hr>
121<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/table"><span estilo-menu-label>Open as table</span></a>
122<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/history"><span estilo-menu-label>Version history</span></a>
123{{if .Data.MayShare}}<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/share"><span estilo-menu-label>Share…</span></a>{{end}}
124</div>
125</details>
126
127{{if .Data.MayWrite}}
128<details estilo-menubar-menu name="estilo-menus">
129<summary estilo-menubar-title>Edit</summary>
130<div estilo-menu>
131{{/* Undo is a form, not a button, so it works with scripting off —
132 it posts to the same route the keyboard shortcut fetches, so
133 there is one undo and not two that can disagree. */}}
134<form method="post" action="/sheets/{{.Data.Sheet.ID}}/undo"><button estilo-menu-item type="submit"><span estilo-menu-label>Undo</span><kbd estilo-menu-key>{{.Frame.Keys.undo}}</kbd></button></form>
135{{/* The separators around the script-only groups are script-only too:
136 with scripting off Undo stands alone, not above two empty rules. */}}
137<hr data-needs-js hidden>
138<button estilo-menu-item type="button" data-cmd="cut" hidden><span estilo-menu-label>Cut</span><kbd estilo-menu-key>{{.Frame.Keys.cut}}</kbd></button>
139<button estilo-menu-item type="button" data-cmd="copy" hidden><span estilo-menu-label>Copy</span><kbd estilo-menu-key>{{.Frame.Keys.copy}}</kbd></button>
140{{/* Paste is here despite the browser being allowed to refuse a menu
141 read of the clipboard. It is not a dead control: clipboard.js
142 asks, and on a refusal says so in the status line below the grid
143 — which is more than the app could do for a person who does not
144 know the keyboard is the only way. The keyboard path needs no
145 permission at all, because a paste EVENT carries its own data. */}}
146<button estilo-menu-item type="button" data-cmd="paste" hidden><span estilo-menu-label>Paste</span><kbd estilo-menu-key>{{.Frame.Keys.paste}}</kbd></button>
147<button estilo-menu-item type="button" data-cmd="clear" hidden><span estilo-menu-label>Clear contents</span><kbd estilo-menu-key>{{.Frame.Keys.delete}}</kbd></button>
148<hr data-needs-js hidden>
149{{/* Fill down and fill right are the menu equivalents of the grid's
150 drag handle — AGENTS.md requires one for every drag-only
151 affordance. Ctrl+R is reload in every browser, so fill right is
152 deliberately shortcut-free. */}}
153<button estilo-menu-item type="button" data-cmd="fill-down" hidden><span estilo-menu-label>Fill down</span><kbd estilo-menu-key>{{.Frame.Keys.filldown}}</kbd></button>
154<button estilo-menu-item type="button" data-cmd="fill-right" hidden><span estilo-menu-label>Fill right</span></button>
155<button estilo-menu-item type="button" data-cmd="select-all" hidden><span estilo-menu-label>Select all</span><kbd estilo-menu-key>{{.Frame.Keys.selectall}}</kbd></button>
156</div>
157</details>
158{{end}}
159
160{{if or .Data.MayWrite .Data.MayComment}}
161<details estilo-menubar-menu name="estilo-menus">
162<summary estilo-menubar-title>Insert</summary>
163<div estilo-menu>
164{{if .Data.MayWrite}}
165{{/* Exactly the aggregates the engine registers and that a one-click
166 insert can guess the arguments for. VLOOKUP, INDEX and MATCH are
167 registered too and are deliberately absent: nothing here can guess
168 their second and third arguments. Rows under a heading, not a
169 submenu, and hidden until commands.js can insert them. */}}
170<p estilo-menu-heading data-needs-js hidden>Function</p>
171<button estilo-menu-item type="button" data-cmd="fn" data-fn="SUM" hidden><span estilo-menu-label>SUM</span></button>
172<button estilo-menu-item type="button" data-cmd="fn" data-fn="AVERAGE" hidden><span estilo-menu-label>AVERAGE</span></button>
173<button estilo-menu-item type="button" data-cmd="fn" data-fn="COUNT" hidden><span estilo-menu-label>COUNT</span></button>
174<button estilo-menu-item type="button" data-cmd="fn" data-fn="MIN" hidden><span estilo-menu-label>MIN</span></button>
175<button estilo-menu-item type="button" data-cmd="fn" data-fn="MAX" hidden><span estilo-menu-label>MAX</span></button>
176{{if .Data.MayComment}}<hr data-needs-js hidden>{{end}}
177{{end}}
178{{if .Data.MayComment}}<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/comments"><span estilo-menu-label>Comment…</span></a>{{end}}
179</div>
180</details>
181{{end}}
182
183{{if .Data.MayWrite}}
184{{/* The Format menu is entirely script-driven, so the whole menu is
185 hidden until commands.js reveals it. A menu of items that do
186 nothing is worse than no menu. */}}
187<details estilo-menubar-menu name="estilo-menus" hidden data-needs-js>
188<summary estilo-menubar-title>Format</summary>
189<div estilo-menu>
190<button estilo-menu-item type="button" data-cmd="bold"><span estilo-menu-label>Bold</span><kbd estilo-menu-key>{{.Frame.Keys.bold}}</kbd></button>
191<button estilo-menu-item type="button" data-cmd="italic"><span estilo-menu-label>Italic</span><kbd estilo-menu-key>{{.Frame.Keys.italic}}</kbd></button>
192<hr>
193<button estilo-menu-item type="button" data-cmd="align" data-value="left"><span estilo-menu-label>Align left</span></button>
194<button estilo-menu-item type="button" data-cmd="align" data-value="center"><span estilo-menu-label>Align centre</span></button>
195<button estilo-menu-item type="button" data-cmd="align" data-value="right"><span estilo-menu-label>Align right</span></button>
196<hr>
197{{/* The menu equivalent of dragging a column edge (AGENTS.md: every
198 drag-only affordance has one). A number field rather than a set of
199 buttons, because the size a drag produces is the one thing here you
200 cannot pick from a list. Real forms rather than a prompt(); they
201 post through commands.js because the size route takes JSON. */}}
202<button estilo-menu-item type="button" data-cmd="autofit-columns"><span estilo-menu-label>Auto-fit columns</span></button>
203<form data-size-form data-axis="col">
204<label for="colw">Column width</label>
205<input rst-input id="colw" name="size" type="number" min="16" max="2000" step="1" placeholder="px">
206<button rst-btn="ghost" type="submit">Set</button>
207</form>
208<form data-size-form data-axis="row">
209<label for="rowh">Row height</label>
210<input rst-input id="rowh" name="size" type="number" min="16" max="2000" step="1" placeholder="px">
211<button rst-btn="ghost" type="submit">Set</button>
212</form>
213<hr>
214<button estilo-menu-item type="button" data-cmd="clear-format"><span estilo-menu-label>Clear formatting</span></button>
215</div>
216</details>
217{{end}}
218
219<details estilo-menubar-menu name="estilo-menus">
220<summary estilo-menubar-title>Data</summary>
221<div estilo-menu>
222{{/* Both items lead to the table view, and the labels say so. The
223 grid does not apply a saved sort or filter yet — snapshot never
224 reads the ViewSpec, only the table does — and a "Sort A→Z" here
225 that silently moved you to another page would be worse than a
226 link that admits where it goes. */}}
227<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/table"><span estilo-menu-label>Sort and filter, in the table view…</span></a>
228{{if .Data.MayWrite}}
229<form method="post" action="/sheets/{{.Data.Sheet.ID}}/view">
230<input type="hidden" name="sortCol" value="-1">
231<input type="hidden" name="filterCol" value="-1">
232<button estilo-menu-item type="submit"><span estilo-menu-label>Clear sort and filter</span></button>
233</form>
234{{end}}
235</div>
236</details>
237
238{{/* No Help menu here. The shortcut list moved into the account menu's
239 Help, as "Keyboard shortcuts…" opening a dialog, where every app's
240 help lives (spec §8.2). */}}
241
242</nav>
243
244{{if .Data.MayWrite}}
245{{/* The toolbar. Hidden until commands.js wires it: every control
246 posts, so an unwired one is a button that does nothing.
247
248 role="toolbar" with a roving tabindex, so the whole strip is one
249 tab stop rather than fourteen — the same shape the Docs app uses,
250 deliberately, so the two apps behave alike. */}}
251<div data-toolbar role="toolbar" aria-label="Formatting" aria-controls="main" hidden data-needs-js>
252
253<button type="button" data-tool rst-tip data-cmd="undo" aria-label="Undo" data-tip="Undo ({{.Frame.Keys.undo}})">{{template "glyph-undo"}}</button>
254
255<span data-tool-rule aria-hidden="true"></span>
256
257<details rst-dropdown name="rst-tools" data-tool-menu>
258<summary data-tool rst-tip aria-label="Number format" data-tip="Number format">123<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
259<div rst-dropdown-menu>
260{{range .Data.NumberFormats}}
261<button type="button" data-cmd="numfmt" data-value="{{.Code}}">{{.Label}}</button>
262{{end}}
263</div>
264</details>
265
266<span data-tool-rule aria-hidden="true"></span>
267
268<button type="button" data-tool rst-tip data-cmd="bold" aria-pressed="false" aria-label="Bold" data-tip="Bold ({{.Frame.Keys.bold}})"><b>B</b></button>
269<button type="button" data-tool rst-tip data-cmd="italic" aria-pressed="false" aria-label="Italic" data-tip="Italic ({{.Frame.Keys.italic}})"><i>I</i></button>
270
271<span data-tool-rule aria-hidden="true"></span>
272
273<details rst-dropdown name="rst-tools" data-tool-menu>
274<summary data-tool rst-tip aria-label="Fill colour" data-tip="Fill colour"><span data-fill-chip aria-hidden="true"></span><span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
275<div rst-dropdown-menu data-swatches>
276<button type="button" data-cmd="fill" data-value="none">No fill</button>
277{{range .Data.FillSwatches}}
278<button type="button" data-cmd="fill" data-value="{{.Name}}" aria-label="Fill {{.Label}}"
279 data-swatch style="--sheet-h:{{.H}};--sheet-c:{{.C}}">{{.Label}}</button>
280{{end}}
281</div>
282</details>
283
284<details rst-dropdown name="rst-tools" data-tool-menu>
285<summary data-tool rst-tip aria-label="Text colour" data-tip="Text colour"><span data-ink-chip aria-hidden="true">A</span><span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
286<div rst-dropdown-menu data-swatches>
287<button type="button" data-cmd="ink" data-value="none">Default</button>
288{{range .Data.InkSwatches}}
289<button type="button" data-cmd="ink" data-value="{{.Name}}" aria-label="Text {{.Label}}"
290 data-swatch data-ink-swatch style="--sheet-kh:{{.H}};--sheet-kc:{{.C}}">{{.Label}}</button>
291{{end}}
292</div>
293</details>
294
295<span data-tool-rule aria-hidden="true"></span>
296
297<button type="button" data-tool rst-tip data-cmd="align" data-value="left" aria-pressed="false" aria-label="Align left" data-tip="Align left">{{template "glyph-align-left"}}</button>
298<button type="button" data-tool rst-tip data-cmd="align" data-value="center" aria-pressed="false" aria-label="Align centre" data-tip="Align centre">{{template "glyph-align-center"}}</button>
299<button type="button" data-tool rst-tip data-cmd="align" data-value="right" aria-pressed="false" aria-label="Align right" data-tip="Align right">{{template "glyph-align-right"}}</button>
300
301</div>
302{{end}}
303
304</div>
305
306{{/* Row C — the name box and the formula bar, full width.
307 The formula bar shows the selected cell's INPUT while the grid
308 shows its computed value, which is the difference between a
309 spreadsheet and a table of numbers. It is also the reliable way to
310 see a formula: the in-cell editor swap is best effort. */}}
311<div data-formula-row>
312<label class="rst-sr-only" for="namebox">Cell reference</label>
313<input rst-input id="namebox" class="rst-mono" data-name-box type="text"
314 autocomplete="off" spellcheck="false" value="A1">
315<label for="formula" data-fx aria-hidden="true"><i>fx</i></label>
316<span class="rst-sr-only" id="formula-label">Selected cell contents</span>
317<input rst-input id="formula" class="rst-mono" data-formula-input type="text"
318 aria-labelledby="formula-label" autocomplete="off" spellcheck="false"
319 placeholder="Select a cell"{{if not .Data.MayWrite}} readonly{{end}}>
320</div>
321
322<noscript>
323{{/* Spec §6 names the grid editor, drag-fill and live sync as the
324 script-required parts, and everything else as scriptless. A visitor
325 with scripting off should land somewhere useful rather than on an
326 empty div, so this is a link and not an apology. */}}
327<div rst-callout rst-tone="info">
328<span rst-callout-ic aria-hidden="true">{{icon "info"}}</span>
329<div rst-callout-body>
330<p>The grid needs JavaScript. Everything it does, the
331<a href="/sheets/{{.Data.Sheet.ID}}/table">table view</a> also does —
332read every cell, edit any of them, one page at a time.</p>
333</div>
334</div>
335</noscript>
336
337{{template "contrast-notice" .Data.Warned}}
338
339<div data-sheet-grid></div>
340
341{{/* Row D — the tabs along the bottom, and the status that used to sit
342 in the strip above the grid.
343
344 One tab today, rendered as a link to the page it is already on, so
345 the strip is real markup rather than a picture of tabs. There is
346 deliberately no "+": nothing in this build creates, names or
347 switches a tab, and spec §14 makes "a second tab is reachable in
348 the UI" the trigger for multi-worksheet import. A + that did
349 nothing would fake that trigger. */}}
350<div data-tabstrip>
351<nav data-sheet-tabs aria-label="Sheets in this document">
352<a href="/sheets/{{.Data.Sheet.ID}}" aria-current="page">{{.Data.Sheet.TabName}}</a>
353</nav>
354{{/* Deliberately NOT role="status". It changes on every arrow key, and
355 a live region announcing "3 cells" after each one talks over the
356 cell content a screen reader is trying to read. It is a glance,
357 and the selection itself is what announces. */}}
358<span data-selection-size></span>
359{{/* The one place this app can say something transient. role="status"
360 is right here and wrong on the selection size beside it: this
361 changes when you ask for something and it did not happen, which
362 is exactly what a person needs read out, where the selection
363 size changes on every arrow key and would talk over the cell. */}}
364<span data-sheet-note role="status"></span>
365<span data-sheet-live data-state="off" role="status">Connecting</span>
366</div>
367{{end}}
368