@svgrid/enterprise 3.0.1 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SvFormulaBar.svelte +606 -0
- package/dist/SvFormulaBar.svelte.d.ts +62 -0
- package/dist/SvPivotDesigner.svelte +65 -14
- package/dist/SvRibbonIcon.svelte +46 -0
- package/dist/SvRibbonIcon.svelte.d.ts +17 -0
- package/dist/SvSchemaChart.svelte +4 -2
- package/dist/SvSheet.svelte +4114 -0
- package/dist/SvSheet.svelte.d.ts +142 -0
- package/dist/SvSheetComment.svelte +98 -0
- package/dist/SvSheetComment.svelte.d.ts +22 -0
- package/dist/SvSheetConditionalFormat.svelte +162 -0
- package/dist/SvSheetConditionalFormat.svelte.d.ts +14 -0
- package/dist/SvSheetDataValidation.svelte +249 -0
- package/dist/SvSheetDataValidation.svelte.d.ts +14 -0
- package/dist/SvSheetFilterMenu.svelte +217 -0
- package/dist/SvSheetFilterMenu.svelte.d.ts +17 -0
- package/dist/SvSheetFindReplace.svelte +132 -0
- package/dist/SvSheetFindReplace.svelte.d.ts +10 -0
- package/dist/SvSheetFormatCells.svelte +421 -0
- package/dist/SvSheetFormatCells.svelte.d.ts +17 -0
- package/dist/SvSheetGoalSeek.svelte +144 -0
- package/dist/SvSheetGoalSeek.svelte.d.ts +12 -0
- package/dist/SvSheetInsertFunction.svelte +147 -0
- package/dist/SvSheetInsertFunction.svelte.d.ts +8 -0
- package/dist/SvSheetListPicker.svelte +89 -0
- package/dist/SvSheetListPicker.svelte.d.ts +16 -0
- package/dist/SvSheetManageRules.svelte +185 -0
- package/dist/SvSheetManageRules.svelte.d.ts +16 -0
- package/dist/SvSheetNameManager.svelte +225 -0
- package/dist/SvSheetNameManager.svelte.d.ts +11 -0
- package/dist/SvSheetPasteSpecial.svelte +105 -0
- package/dist/SvSheetPasteSpecial.svelte.d.ts +12 -0
- package/dist/SvSheetRemoveDuplicates.svelte +149 -0
- package/dist/SvSheetRemoveDuplicates.svelte.d.ts +12 -0
- package/dist/SvSheetRibbon.svelte +1201 -0
- package/dist/SvSheetRibbon.svelte.d.ts +34 -0
- package/dist/SvSheetSizeDialog.svelte +58 -0
- package/dist/SvSheetSizeDialog.svelte.d.ts +11 -0
- package/dist/SvSheetTabs.svelte +441 -0
- package/dist/SvSheetTabs.svelte.d.ts +36 -0
- package/dist/SvSheetTabs.test-harness.svelte.d.ts +17 -0
- package/dist/SvSheetTabs.test-harness.svelte.js +22 -0
- package/dist/SvSheetTextToColumns.svelte +172 -0
- package/dist/SvSheetTextToColumns.svelte.d.ts +13 -0
- package/dist/SvSheetValidationAlert.svelte +83 -0
- package/dist/SvSheetValidationAlert.svelte.d.ts +15 -0
- package/dist/cdn/svgrid-enterprise.svelte-external.js +18997 -5396
- package/dist/designer/assets/GridMenus-BlsvL_aU.js +8 -0
- package/dist/designer/assets/SvChartMenu-BYO9cYdy.css +1 -0
- package/dist/designer/assets/SvChartMenu-D9hEeHar.js +1 -0
- package/dist/designer/assets/SvDateRangeInput-BBRzrQeg.js +1 -0
- package/dist/designer/assets/SvDateTimePicker-DARvKUL2.js +1 -0
- package/dist/designer/assets/SvGridCellEditor-C7uQXYds.js +4 -0
- package/dist/designer/assets/SvGridChart-B8j1SSy3.js +3 -0
- package/dist/designer/assets/SvGridChart-BgLuPO7I.css +1 -0
- package/dist/designer/assets/SvGridChartBuilder-08YWWeqY.css +1 -0
- package/dist/designer/assets/SvGridChartBuilder-kvyIU_Rl.js +1 -0
- package/dist/designer/assets/SvGridChartPanel-4Pc8y5yO.css +1 -0
- package/dist/designer/assets/SvGridChartPanel-pT6ITc2P.js +4 -0
- package/dist/designer/assets/{SvGridChartView-DUc-1RUf.css → SvGridChartView-7msfnWZT.css} +1 -1
- package/dist/designer/assets/SvGridChartView-bg281UA1.js +1 -0
- package/dist/designer/assets/SvListBox-BDS66lmZ.js +110 -0
- package/dist/designer/assets/SvMenuList-DJ47ba1h.css +1 -0
- package/dist/designer/assets/SvMenuList-Dgq2D5VN.js +1 -0
- package/dist/designer/assets/SvTabs-DKmwb5p1.css +1 -0
- package/dist/designer/assets/SvTabs-UFCMTHXS.js +1 -0
- package/dist/designer/assets/chart-cIPUsbM5.js +1 -0
- package/dist/designer/assets/chart-export-B6bkQ3X8.js +11 -0
- package/dist/designer/assets/chart-export-pdf-BbA28-gt.js +11 -0
- package/dist/designer/assets/chart-panel-messages-DtTxpBUU.js +1 -0
- package/dist/designer/assets/chart-summary-Czu_5epb.js +1 -0
- package/dist/designer/assets/chart-trend-D5I5VEe8.js +1 -0
- package/dist/designer/assets/chart-validate-BMqM-qkF.js +1 -0
- package/dist/designer/assets/column-resize-BYDzR1Ve.js +1 -0
- package/dist/designer/assets/date-format-B8JbEtIG.js +1 -0
- package/dist/designer/assets/date-format-DKS-z0xS.css +1 -0
- package/dist/designer/assets/editor-contract-Bpoa7A5g.js +1 -0
- package/dist/designer/assets/focus-trap-CAreAhlU.js +1 -0
- package/dist/designer/assets/index-CMFgAoVV.css +1 -0
- package/dist/designer/assets/{index-C_a9Cj8Y.js → index-DwU7CAMm.js} +291 -290
- package/dist/designer/assets/list-option-BP_dWS66.js +1 -0
- package/dist/designer/assets/row-resize-DLktcqUK.js +1 -0
- package/dist/designer/assets/src-CITpcm6b.js +1 -0
- package/dist/designer/assets/src-EdI3Xqds.js +58 -0
- package/dist/designer/index.html +23 -14
- package/dist/export-pdf.d.ts +89 -0
- package/dist/export-pdf.js +130 -0
- package/dist/export.js +17 -10
- package/dist/index.d.ts +64 -1
- package/dist/index.js +68 -1
- package/dist/install.js +5 -0
- package/dist/node/studio.js +64 -2
- package/dist/pivot-chart.d.ts +25 -1
- package/dist/pivot-chart.js +25 -69
- package/dist/sheet/address.d.ts +43 -0
- package/dist/sheet/address.js +81 -0
- package/dist/sheet/ast.d.ts +94 -0
- package/dist/sheet/ast.js +56 -0
- package/dist/sheet/auto-filter.d.ts +66 -0
- package/dist/sheet/auto-filter.js +147 -0
- package/dist/sheet/autocomplete.d.ts +42 -0
- package/dist/sheet/autocomplete.js +162 -0
- package/dist/sheet/coerce.d.ts +31 -0
- package/dist/sheet/coerce.js +122 -0
- package/dist/sheet/commands.d.ts +89 -0
- package/dist/sheet/commands.js +230 -0
- package/dist/sheet/comments.d.ts +29 -0
- package/dist/sheet/comments.js +73 -0
- package/dist/sheet/conditional-formats.d.ts +144 -0
- package/dist/sheet/conditional-formats.js +316 -0
- package/dist/sheet/deps.d.ts +60 -0
- package/dist/sheet/deps.js +199 -0
- package/dist/sheet/document.d.ts +150 -0
- package/dist/sheet/document.js +264 -0
- package/dist/sheet/edit-keys.d.ts +14 -0
- package/dist/sheet/edit-keys.js +40 -0
- package/dist/sheet/entry.d.ts +31 -0
- package/dist/sheet/entry.js +76 -0
- package/dist/sheet/evaluate.d.ts +61 -0
- package/dist/sheet/evaluate.js +337 -0
- package/dist/sheet/find-replace.d.ts +62 -0
- package/dist/sheet/find-replace.js +152 -0
- package/dist/sheet/format-store.d.ts +99 -0
- package/dist/sheet/format-store.js +233 -0
- package/dist/sheet/freeze.d.ts +54 -0
- package/dist/sheet/freeze.js +58 -0
- package/dist/sheet/function-catalog.d.ts +10 -0
- package/dist/sheet/function-catalog.js +94 -0
- package/dist/sheet/functions.d.ts +28 -0
- package/dist/sheet/functions.js +362 -0
- package/dist/sheet/goal-seek.d.ts +73 -0
- package/dist/sheet/goal-seek.js +153 -0
- package/dist/sheet/index.d.ts +53 -0
- package/dist/sheet/index.js +53 -0
- package/dist/sheet/merges.d.ts +71 -0
- package/dist/sheet/merges.js +137 -0
- package/dist/sheet/names.d.ts +31 -0
- package/dist/sheet/names.js +87 -0
- package/dist/sheet/navigate.d.ts +52 -0
- package/dist/sheet/navigate.js +135 -0
- package/dist/sheet/number-format.d.ts +63 -0
- package/dist/sheet/number-format.js +508 -0
- package/dist/sheet/palette.d.ts +19 -0
- package/dist/sheet/palette.js +84 -0
- package/dist/sheet/parse.d.ts +13 -0
- package/dist/sheet/parse.js +108 -0
- package/dist/sheet/paste-special.d.ts +164 -0
- package/dist/sheet/paste-special.js +592 -0
- package/dist/sheet/protection.d.ts +21 -0
- package/dist/sheet/protection.js +48 -0
- package/dist/sheet/rects.d.ts +45 -0
- package/dist/sheet/rects.js +112 -0
- package/dist/sheet/refs.d.ts +82 -0
- package/dist/sheet/refs.js +266 -0
- package/dist/sheet/ribbon-icons.d.ts +31 -0
- package/dist/sheet/ribbon-icons.js +350 -0
- package/dist/sheet/ribbon.d.ts +173 -0
- package/dist/sheet/ribbon.js +753 -0
- package/dist/sheet/shortcuts.d.ts +164 -0
- package/dist/sheet/shortcuts.js +623 -0
- package/dist/sheet/structure.d.ts +87 -0
- package/dist/sheet/structure.js +166 -0
- package/dist/sheet/tables.d.ts +70 -0
- package/dist/sheet/tables.js +147 -0
- package/dist/sheet/tokenize.d.ts +54 -0
- package/dist/sheet/tokenize.js +304 -0
- package/dist/sheet/transforms.d.ts +76 -0
- package/dist/sheet/transforms.js +154 -0
- package/dist/sheet/validation.d.ts +92 -0
- package/dist/sheet/validation.js +241 -0
- package/dist/sheet/workbook.d.ts +72 -0
- package/dist/sheet/workbook.js +409 -0
- package/dist/sheet-enable.d.ts +6 -0
- package/dist/sheet-enable.js +46 -0
- package/dist/studio/ui-components.generated.js +63 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -4
- package/dist/designer/assets/GridMenus-B1JtIN21.js +0 -8
- package/dist/designer/assets/SvDateRangeInput-CjO7nn_x.js +0 -1
- package/dist/designer/assets/SvDateTimePicker-ByBD_ov6.js +0 -1
- package/dist/designer/assets/SvField-YDLipR6O.css +0 -1
- package/dist/designer/assets/SvField-cj1PFNgZ.js +0 -1
- package/dist/designer/assets/SvGridCellEditor-0W1L2hJ1.js +0 -2
- package/dist/designer/assets/SvGridChart-DBOR4xcz.js +0 -2
- package/dist/designer/assets/SvGridChart-fkMWeh1J.css +0 -1
- package/dist/designer/assets/SvGridChartPanel-symrhNnR.js +0 -11
- package/dist/designer/assets/SvGridChartView-BmRxq6mU.js +0 -1
- package/dist/designer/assets/SvListBox-1oUF6Iav.js +0 -110
- package/dist/designer/assets/chart-DXFganU8.js +0 -1
- package/dist/designer/assets/column-resize-C_inr5zT.js +0 -1
- package/dist/designer/assets/date-format-DGagAqRc.css +0 -1
- package/dist/designer/assets/date-format-onlYDsrY.js +0 -1
- package/dist/designer/assets/easing-zaypKrr0.js +0 -3
- package/dist/designer/assets/index-k2stW6L8.css +0 -1
- package/dist/designer/assets/row-resize-B0FWHcNr.js +0 -1
- package/dist/designer/assets/src-D9p27OGC.js +0 -58
|
@@ -0,0 +1,1201 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The ribbon.
|
|
4
|
+
*
|
|
5
|
+
* Renders `RIBBON_TABS` and nothing else: the tab strip, then a band of
|
|
6
|
+
* groups, each a block of controls over a small centred group label.
|
|
7
|
+
* That label is the thing that makes a ribbon read as a ribbon rather than
|
|
8
|
+
* as a toolbar, so it is not optional.
|
|
9
|
+
*
|
|
10
|
+
* The geometry is Excel's. A group is three rows of 24px; a large button
|
|
11
|
+
* (icon over label) takes a whole column of that height and a small one
|
|
12
|
+
* takes a row, so Cut / Copy / Paste Special stack beside a tall Paste. A
|
|
13
|
+
* split button pairs a face that applies the last pick with an arrow that
|
|
14
|
+
* opens the menu, and the colour menus are Excel's picker: the theme row,
|
|
15
|
+
* five tints and shades under each colour, then the standard colours.
|
|
16
|
+
*
|
|
17
|
+
* It is responsive the way Excel's is, in Excel's two steps: when the band
|
|
18
|
+
* is narrower than its groups, groups go compact from the right (small
|
|
19
|
+
* buttons keep the icon and drop the label), and only when every group is
|
|
20
|
+
* compact do groups fold, again from the right, into one large button each
|
|
21
|
+
* that opens the group in a dropdown. Nothing is hidden, it is only folded.
|
|
22
|
+
* The three widths of every group are measured off invisible copies of the
|
|
23
|
+
* band, so a group that starts folded still knows how wide it would like
|
|
24
|
+
* to be.
|
|
25
|
+
*
|
|
26
|
+
* It collapses the way Excel's does: a double-click on a tab, the chevron
|
|
27
|
+
* at the band's end or Ctrl+F1 leaves the tab row alone, a click on a tab
|
|
28
|
+
* then shows the band over the sheet until a command runs, and the pin at
|
|
29
|
+
* its end brings it back for good (`collapsed`, bindable).
|
|
30
|
+
*
|
|
31
|
+
* Painted with the grid's own --sg-* tokens rather than Excel's palette, so
|
|
32
|
+
* it matches whatever theme the app runs. Pick the `excel` theme preset and
|
|
33
|
+
* it reads as Excel; pick a dark one and it stays legible.
|
|
34
|
+
*
|
|
35
|
+
* The ribbon owns no sheet state. Every button calls an action from
|
|
36
|
+
* `sheet/ribbon.ts`, which calls the same function the matching keystroke
|
|
37
|
+
* does, so a button and its shortcut cannot drift apart.
|
|
38
|
+
*/
|
|
39
|
+
import { SvPopover } from '@svgrid/grid'
|
|
40
|
+
import type { GridCommandContext } from '@svgrid/grid/shortcuts'
|
|
41
|
+
import SvRibbonIcon from './SvRibbonIcon.svelte'
|
|
42
|
+
import {
|
|
43
|
+
RIBBON_TABS,
|
|
44
|
+
type RibbonTab, type RibbonGroup, type RibbonItem, type RibbonOption, type RibbonActionId,
|
|
45
|
+
} from './sheet/ribbon'
|
|
46
|
+
import { THEME_COLOURS, STANDARD_COLOURS } from './sheet/palette'
|
|
47
|
+
|
|
48
|
+
type Props = {
|
|
49
|
+
/**
|
|
50
|
+
* How to reach the grid. A getter rather than the context itself because
|
|
51
|
+
* the context is rebuilt per call and the ribbon needs a live one at the
|
|
52
|
+
* moment a button is pressed, not the one that existed at mount.
|
|
53
|
+
*/
|
|
54
|
+
cmd: () => GridCommandContext | null
|
|
55
|
+
/** Called after any action that changed something, so the host re-renders. */
|
|
56
|
+
onChange?: () => void
|
|
57
|
+
/** Actions the ribbon does not run itself, because they need chrome. */
|
|
58
|
+
onAction?: (action: RibbonActionId, cmd: GridCommandContext) => void
|
|
59
|
+
/** Which of `onAction`'s toggles are currently on. The host owns that
|
|
60
|
+
* state (there is no store here to read it from), so it reports it. */
|
|
61
|
+
activeActions?: ReadonlyArray<RibbonActionId>
|
|
62
|
+
tabs?: ReadonlyArray<RibbonTab>
|
|
63
|
+
/** Start on a tab other than the first. */
|
|
64
|
+
tab?: string
|
|
65
|
+
/**
|
|
66
|
+
* Actions to leave off the ribbon: a button nobody answers is worse
|
|
67
|
+
* than no button. A group with nothing left in it goes too.
|
|
68
|
+
*/
|
|
69
|
+
without?: ReadonlyArray<RibbonActionId>
|
|
70
|
+
/**
|
|
71
|
+
* Excel's collapsed ribbon: the tabs alone. A click on a tab shows the
|
|
72
|
+
* band over the sheet until a command runs or the pointer leaves;
|
|
73
|
+
* double-clicking a tab, the chevron and Ctrl+F1 toggle it.
|
|
74
|
+
*/
|
|
75
|
+
collapsed?: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let {
|
|
79
|
+
cmd,
|
|
80
|
+
onChange,
|
|
81
|
+
onAction,
|
|
82
|
+
activeActions = [],
|
|
83
|
+
tabs = RIBBON_TABS,
|
|
84
|
+
tab = $bindable(tabs[0]?.id ?? 'home'),
|
|
85
|
+
without = [],
|
|
86
|
+
collapsed = $bindable(false),
|
|
87
|
+
}: Props = $props()
|
|
88
|
+
|
|
89
|
+
/** The band shown over the sheet while the ribbon is collapsed. */
|
|
90
|
+
let peek = $state(false)
|
|
91
|
+
function onTabClick(id: string) {
|
|
92
|
+
if (collapsed) { peek = tab === id ? !peek : true }
|
|
93
|
+
tab = id
|
|
94
|
+
}
|
|
95
|
+
function toggleCollapsed() {
|
|
96
|
+
collapsed = !collapsed
|
|
97
|
+
peek = false
|
|
98
|
+
}
|
|
99
|
+
$effect(() => {
|
|
100
|
+
if (!collapsed) peek = false
|
|
101
|
+
})
|
|
102
|
+
$effect(() => {
|
|
103
|
+
if (!peek) return
|
|
104
|
+
// A click outside the ribbon, or Escape, puts the peeked band away.
|
|
105
|
+
const away = (e: PointerEvent) => { if (!rootEl?.contains(e.target as Node) && !(e.target as HTMLElement)?.closest?.('.sv-popover, .sv-modal')) peek = false }
|
|
106
|
+
const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') peek = false }
|
|
107
|
+
window.addEventListener('pointerdown', away, true)
|
|
108
|
+
window.addEventListener('keydown', esc, true)
|
|
109
|
+
return () => { window.removeEventListener('pointerdown', away, true); window.removeEventListener('keydown', esc, true) }
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Bumped after every action so the `isOn` / `isEnabled` / `current` reads
|
|
114
|
+
* below re-run.
|
|
115
|
+
*
|
|
116
|
+
* They read through the command context into the grid and the format store,
|
|
117
|
+
* neither of which is `$state`, so nothing would otherwise tell Svelte that
|
|
118
|
+
* pressing Bold should light the Bold button up.
|
|
119
|
+
*/
|
|
120
|
+
let version = $state(0)
|
|
121
|
+
|
|
122
|
+
const current = $derived.by(() => {
|
|
123
|
+
const found = tabs.find((t) => t.id === tab) ?? tabs[0]
|
|
124
|
+
if (!found || without.length === 0) return found
|
|
125
|
+
const groups = found.groups
|
|
126
|
+
.map((g) => ({ ...g, items: g.items.filter((i) => !i.emits || !without.includes(i.emits)) }))
|
|
127
|
+
.filter((g) => g.items.length > 0)
|
|
128
|
+
return { ...found, groups }
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function stateOf(item: RibbonItem): { on: boolean; enabled: boolean } {
|
|
132
|
+
void version
|
|
133
|
+
const context = cmd()
|
|
134
|
+
if (!context) return { on: false, enabled: false }
|
|
135
|
+
if (item.emits) {
|
|
136
|
+
return { on: activeActions.includes(item.emits), enabled: true }
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
on: item.lit ? activeActions.includes(item.lit) : (item.isOn?.(context) ?? false),
|
|
140
|
+
enabled: item.isEnabled?.(context) ?? true,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function currentOf(item: RibbonItem): string {
|
|
145
|
+
void version
|
|
146
|
+
const context = cmd()
|
|
147
|
+
return context && item.current ? item.current(context) : ''
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function run(item: RibbonItem, value?: string) {
|
|
151
|
+
const context = cmd()
|
|
152
|
+
if (!context) return
|
|
153
|
+
if (item.emits) {
|
|
154
|
+
onAction?.(item.emits, context)
|
|
155
|
+
version += 1
|
|
156
|
+
// Focus goes back to the sheet here too. A dialog the action opens
|
|
157
|
+
// takes it from there and, being what had focus when the dialog
|
|
158
|
+
// opened, the sheet is where the dialog returns it; an action with no
|
|
159
|
+
// dialog (Sort A to Z) leaves the next Ctrl+Z on the sheet rather
|
|
160
|
+
// than on the button.
|
|
161
|
+
context.focus()
|
|
162
|
+
peek = false
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
// Only report a change when the action says it did something. A declined
|
|
166
|
+
// action (nothing selected, no store attached) should not make the host
|
|
167
|
+
// re-render and should not look like it worked.
|
|
168
|
+
if (item.run?.(context, value)) onChange?.()
|
|
169
|
+
version += 1
|
|
170
|
+
// The click left focus on the button. Excel puts it back on the sheet, so
|
|
171
|
+
// the next keystroke - Ctrl+Z, or just typing - goes where the user is
|
|
172
|
+
// looking rather than re-pressing Bold.
|
|
173
|
+
context.focus()
|
|
174
|
+
// A command from the peeked band is the band's last act; Excel's goes
|
|
175
|
+
// away the same moment.
|
|
176
|
+
peek = false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function launch(action: RibbonActionId) {
|
|
180
|
+
const context = cmd()
|
|
181
|
+
if (context) onAction?.(action, context)
|
|
182
|
+
peek = false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Excel puts the shortcut after the name in the tooltip. */
|
|
186
|
+
function tip(item: RibbonItem): string {
|
|
187
|
+
return item.keys ? `${item.title} (${item.keys})` : item.title
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Re-read the toggles when the selection moves, so Bold lights up when you
|
|
192
|
+
* click a bold cell rather than only after you press a ribbon button.
|
|
193
|
+
* Cheap: it only bumps a counter, and the reads are lazy.
|
|
194
|
+
*/
|
|
195
|
+
function refresh() {
|
|
196
|
+
version += 1
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---- split buttons -------------------------------------------------------
|
|
200
|
+
/** The last value picked from each menu, which its face re-applies. */
|
|
201
|
+
let picked = $state<Record<string, string>>({})
|
|
202
|
+
// Every key exists from the start: a popover's `open` is bound to one of
|
|
203
|
+
// these, and binding an undefined value to a prop with a fallback is an
|
|
204
|
+
// error in Svelte rather than a false.
|
|
205
|
+
// svelte-ignore state_referenced_locally
|
|
206
|
+
let menuOpen = $state<Record<string, boolean>>(
|
|
207
|
+
Object.fromEntries(tabs.flatMap((t) => t.groups.flatMap((g) => g.items.map((i) => [i.id, false])))),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
function faceValue(item: RibbonItem): string {
|
|
211
|
+
return picked[item.id] ?? item.initial ?? item.options?.[0]?.value ?? ''
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pick(item: RibbonItem, value: string) {
|
|
215
|
+
picked = { ...picked, [item.id]: value }
|
|
216
|
+
menuOpen = { ...menuOpen, [item.id]: false }
|
|
217
|
+
run(item, value)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A dropdown's entry: its own action, or the item's run with its value. */
|
|
221
|
+
function pickOption(item: RibbonItem, option: RibbonOption) {
|
|
222
|
+
menuOpen = { ...menuOpen, [item.id]: false }
|
|
223
|
+
if (option.emits) {
|
|
224
|
+
const context = cmd()
|
|
225
|
+
if (!context) return
|
|
226
|
+
onAction?.(option.emits, context)
|
|
227
|
+
version += 1
|
|
228
|
+
context.focus()
|
|
229
|
+
peek = false
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
run(item, option.value)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Whether a face's colour bar has a colour to show, or means "none". */
|
|
236
|
+
function barColour(item: RibbonItem): string | null {
|
|
237
|
+
const value = faceValue(item)
|
|
238
|
+
return value === item.none?.value ? null : value
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---- responsive collapse -------------------------------------------------
|
|
242
|
+
/** Natural width of each group, expanded, measured off the hidden band. */
|
|
243
|
+
const natural = new Map<string, number>()
|
|
244
|
+
/** The same with the small buttons' labels off: Excel's first step down. */
|
|
245
|
+
const compactWidth = new Map<string, number>()
|
|
246
|
+
/** And folded to one large button, whose width its label sets. */
|
|
247
|
+
const foldedWidth = new Map<string, number>()
|
|
248
|
+
let measured = $state(0)
|
|
249
|
+
let bandWidth = $state(0)
|
|
250
|
+
// svelte-ignore state_referenced_locally
|
|
251
|
+
let groupOpen = $state<Record<string, boolean>>(
|
|
252
|
+
Object.fromEntries(tabs.flatMap((t) => t.groups.map((g) => [g.id, false]))),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
/** A folded group before its button has been measured. */
|
|
256
|
+
const COLLAPSED_WIDTH = 66
|
|
257
|
+
/** The band's own padding (4px a side; `clientWidth` already leaves the
|
|
258
|
+
* border out) and the collapse chevron at its end (20px and 4px of
|
|
259
|
+
* margins), so the fit is judged against the space the groups really
|
|
260
|
+
* get. Without the chevron in the sum the last group sat 18px past the
|
|
261
|
+
* edge and the band scrolled by that much. */
|
|
262
|
+
const BAND_PADDING = 8 + 24
|
|
263
|
+
|
|
264
|
+
function measure(node: HTMLElement, key: string) {
|
|
265
|
+
// "id" measures the group as drawn, "id:compact" with its labels off,
|
|
266
|
+
// "id:folded" as the one large button it folds into.
|
|
267
|
+
const colon = key.indexOf(':')
|
|
268
|
+
const id = colon < 0 ? key : key.slice(0, colon)
|
|
269
|
+
const into = key.endsWith(':compact') ? compactWidth : key.endsWith(':folded') ? foldedWidth : natural
|
|
270
|
+
const record = () => {
|
|
271
|
+
const width = node.getBoundingClientRect().width
|
|
272
|
+
if (width > 0 && into.get(id) !== width) {
|
|
273
|
+
into.set(id, width)
|
|
274
|
+
measured += 1
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
record()
|
|
278
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(record)
|
|
279
|
+
observer?.observe(node)
|
|
280
|
+
return { destroy: () => observer?.disconnect() }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* How the band fits its width, Excel's way and in Excel's order: first
|
|
285
|
+
* the groups go compact from the right (their small buttons keep the
|
|
286
|
+
* icon and lose the label, so Fill Down is an arrow and Wrap Text a
|
|
287
|
+
* glyph), and only when every group is compact and the band is still too
|
|
288
|
+
* narrow do groups fold, again from the right, into one large button
|
|
289
|
+
* each. Recomputed from scratch on every width change rather than
|
|
290
|
+
* nudged, so growing the window undoes exactly what shrinking it did.
|
|
291
|
+
*/
|
|
292
|
+
const fit = $derived.by(() => {
|
|
293
|
+
void measured
|
|
294
|
+
const groups = current?.groups ?? []
|
|
295
|
+
const compact = new Set<string>()
|
|
296
|
+
const folded = new Set<string>()
|
|
297
|
+
if (!bandWidth) return { compact, folded }
|
|
298
|
+
const wide = (g: RibbonGroup) => natural.get(g.id) ?? 120
|
|
299
|
+
const narrow = (g: RibbonGroup) => compactWidth.get(g.id) ?? wide(g)
|
|
300
|
+
const oneButton = (g: RibbonGroup) => foldedWidth.get(g.id) ?? COLLAPSED_WIDTH
|
|
301
|
+
let total = BAND_PADDING
|
|
302
|
+
for (const group of groups) total += wide(group)
|
|
303
|
+
for (let i = groups.length - 1; i >= 0 && total > bandWidth; i -= 1) {
|
|
304
|
+
const group = groups[i]!
|
|
305
|
+
if (narrow(group) < wide(group)) { total += narrow(group) - wide(group); compact.add(group.id) }
|
|
306
|
+
}
|
|
307
|
+
for (let i = groups.length - 1; i >= 0 && total > bandWidth; i -= 1) {
|
|
308
|
+
const group = groups[i]!
|
|
309
|
+
total += oneButton(group) - (compact.has(group.id) ? narrow(group) : wide(group))
|
|
310
|
+
folded.add(group.id)
|
|
311
|
+
}
|
|
312
|
+
return { compact, folded }
|
|
313
|
+
})
|
|
314
|
+
const collapsedGroups = $derived(fit.folded)
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Excel's column model, made explicit. Items run down a column while
|
|
318
|
+
* their rows go up (1, 2, 3) and a new column starts when they do not,
|
|
319
|
+
* or at a large button, which is a column by itself. The grid's own
|
|
320
|
+
* auto-placement keeps a cursor per row, so a two-item column (Undo over
|
|
321
|
+
* Redo) had the next column's third item slide back under it.
|
|
322
|
+
*/
|
|
323
|
+
function placed(items: ReadonlyArray<RibbonItem>): Array<{ item: RibbonItem; col: number }> {
|
|
324
|
+
let col = 0
|
|
325
|
+
let lastRow = 99
|
|
326
|
+
return items.map((item) => {
|
|
327
|
+
const row = item.size === 'large' ? 1 : (item.row ?? 1)
|
|
328
|
+
if (item.size === 'large' || row <= lastRow) col += 1
|
|
329
|
+
lastRow = item.size === 'large' ? 99 : row
|
|
330
|
+
return { item, col }
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The icon a folded group shows: its own, else its large command's, else its first. */
|
|
335
|
+
function groupIcon(group: RibbonGroup) {
|
|
336
|
+
return group.icon ?? (group.items.find((i) => i.size === 'large') ?? group.items.find((i) => i.icon))?.icon ?? 'format-cells'
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* What the empty font option is called. A cell with no font of its own
|
|
341
|
+
* renders in the theme's font, and Excel's combo names the font a cell
|
|
342
|
+
* has ("Aptos Narrow"), so the option reads the theme's first family
|
|
343
|
+
* rather than "Default".
|
|
344
|
+
*/
|
|
345
|
+
let rootEl = $state<HTMLDivElement | null>(null)
|
|
346
|
+
let themeFont = $state('Default')
|
|
347
|
+
$effect(() => {
|
|
348
|
+
if (!rootEl) return
|
|
349
|
+
const first = getComputedStyle(rootEl).fontFamily.split(',')[0]?.replace(/["']/g, '').trim()
|
|
350
|
+
if (first) themeFont = first
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
function onTabKey(event: KeyboardEvent) {
|
|
354
|
+
const ids = tabs.map((t) => t.id)
|
|
355
|
+
const at = ids.indexOf(tab)
|
|
356
|
+
if (event.key === 'ArrowRight' && at < ids.length - 1) { event.preventDefault(); tab = ids[at + 1]! }
|
|
357
|
+
if (event.key === 'ArrowLeft' && at > 0) { event.preventDefault(); tab = ids[at - 1]! }
|
|
358
|
+
}
|
|
359
|
+
</script>
|
|
360
|
+
|
|
361
|
+
<svelte:window onpointerup={refresh} onkeyup={refresh} />
|
|
362
|
+
|
|
363
|
+
<!--
|
|
364
|
+
One button, either size. `live` is off inside the hidden measuring band:
|
|
365
|
+
the copy there exists for its width alone, so it reads no state.
|
|
366
|
+
-->
|
|
367
|
+
{#snippet plainButton(item: RibbonItem, live: boolean)}
|
|
368
|
+
{@const state = live ? stateOf(item) : { on: false, enabled: true }}
|
|
369
|
+
<button
|
|
370
|
+
type="button"
|
|
371
|
+
class="btn"
|
|
372
|
+
class:large={item.size === 'large'}
|
|
373
|
+
class:wide={item.wide && item.size !== 'large'}
|
|
374
|
+
class:on={state.on}
|
|
375
|
+
disabled={!state.enabled}
|
|
376
|
+
title={tip(item)}
|
|
377
|
+
aria-label={item.title}
|
|
378
|
+
aria-pressed={item.kind === 'toggle' ? state.on : undefined}
|
|
379
|
+
onclick={() => run(item)}
|
|
380
|
+
>
|
|
381
|
+
{#if item.icon}
|
|
382
|
+
<SvRibbonIcon name={item.icon} size={item.size === 'large' ? 32 : 16} />
|
|
383
|
+
{:else}
|
|
384
|
+
<span class="glyph" data-glyph={item.id}>{item.label}</span>
|
|
385
|
+
{/if}
|
|
386
|
+
{#if item.size === 'large' || item.wide}
|
|
387
|
+
<span class="label">{item.label}</span>
|
|
388
|
+
{/if}
|
|
389
|
+
</button>
|
|
390
|
+
{/snippet}
|
|
391
|
+
|
|
392
|
+
<!-- Excel's split button: a face that re-applies the last pick, an arrow that opens the menu. -->
|
|
393
|
+
{#snippet split(item: RibbonItem, live: boolean)}
|
|
394
|
+
{@const state = live ? stateOf(item) : { on: false, enabled: true }}
|
|
395
|
+
{@const colour = barColour(item)}
|
|
396
|
+
<div class="split" class:on={state.on} class:disabled={!state.enabled}>
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
class="btn face"
|
|
400
|
+
class:has-bar={item.palette}
|
|
401
|
+
disabled={!state.enabled}
|
|
402
|
+
title={tip(item)}
|
|
403
|
+
aria-label={item.title}
|
|
404
|
+
onclick={() => run(item, faceValue(item))}
|
|
405
|
+
>
|
|
406
|
+
{#if item.icon && !(item.palette && item.id === 'text-colour')}
|
|
407
|
+
<SvRibbonIcon name={item.icon} />
|
|
408
|
+
{:else}
|
|
409
|
+
<span class="glyph" data-glyph={item.id}>{item.label}</span>
|
|
410
|
+
{/if}
|
|
411
|
+
{#if item.palette}
|
|
412
|
+
<span class="bar" class:none={colour === null} style:background={colour ?? 'transparent'}></span>
|
|
413
|
+
{/if}
|
|
414
|
+
</button>
|
|
415
|
+
{#if live}
|
|
416
|
+
<SvPopover bind:open={menuOpen[item.id]} placement="bottom-start" arrow={false} offset={2} ariaLabel={item.title}>
|
|
417
|
+
{#snippet anchor()}
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
class="btn arrow"
|
|
421
|
+
disabled={!state.enabled}
|
|
422
|
+
aria-label={`${item.title} options`}
|
|
423
|
+
aria-haspopup="menu"
|
|
424
|
+
aria-expanded={menuOpen[item.id] ?? false}
|
|
425
|
+
><SvRibbonIcon name="chevron-down" size={10} /></button>
|
|
426
|
+
{/snippet}
|
|
427
|
+
{#if item.palette}
|
|
428
|
+
<div class="palette" role="menu" aria-label={item.title}>
|
|
429
|
+
{#if item.none}
|
|
430
|
+
<button type="button" class="palette-none" role="menuitem" onclick={() => pick(item, item.none!.value)}>
|
|
431
|
+
<span class="swatch no-colour" aria-hidden="true"></span>{item.none.label}
|
|
432
|
+
</button>
|
|
433
|
+
{/if}
|
|
434
|
+
<div class="palette-title">Theme Colors</div>
|
|
435
|
+
<div class="swatches">
|
|
436
|
+
{#each THEME_COLOURS as row, r (r)}
|
|
437
|
+
{#each row as c (c.value + r)}
|
|
438
|
+
<button type="button" class="swatch" class:shade={r > 0} role="menuitem" title={c.label} aria-label={c.label} style:background={c.value} onclick={() => pick(item, c.value)}></button>
|
|
439
|
+
{/each}
|
|
440
|
+
{/each}
|
|
441
|
+
</div>
|
|
442
|
+
<div class="palette-title">Standard Colors</div>
|
|
443
|
+
<div class="swatches">
|
|
444
|
+
{#each STANDARD_COLOURS as c (c.value)}
|
|
445
|
+
<button type="button" class="swatch" role="menuitem" title={c.label} aria-label={c.label} style:background={c.value} onclick={() => pick(item, c.value)}></button>
|
|
446
|
+
{/each}
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
{:else}
|
|
450
|
+
<div class="menu" role="menu" aria-label={item.title}>
|
|
451
|
+
{#each item.options ?? [] as option (option.value)}
|
|
452
|
+
<button type="button" class="menu-item" role="menuitem" onclick={() => pick(item, option.value)}>
|
|
453
|
+
{#if option.icon}<SvRibbonIcon name={option.icon} />{/if}
|
|
454
|
+
<span>{option.label}</span>
|
|
455
|
+
</button>
|
|
456
|
+
{/each}
|
|
457
|
+
</div>
|
|
458
|
+
{/if}
|
|
459
|
+
</SvPopover>
|
|
460
|
+
{:else}
|
|
461
|
+
<span class="btn arrow" aria-hidden="true"><SvRibbonIcon name="chevron-down" size={10} /></span>
|
|
462
|
+
{/if}
|
|
463
|
+
</div>
|
|
464
|
+
{/snippet}
|
|
465
|
+
|
|
466
|
+
<!-- Excel's plain menu button: the face opens the list, every entry acts on its own. -->
|
|
467
|
+
{#snippet dropdown(item: RibbonItem, live: boolean)}
|
|
468
|
+
{@const state = live ? stateOf(item) : { on: false, enabled: true }}
|
|
469
|
+
{#snippet face()}
|
|
470
|
+
<button
|
|
471
|
+
type="button"
|
|
472
|
+
class="btn dropdown"
|
|
473
|
+
class:wide={item.wide}
|
|
474
|
+
class:large={item.size === 'large'}
|
|
475
|
+
disabled={!state.enabled}
|
|
476
|
+
title={tip(item)}
|
|
477
|
+
aria-label={item.title}
|
|
478
|
+
aria-haspopup="menu"
|
|
479
|
+
aria-expanded={live ? (menuOpen[item.id] ?? false) : false}
|
|
480
|
+
>
|
|
481
|
+
{#if item.icon}
|
|
482
|
+
<SvRibbonIcon name={item.icon} size={item.size === 'large' ? 32 : 16} />
|
|
483
|
+
{:else}
|
|
484
|
+
<span class="glyph" data-glyph={item.id}>{item.label}</span>
|
|
485
|
+
{/if}
|
|
486
|
+
{#if item.size === 'large' || item.wide}
|
|
487
|
+
<span class="label">{item.label}</span>
|
|
488
|
+
{/if}
|
|
489
|
+
<SvRibbonIcon name="chevron-down" size={8} />
|
|
490
|
+
</button>
|
|
491
|
+
{/snippet}
|
|
492
|
+
{#snippet entries()}
|
|
493
|
+
<div class="menu" role="menu" aria-label={item.title}>
|
|
494
|
+
{#each item.options ?? [] as option (option.value)}
|
|
495
|
+
{#if option.heading}
|
|
496
|
+
<div class="menu-heading" role="presentation">{option.label}</div>
|
|
497
|
+
{:else}
|
|
498
|
+
{#if option.toggle && option.emits}
|
|
499
|
+
{@const on = live && activeActions.includes(option.emits)}
|
|
500
|
+
<button type="button" class="menu-item" class:on role="menuitemcheckbox" aria-checked={on} onclick={() => pickOption(item, option)}>
|
|
501
|
+
{#if option.icon}<SvRibbonIcon name={option.icon} />{/if}
|
|
502
|
+
<span>{option.label}</span>
|
|
503
|
+
{#if option.keys}<span class="keys">{option.keys}</span>{/if}
|
|
504
|
+
</button>
|
|
505
|
+
{:else}
|
|
506
|
+
<button type="button" class="menu-item" role="menuitem" onclick={() => pickOption(item, option)}>
|
|
507
|
+
{#if option.icon}<SvRibbonIcon name={option.icon} />{/if}
|
|
508
|
+
<span>{option.label}</span>
|
|
509
|
+
{#if option.keys}<span class="keys">{option.keys}</span>{/if}
|
|
510
|
+
</button>
|
|
511
|
+
{/if}
|
|
512
|
+
{/if}
|
|
513
|
+
{/each}
|
|
514
|
+
</div>
|
|
515
|
+
{/snippet}
|
|
516
|
+
{#if item.split}
|
|
517
|
+
<!-- Excel's split button. Large: the icon runs the item and the label
|
|
518
|
+
with its chevron opens the entries. Small: face beside arrow, as
|
|
519
|
+
the colour menus. -->
|
|
520
|
+
<div class="split" class:split-large={item.size === 'large'} class:on={state.on} class:disabled={!state.enabled}>
|
|
521
|
+
<button
|
|
522
|
+
type="button"
|
|
523
|
+
class="btn face"
|
|
524
|
+
class:large={item.size === 'large'}
|
|
525
|
+
disabled={!state.enabled}
|
|
526
|
+
title={tip(item)}
|
|
527
|
+
aria-label={item.title}
|
|
528
|
+
onclick={() => run(item)}
|
|
529
|
+
>
|
|
530
|
+
{#if item.icon}
|
|
531
|
+
<SvRibbonIcon name={item.icon} size={item.size === 'large' ? 32 : 16} />
|
|
532
|
+
{:else}
|
|
533
|
+
<span class="glyph" data-glyph={item.id}>{item.label}</span>
|
|
534
|
+
{/if}
|
|
535
|
+
<!-- A small wide split (Merge & Center) prints its label on the face, as
|
|
536
|
+
Excel does; the colour splits stay icon-only. -->
|
|
537
|
+
{#if item.wide && item.size !== 'large'}<span class="label">{item.label}</span>{/if}
|
|
538
|
+
</button>
|
|
539
|
+
{#if live}
|
|
540
|
+
<SvPopover bind:open={menuOpen[item.id]} placement="bottom-start" arrow={false} offset={2} ariaLabel={item.title}>
|
|
541
|
+
{#snippet anchor()}
|
|
542
|
+
<button
|
|
543
|
+
type="button"
|
|
544
|
+
class="btn arrow"
|
|
545
|
+
disabled={!state.enabled}
|
|
546
|
+
aria-label={`${item.title} options`}
|
|
547
|
+
aria-haspopup="menu"
|
|
548
|
+
aria-expanded={menuOpen[item.id] ?? false}
|
|
549
|
+
>{#if item.size === 'large'}<span class="label">{item.label}</span>{/if}<SvRibbonIcon name="chevron-down" size={10} /></button>
|
|
550
|
+
{/snippet}
|
|
551
|
+
{@render entries()}
|
|
552
|
+
</SvPopover>
|
|
553
|
+
{:else}
|
|
554
|
+
<span class="btn arrow" aria-hidden="true">{#if item.size === 'large'}<span class="label">{item.label}</span>{/if}<SvRibbonIcon name="chevron-down" size={10} /></span>
|
|
555
|
+
{/if}
|
|
556
|
+
</div>
|
|
557
|
+
{:else if live}
|
|
558
|
+
<SvPopover bind:open={menuOpen[item.id]} placement="bottom-start" arrow={false} offset={2} ariaLabel={item.title}>
|
|
559
|
+
{#snippet anchor()}
|
|
560
|
+
{@render face()}
|
|
561
|
+
{/snippet}
|
|
562
|
+
{@render entries()}
|
|
563
|
+
</SvPopover>
|
|
564
|
+
{:else}
|
|
565
|
+
{@render face()}
|
|
566
|
+
{/if}
|
|
567
|
+
{/snippet}
|
|
568
|
+
|
|
569
|
+
{#snippet control(item: RibbonItem, live: boolean)}
|
|
570
|
+
{#if item.kind === 'select'}
|
|
571
|
+
{@const state = live ? stateOf(item) : { on: false, enabled: true }}
|
|
572
|
+
<select
|
|
573
|
+
class="select"
|
|
574
|
+
data-select={item.id}
|
|
575
|
+
title={tip(item)}
|
|
576
|
+
aria-label={item.title}
|
|
577
|
+
disabled={!state.enabled}
|
|
578
|
+
value={live ? currentOf(item) : ''}
|
|
579
|
+
onchange={(e) => run(item, e.currentTarget.value)}
|
|
580
|
+
>
|
|
581
|
+
{#if live && currentOf(item) && !item.options?.some((o) => o.value === currentOf(item))}
|
|
582
|
+
<!-- A value the list does not carry (a 17px title) still reads on the
|
|
583
|
+
face, the way Excel's combo shows whatever the cell has. -->
|
|
584
|
+
<option value={currentOf(item)}>{currentOf(item)}</option>
|
|
585
|
+
{/if}
|
|
586
|
+
{#each item.options ?? [] as option (option.value)}
|
|
587
|
+
<option value={option.value}>{option.value === '' && item.id === 'font-family' ? themeFont : option.label}</option>
|
|
588
|
+
{/each}
|
|
589
|
+
</select>
|
|
590
|
+
{:else if item.kind === 'menu'}
|
|
591
|
+
{@render split(item, live)}
|
|
592
|
+
{:else if item.kind === 'dropdown'}
|
|
593
|
+
{@render dropdown(item, live)}
|
|
594
|
+
{:else}
|
|
595
|
+
{@render plainButton(item, live)}
|
|
596
|
+
{/if}
|
|
597
|
+
{/snippet}
|
|
598
|
+
|
|
599
|
+
<!-- The controls of one group, in Excel's three-row block. -->
|
|
600
|
+
{#snippet body(group: RibbonGroup, live: boolean)}
|
|
601
|
+
{#if group.layout === 'flow'}
|
|
602
|
+
<div class="controls flow">
|
|
603
|
+
{#each [1, 2, 3] as row (row)}
|
|
604
|
+
{@const items = group.items.filter((i) => (i.row ?? 1) === row)}
|
|
605
|
+
{#if items.length}
|
|
606
|
+
<div class="row">
|
|
607
|
+
{#each items as item (item.id)}{@render control(item, live)}{/each}
|
|
608
|
+
</div>
|
|
609
|
+
{/if}
|
|
610
|
+
{/each}
|
|
611
|
+
</div>
|
|
612
|
+
{:else}
|
|
613
|
+
<div class="controls grid">
|
|
614
|
+
{#each placed(group.items) as { item, col } (item.id)}
|
|
615
|
+
<div class="cell" style:grid-row={item.size === 'large' ? '1 / span 3' : String(item.row ?? 1)} style:grid-column={col}>
|
|
616
|
+
{@render control(item, live)}
|
|
617
|
+
</div>
|
|
618
|
+
{/each}
|
|
619
|
+
</div>
|
|
620
|
+
{/if}
|
|
621
|
+
{/snippet}
|
|
622
|
+
|
|
623
|
+
<div class="sv-ribbon" class:collapsed role="toolbar" aria-label="Spreadsheet ribbon" aria-orientation="horizontal" bind:this={rootEl}>
|
|
624
|
+
<div class="tabs" role="tablist" aria-label="Ribbon tabs">
|
|
625
|
+
{#each tabs as entry (entry.id)}
|
|
626
|
+
<button
|
|
627
|
+
type="button"
|
|
628
|
+
role="tab"
|
|
629
|
+
class="tab"
|
|
630
|
+
class:active={entry.id === current?.id}
|
|
631
|
+
aria-selected={entry.id === current?.id}
|
|
632
|
+
tabindex={entry.id === current?.id ? 0 : -1}
|
|
633
|
+
onclick={() => onTabClick(entry.id)}
|
|
634
|
+
ondblclick={toggleCollapsed}
|
|
635
|
+
onkeydown={onTabKey}
|
|
636
|
+
>{entry.label}</button>
|
|
637
|
+
{/each}
|
|
638
|
+
<span class="tabs-grow"></span>
|
|
639
|
+
{#if collapsed && !peek}
|
|
640
|
+
<button type="button" class="ribbon-toggle" title="Expand the Ribbon (Ctrl+F1)" aria-label="Expand the Ribbon" aria-expanded="false" onclick={toggleCollapsed}>
|
|
641
|
+
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true"><path d="M2 3.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
642
|
+
</button>
|
|
643
|
+
{/if}
|
|
644
|
+
</div>
|
|
645
|
+
|
|
646
|
+
<div class="band" class:away={collapsed && !peek} class:peek={collapsed && peek} bind:clientWidth={bandWidth}>
|
|
647
|
+
{#each current?.groups ?? [] as group (group.id)}
|
|
648
|
+
{#if collapsedGroups.has(group.id)}
|
|
649
|
+
<section class="group folded" aria-label={group.label}>
|
|
650
|
+
<SvPopover bind:open={groupOpen[group.id]} placement="bottom-start" arrow={false} offset={4} ariaLabel={group.label}>
|
|
651
|
+
{#snippet anchor()}
|
|
652
|
+
<button type="button" class="btn large" aria-haspopup="true" aria-expanded={groupOpen[group.id] ?? false} title={group.label}>
|
|
653
|
+
<span class="fold-icon"><SvRibbonIcon name={groupIcon(group)} size={32} /></span>
|
|
654
|
+
<span class="label">{group.label} <SvRibbonIcon name="chevron-down" size={10} /></span>
|
|
655
|
+
</button>
|
|
656
|
+
{/snippet}
|
|
657
|
+
<div class="popup-group">
|
|
658
|
+
{@render body(group, true)}
|
|
659
|
+
<div class="label-row"><span class="group-label">{group.label}</span></div>
|
|
660
|
+
</div>
|
|
661
|
+
</SvPopover>
|
|
662
|
+
</section>
|
|
663
|
+
{:else}
|
|
664
|
+
<section class="group" class:compact={fit.compact.has(group.id)} aria-label={group.label}>
|
|
665
|
+
{@render body(group, true)}
|
|
666
|
+
<div class="label-row">
|
|
667
|
+
<span class="group-label">{group.label}</span>
|
|
668
|
+
{#if group.launcher}
|
|
669
|
+
<button type="button" class="launcher" title={`${group.label} settings`} aria-label={`${group.label} settings`} onclick={() => launch(group.launcher!)}>
|
|
670
|
+
<svg viewBox="0 0 8 8" width="8" height="8" aria-hidden="true"><path d="M1 1h3.5M1 1v3.5M1.5 1.5L6.5 6.5M6.5 3.5V6.5H3.5" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
671
|
+
</button>
|
|
672
|
+
{/if}
|
|
673
|
+
</div>
|
|
674
|
+
</section>
|
|
675
|
+
{/if}
|
|
676
|
+
{/each}
|
|
677
|
+
<!-- Excel's chevron at the band's end: collapse, or pin the peeked band. -->
|
|
678
|
+
<span class="band-grow"></span>
|
|
679
|
+
<button type="button" class="ribbon-toggle in-band" title={collapsed ? 'Pin the Ribbon (Ctrl+F1)' : 'Collapse the Ribbon (Ctrl+F1)'} aria-label={collapsed ? 'Pin the Ribbon' : 'Collapse the Ribbon'} onclick={toggleCollapsed}>
|
|
680
|
+
{#if collapsed}
|
|
681
|
+
<svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true"><path d="M4 1.5h4M6 1.5v3.5M3.5 5l2.5 2.5L8.5 5M6 7.5V11" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
682
|
+
{:else}
|
|
683
|
+
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true"><path d="M2 6.5l3-3 3 3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
684
|
+
{/if}
|
|
685
|
+
</button>
|
|
686
|
+
</div>
|
|
687
|
+
|
|
688
|
+
<!--
|
|
689
|
+
The measuring bands: every group of the current tab expanded, then
|
|
690
|
+
every group compact, invisible and out of the tab order, so the fit
|
|
691
|
+
above has both widths for each group even when it never got to draw
|
|
692
|
+
itself either way.
|
|
693
|
+
-->
|
|
694
|
+
<div class="measure-well" aria-hidden="true" inert>
|
|
695
|
+
<div class="band measure">
|
|
696
|
+
{#each current?.groups ?? [] as group (group.id)}
|
|
697
|
+
<section class="group" use:measure={group.id}>
|
|
698
|
+
{@render body(group, false)}
|
|
699
|
+
<div class="label-row"><span class="group-label">{group.label}</span></div>
|
|
700
|
+
</section>
|
|
701
|
+
{/each}
|
|
702
|
+
</div>
|
|
703
|
+
<div class="band measure">
|
|
704
|
+
{#each current?.groups ?? [] as group (group.id)}
|
|
705
|
+
<section class="group compact" use:measure={`${group.id}:compact`}>
|
|
706
|
+
{@render body(group, false)}
|
|
707
|
+
<div class="label-row"><span class="group-label">{group.label}</span></div>
|
|
708
|
+
</section>
|
|
709
|
+
{/each}
|
|
710
|
+
</div>
|
|
711
|
+
<div class="band measure">
|
|
712
|
+
{#each current?.groups ?? [] as group (group.id)}
|
|
713
|
+
<section class="group folded" use:measure={`${group.id}:folded`}>
|
|
714
|
+
<button type="button" class="btn large" tabindex="-1">
|
|
715
|
+
<span class="fold-icon"><SvRibbonIcon name={groupIcon(group)} size={32} /></span>
|
|
716
|
+
<span class="label">{group.label} <SvRibbonIcon name="chevron-down" size={10} /></span>
|
|
717
|
+
</button>
|
|
718
|
+
</section>
|
|
719
|
+
{/each}
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
</div>
|
|
723
|
+
|
|
724
|
+
<style>
|
|
725
|
+
.sv-ribbon {
|
|
726
|
+
position: relative;
|
|
727
|
+
display: flex;
|
|
728
|
+
flex-direction: column;
|
|
729
|
+
font-family: var(--sg-font, "Segoe UI", system-ui, sans-serif);
|
|
730
|
+
font-size: 12px;
|
|
731
|
+
line-height: 1.2;
|
|
732
|
+
background: var(--sg-bg-subtle, var(--sg-header-bg, #f3f3f3));
|
|
733
|
+
color: var(--sg-fg, #242424);
|
|
734
|
+
border: 1px solid var(--sg-border, #e0e0e0);
|
|
735
|
+
border-radius: var(--sg-radius, 4px);
|
|
736
|
+
}
|
|
737
|
+
/* Collapsed, the peeked band hangs below the tabs over the sheet: the
|
|
738
|
+
ribbon has to sit above the formula bar and the grid's sticky header
|
|
739
|
+
(z-index 30) for it to show. */
|
|
740
|
+
.sv-ribbon.collapsed { z-index: 70; }
|
|
741
|
+
|
|
742
|
+
/* ---- tabs ---------------------------------------------------------- */
|
|
743
|
+
.tabs {
|
|
744
|
+
display: flex;
|
|
745
|
+
align-items: stretch;
|
|
746
|
+
gap: 0;
|
|
747
|
+
height: 30px;
|
|
748
|
+
padding: 0 8px;
|
|
749
|
+
}
|
|
750
|
+
.tab {
|
|
751
|
+
position: relative;
|
|
752
|
+
font: inherit;
|
|
753
|
+
border: 0;
|
|
754
|
+
background: transparent;
|
|
755
|
+
color: var(--sg-fg, #242424);
|
|
756
|
+
padding: 0 11px;
|
|
757
|
+
cursor: pointer;
|
|
758
|
+
white-space: nowrap;
|
|
759
|
+
border-radius: 4px 4px 0 0;
|
|
760
|
+
}
|
|
761
|
+
.tab:hover { background: var(--sg-row-hover-bg, rgba(0, 0, 0, 0.04)); }
|
|
762
|
+
.tab.active { color: var(--sg-accent, #107c41); font-weight: 600; }
|
|
763
|
+
/* Excel's selected-tab mark: a short rounded underline, not a full-width
|
|
764
|
+
border, inset from the label's edges. */
|
|
765
|
+
.tab.active::after {
|
|
766
|
+
content: "";
|
|
767
|
+
position: absolute;
|
|
768
|
+
left: 9px;
|
|
769
|
+
right: 9px;
|
|
770
|
+
bottom: 0;
|
|
771
|
+
height: 3px;
|
|
772
|
+
border-radius: 3px 3px 0 0;
|
|
773
|
+
background: var(--sg-accent, #107c41);
|
|
774
|
+
}
|
|
775
|
+
.tab:focus-visible {
|
|
776
|
+
outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #107c41));
|
|
777
|
+
outline-offset: -2px;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/* ---- band ---------------------------------------------------------- */
|
|
781
|
+
/* Excel's band is a white card on the ribbon's grey ground, inset a few
|
|
782
|
+
pixels and rounded, with the groups divided by hairlines inside it. */
|
|
783
|
+
.band {
|
|
784
|
+
display: flex;
|
|
785
|
+
align-items: stretch;
|
|
786
|
+
margin: 0 6px 6px;
|
|
787
|
+
padding: 4px 4px 2px;
|
|
788
|
+
background: var(--sg-bg, #fff);
|
|
789
|
+
border: 1px solid var(--sg-border, #e0e0e0);
|
|
790
|
+
border-radius: var(--sg-radius-lg, 6px);
|
|
791
|
+
/* Seven folded groups are 472px; on a phone the band is narrower than
|
|
792
|
+
that, and a hidden overflow put Cells and Editing out of reach. The
|
|
793
|
+
band scrolls sideways instead, as Excel's does, without a scrollbar
|
|
794
|
+
stealing a row. */
|
|
795
|
+
overflow-x: auto;
|
|
796
|
+
overflow-y: hidden;
|
|
797
|
+
scrollbar-width: none;
|
|
798
|
+
}
|
|
799
|
+
.band::-webkit-scrollbar {
|
|
800
|
+
display: none;
|
|
801
|
+
}
|
|
802
|
+
/* The measuring bands live in a well of no size that clips them, so
|
|
803
|
+
their max-content widths never reach the page's scroll width. */
|
|
804
|
+
.measure-well {
|
|
805
|
+
position: absolute;
|
|
806
|
+
left: 0;
|
|
807
|
+
top: 0;
|
|
808
|
+
width: 0;
|
|
809
|
+
height: 0;
|
|
810
|
+
overflow: hidden;
|
|
811
|
+
visibility: hidden;
|
|
812
|
+
pointer-events: none;
|
|
813
|
+
}
|
|
814
|
+
.band.measure {
|
|
815
|
+
position: absolute;
|
|
816
|
+
left: 0;
|
|
817
|
+
top: 0;
|
|
818
|
+
width: max-content;
|
|
819
|
+
height: 0;
|
|
820
|
+
margin: 0;
|
|
821
|
+
padding-block: 0;
|
|
822
|
+
border: 0;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
.group {
|
|
826
|
+
display: flex;
|
|
827
|
+
flex-direction: column;
|
|
828
|
+
flex: 0 0 auto;
|
|
829
|
+
/* Three rows of controls and the label row: the band keeps this
|
|
830
|
+
height when every group has folded to a button. */
|
|
831
|
+
min-height: 92px;
|
|
832
|
+
padding: 0 4px;
|
|
833
|
+
border-right: 1px solid var(--sg-border, #e0e0e0);
|
|
834
|
+
}
|
|
835
|
+
.group:last-of-type { border-right: 0; }
|
|
836
|
+
/* Folded: one button the height of the group, its icon centred where the
|
|
837
|
+
controls sit and its label down on the group-label row, so the row of
|
|
838
|
+
labels reads straight across folded and open groups alike. */
|
|
839
|
+
.group.folded { padding: 0 2px; }
|
|
840
|
+
/* The popover's anchor wrapper sits between the group and its button;
|
|
841
|
+
it has to stretch for the button's 100% height to mean the group's. */
|
|
842
|
+
.group.folded :global(.sv-pop__anchor) { flex: 1 1 auto; display: flex; align-items: stretch; }
|
|
843
|
+
.group.folded :global(.btn.large) { height: 100%; padding-top: 0; padding-bottom: 0; gap: 0; }
|
|
844
|
+
.group.folded :global(.btn.large .fold-icon) { flex: 1 1 auto; display: flex; align-items: center; }
|
|
845
|
+
.group.folded :global(.btn.large .label) { height: 16px; padding-bottom: 1px; color: var(--sg-muted, #616161); }
|
|
846
|
+
.group.folded :global(.btn.large:hover .label) { color: inherit; }
|
|
847
|
+
/* Compact: the small buttons keep their icon and drop their label. */
|
|
848
|
+
.group.compact :global(.btn.wide:not(.large) .label) { display: none; }
|
|
849
|
+
.group.compact :global(.btn.wide:not(.large)) { justify-content: center; padding-right: 3px; }
|
|
850
|
+
.group.compact :global(.split .btn.face .label) { display: none; }
|
|
851
|
+
|
|
852
|
+
/* Collapsed: the tabs alone; peeked: the band floats over the sheet. */
|
|
853
|
+
/* Not "hidden": a host reset owns that name too. */
|
|
854
|
+
.band.away { display: none; }
|
|
855
|
+
.band.peek {
|
|
856
|
+
position: absolute;
|
|
857
|
+
left: 0;
|
|
858
|
+
right: 0;
|
|
859
|
+
top: 30px;
|
|
860
|
+
z-index: 60;
|
|
861
|
+
margin: 0;
|
|
862
|
+
border-radius: 0 0 var(--sg-radius-lg, 6px) var(--sg-radius-lg, 6px);
|
|
863
|
+
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
|
864
|
+
}
|
|
865
|
+
.tabs-grow, .band-grow { flex: 1 1 auto; }
|
|
866
|
+
/* Not "collapse": that is a visibility utility in a host's reset. */
|
|
867
|
+
.ribbon-toggle {
|
|
868
|
+
align-self: center;
|
|
869
|
+
width: 20px;
|
|
870
|
+
height: 20px;
|
|
871
|
+
padding: 0;
|
|
872
|
+
border: 1px solid transparent;
|
|
873
|
+
border-radius: 3px;
|
|
874
|
+
background: transparent;
|
|
875
|
+
color: var(--sg-muted, #616161);
|
|
876
|
+
cursor: pointer;
|
|
877
|
+
display: inline-flex;
|
|
878
|
+
align-items: center;
|
|
879
|
+
justify-content: center;
|
|
880
|
+
}
|
|
881
|
+
.ribbon-toggle.in-band { align-self: flex-end; margin: 0 2px 2px 2px; }
|
|
882
|
+
.ribbon-toggle:hover { background: var(--sg-row-hover-bg, #f0f0f0); border-color: var(--sg-border, #e0e0e0); color: var(--sg-fg, #242424); }
|
|
883
|
+
|
|
884
|
+
/* Three rows of 24px: what a large button fills and a small one takes one of. */
|
|
885
|
+
.controls {
|
|
886
|
+
flex: 1 0 auto;
|
|
887
|
+
min-height: 76px;
|
|
888
|
+
}
|
|
889
|
+
.controls.grid {
|
|
890
|
+
display: grid;
|
|
891
|
+
grid-template-rows: repeat(3, 24px);
|
|
892
|
+
grid-auto-columns: max-content;
|
|
893
|
+
grid-auto-flow: row;
|
|
894
|
+
align-content: start;
|
|
895
|
+
gap: 2px 3px;
|
|
896
|
+
}
|
|
897
|
+
.cell { display: flex; align-items: stretch; }
|
|
898
|
+
.cell > :global(*) { flex: 1 1 auto; }
|
|
899
|
+
.controls.flow {
|
|
900
|
+
display: flex;
|
|
901
|
+
flex-direction: column;
|
|
902
|
+
justify-content: space-evenly;
|
|
903
|
+
gap: 2px;
|
|
904
|
+
}
|
|
905
|
+
.controls.flow .row {
|
|
906
|
+
display: flex;
|
|
907
|
+
align-items: center;
|
|
908
|
+
gap: 2px;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
.label-row {
|
|
912
|
+
position: relative;
|
|
913
|
+
display: flex;
|
|
914
|
+
align-items: center;
|
|
915
|
+
justify-content: center;
|
|
916
|
+
height: 16px;
|
|
917
|
+
margin-top: 2px;
|
|
918
|
+
}
|
|
919
|
+
.group-label {
|
|
920
|
+
font-size: 10.5px;
|
|
921
|
+
color: var(--sg-muted, #616161);
|
|
922
|
+
white-space: nowrap;
|
|
923
|
+
padding: 0 8px;
|
|
924
|
+
}
|
|
925
|
+
/* The dialog box launcher, in the corner where Excel keeps it. */
|
|
926
|
+
.launcher {
|
|
927
|
+
position: absolute;
|
|
928
|
+
right: -4px;
|
|
929
|
+
bottom: 1px;
|
|
930
|
+
width: 14px;
|
|
931
|
+
height: 14px;
|
|
932
|
+
padding: 0;
|
|
933
|
+
border: 1px solid transparent;
|
|
934
|
+
border-radius: 3px;
|
|
935
|
+
background: transparent;
|
|
936
|
+
color: var(--sg-muted, #616161);
|
|
937
|
+
cursor: pointer;
|
|
938
|
+
display: inline-flex;
|
|
939
|
+
align-items: center;
|
|
940
|
+
justify-content: center;
|
|
941
|
+
}
|
|
942
|
+
.launcher:hover { background: var(--sg-row-hover-bg, #f0f0f0); border-color: var(--sg-border, #e0e0e0); color: var(--sg-fg, #242424); }
|
|
943
|
+
|
|
944
|
+
/* ---- buttons ------------------------------------------------------- */
|
|
945
|
+
.btn {
|
|
946
|
+
font: inherit;
|
|
947
|
+
display: inline-flex;
|
|
948
|
+
align-items: center;
|
|
949
|
+
justify-content: center;
|
|
950
|
+
gap: 5px;
|
|
951
|
+
min-width: 22px;
|
|
952
|
+
height: 24px;
|
|
953
|
+
padding: 0 2px;
|
|
954
|
+
border: 1px solid transparent;
|
|
955
|
+
border-radius: 4px;
|
|
956
|
+
background: transparent;
|
|
957
|
+
color: inherit;
|
|
958
|
+
cursor: pointer;
|
|
959
|
+
white-space: nowrap;
|
|
960
|
+
position: relative;
|
|
961
|
+
}
|
|
962
|
+
.btn.wide { justify-content: flex-start; padding-right: 6px; }
|
|
963
|
+
.btn:hover:not(:disabled) {
|
|
964
|
+
background: var(--sg-row-hover-bg, #f0f0f0);
|
|
965
|
+
border-color: var(--sg-border, #d1d1d1);
|
|
966
|
+
}
|
|
967
|
+
.btn:active:not(:disabled) { background: var(--sg-muted-bg, #e6e6e6); }
|
|
968
|
+
/* A pressed toggle: Excel tints it, it does not invert it. */
|
|
969
|
+
.btn.on,
|
|
970
|
+
.split.on .btn {
|
|
971
|
+
background: var(--sg-selection-bg, rgba(16, 124, 65, 0.12));
|
|
972
|
+
border-color: var(--sg-border, #d1d1d1);
|
|
973
|
+
}
|
|
974
|
+
.btn:disabled { opacity: 0.38; cursor: default; }
|
|
975
|
+
.btn:focus-visible {
|
|
976
|
+
outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #107c41));
|
|
977
|
+
outline-offset: -2px;
|
|
978
|
+
}
|
|
979
|
+
.btn.large {
|
|
980
|
+
flex-direction: column;
|
|
981
|
+
justify-content: flex-start;
|
|
982
|
+
gap: 3px;
|
|
983
|
+
height: 100%;
|
|
984
|
+
min-width: 40px;
|
|
985
|
+
padding: 5px 3px 2px;
|
|
986
|
+
}
|
|
987
|
+
.btn.large .label {
|
|
988
|
+
white-space: normal;
|
|
989
|
+
text-align: center;
|
|
990
|
+
line-height: 1.15;
|
|
991
|
+
max-width: 68px;
|
|
992
|
+
display: inline-flex;
|
|
993
|
+
flex-wrap: wrap;
|
|
994
|
+
justify-content: center;
|
|
995
|
+
align-items: center;
|
|
996
|
+
gap: 2px;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/* Typographic faces: B I U S read as their own styling, as Excel's do. */
|
|
1000
|
+
.glyph {
|
|
1001
|
+
display: inline-flex;
|
|
1002
|
+
align-items: center;
|
|
1003
|
+
justify-content: center;
|
|
1004
|
+
min-width: 16px;
|
|
1005
|
+
font-size: 13px;
|
|
1006
|
+
line-height: 1;
|
|
1007
|
+
}
|
|
1008
|
+
.glyph[data-glyph='bold'] { font-weight: 800; }
|
|
1009
|
+
.glyph[data-glyph='italic'] { font-style: italic; font-family: Georgia, "Times New Roman", serif; }
|
|
1010
|
+
.glyph[data-glyph='underline'] { text-decoration: underline; }
|
|
1011
|
+
.glyph[data-glyph='strike'] { text-decoration: line-through; }
|
|
1012
|
+
.glyph[data-glyph='text-colour'] { font-weight: 700; font-size: 14px; transform: translateY(-2px); }
|
|
1013
|
+
.glyph[data-glyph='fmt-currency'],
|
|
1014
|
+
.glyph[data-glyph='fmt-percent'],
|
|
1015
|
+
.glyph[data-glyph='fmt-number'] { font-weight: 600; }
|
|
1016
|
+
|
|
1017
|
+
/* ---- split buttons ------------------------------------------------- */
|
|
1018
|
+
.split {
|
|
1019
|
+
display: inline-flex;
|
|
1020
|
+
align-items: stretch;
|
|
1021
|
+
height: 24px;
|
|
1022
|
+
border-radius: 4px;
|
|
1023
|
+
}
|
|
1024
|
+
.split .face { border-radius: 4px 0 0 4px; padding: 0 3px; }
|
|
1025
|
+
.split .arrow {
|
|
1026
|
+
border-radius: 0 4px 4px 0;
|
|
1027
|
+
min-width: 12px;
|
|
1028
|
+
width: 12px;
|
|
1029
|
+
padding: 0;
|
|
1030
|
+
color: var(--sg-muted, #616161);
|
|
1031
|
+
}
|
|
1032
|
+
.split:hover:not(.disabled) .btn { border-color: var(--sg-border, #d1d1d1); }
|
|
1033
|
+
.split:hover:not(.disabled) .face { border-right-color: transparent; }
|
|
1034
|
+
.split .face.has-bar { padding-bottom: 4px; }
|
|
1035
|
+
/* Excel's colour bar under the icon: the colour the face will apply. */
|
|
1036
|
+
.bar {
|
|
1037
|
+
position: absolute;
|
|
1038
|
+
left: 3px;
|
|
1039
|
+
right: 3px;
|
|
1040
|
+
bottom: 2px;
|
|
1041
|
+
height: 3px;
|
|
1042
|
+
border-radius: 1px;
|
|
1043
|
+
}
|
|
1044
|
+
.bar.none {
|
|
1045
|
+
background: repeating-linear-gradient(90deg, var(--sg-muted, #8a8886) 0 3px, transparent 3px 5px) !important;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/* ---- selects ------------------------------------------------------- */
|
|
1049
|
+
.select {
|
|
1050
|
+
font: inherit;
|
|
1051
|
+
height: 22px;
|
|
1052
|
+
padding: 0 2px 0 5px;
|
|
1053
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #d1d1d1));
|
|
1054
|
+
border-radius: 3px;
|
|
1055
|
+
/* Not transparent: a native select paints its option list with this
|
|
1056
|
+
background, and a see-through one is unreadable on a dark theme. */
|
|
1057
|
+
background: var(--sg-input-bg, var(--sg-bg, #fff));
|
|
1058
|
+
color: var(--sg-fg, #242424);
|
|
1059
|
+
}
|
|
1060
|
+
.select[data-select='font-family'] { width: 112px; }
|
|
1061
|
+
.select[data-select='font-size'] { width: 46px; }
|
|
1062
|
+
.select[data-select='num-format'] { width: 108px; }
|
|
1063
|
+
.select:disabled { opacity: 0.38; }
|
|
1064
|
+
.select:focus-visible {
|
|
1065
|
+
outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #107c41));
|
|
1066
|
+
outline-offset: -1px;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/* ---- menus --------------------------------------------------------- */
|
|
1070
|
+
.menu {
|
|
1071
|
+
min-width: 200px;
|
|
1072
|
+
padding: 4px;
|
|
1073
|
+
}
|
|
1074
|
+
.menu-item {
|
|
1075
|
+
display: flex;
|
|
1076
|
+
align-items: center;
|
|
1077
|
+
gap: 10px;
|
|
1078
|
+
width: 100%;
|
|
1079
|
+
padding: 5px 10px;
|
|
1080
|
+
font: inherit;
|
|
1081
|
+
font-size: 12px;
|
|
1082
|
+
border: 0;
|
|
1083
|
+
border-radius: 3px;
|
|
1084
|
+
background: transparent;
|
|
1085
|
+
color: inherit;
|
|
1086
|
+
text-align: left;
|
|
1087
|
+
cursor: pointer;
|
|
1088
|
+
white-space: nowrap;
|
|
1089
|
+
}
|
|
1090
|
+
.menu-item:hover { background: var(--sg-row-hover-bg, #f0f0f0); }
|
|
1091
|
+
/* A toggle entry that is on (Lock Cell on a locked cell): the same tint
|
|
1092
|
+
as a pressed button, so the menu and the ribbon agree. */
|
|
1093
|
+
.menu-item.on { background: var(--sg-selection-bg, rgba(16, 124, 65, 0.12)); }
|
|
1094
|
+
.menu-item.on:hover { background: var(--sg-muted-bg, #e6e6e6); }
|
|
1095
|
+
.menu-item .keys {
|
|
1096
|
+
margin-left: auto;
|
|
1097
|
+
padding-left: 18px;
|
|
1098
|
+
color: var(--sg-muted, #616161);
|
|
1099
|
+
font-size: 11px;
|
|
1100
|
+
}
|
|
1101
|
+
/* Excel's section labels between a menu's groups: small caps, a rule above. */
|
|
1102
|
+
.menu-heading {
|
|
1103
|
+
margin: 4px 0 2px;
|
|
1104
|
+
padding: 6px 10px 2px;
|
|
1105
|
+
border-top: 1px solid var(--sg-border, #e1e1e1);
|
|
1106
|
+
color: var(--sg-muted, #616161);
|
|
1107
|
+
font-size: 11px;
|
|
1108
|
+
font-weight: 600;
|
|
1109
|
+
}
|
|
1110
|
+
.menu-heading:first-child { border-top: 0; margin-top: 0; padding-top: 2px; }
|
|
1111
|
+
/* The chevron sits close to the label, as Excel's does, so the face is a
|
|
1112
|
+
few pixels wider than the plain button and the band still fits 1050px
|
|
1113
|
+
with every group open. */
|
|
1114
|
+
.btn.dropdown { padding-right: 3px; }
|
|
1115
|
+
/* The large split: icon on top pastes, "Paste v" under it opens the menu. */
|
|
1116
|
+
.split.split-large {
|
|
1117
|
+
flex-direction: column;
|
|
1118
|
+
align-items: stretch;
|
|
1119
|
+
height: 100%;
|
|
1120
|
+
min-width: 46px;
|
|
1121
|
+
}
|
|
1122
|
+
.split.split-large .face {
|
|
1123
|
+
flex: 1 1 auto;
|
|
1124
|
+
border-radius: 4px 4px 0 0;
|
|
1125
|
+
justify-content: flex-start;
|
|
1126
|
+
padding: 5px 4px 0;
|
|
1127
|
+
}
|
|
1128
|
+
.split.split-large .arrow {
|
|
1129
|
+
width: auto;
|
|
1130
|
+
min-width: 0;
|
|
1131
|
+
height: auto;
|
|
1132
|
+
padding: 0 2px 3px;
|
|
1133
|
+
gap: 2px;
|
|
1134
|
+
border-radius: 0 0 4px 4px;
|
|
1135
|
+
color: inherit;
|
|
1136
|
+
font-size: 12px;
|
|
1137
|
+
line-height: 1.15;
|
|
1138
|
+
}
|
|
1139
|
+
.btn.dropdown .label { margin-right: -3px; }
|
|
1140
|
+
|
|
1141
|
+
.palette {
|
|
1142
|
+
width: 232px;
|
|
1143
|
+
padding: 8px;
|
|
1144
|
+
font-size: 12px;
|
|
1145
|
+
}
|
|
1146
|
+
.palette-none {
|
|
1147
|
+
display: flex;
|
|
1148
|
+
align-items: center;
|
|
1149
|
+
gap: 8px;
|
|
1150
|
+
width: 100%;
|
|
1151
|
+
padding: 4px 6px;
|
|
1152
|
+
margin-bottom: 4px;
|
|
1153
|
+
font: inherit;
|
|
1154
|
+
border: 0;
|
|
1155
|
+
border-radius: 3px;
|
|
1156
|
+
background: transparent;
|
|
1157
|
+
color: inherit;
|
|
1158
|
+
text-align: left;
|
|
1159
|
+
cursor: pointer;
|
|
1160
|
+
}
|
|
1161
|
+
.palette-none:hover { background: var(--sg-row-hover-bg, #f0f0f0); }
|
|
1162
|
+
.palette-title {
|
|
1163
|
+
font-size: 11px;
|
|
1164
|
+
font-weight: 600;
|
|
1165
|
+
color: var(--sg-muted, #616161);
|
|
1166
|
+
margin: 6px 0 4px;
|
|
1167
|
+
}
|
|
1168
|
+
.swatches {
|
|
1169
|
+
display: grid;
|
|
1170
|
+
grid-template-columns: repeat(10, 18px);
|
|
1171
|
+
gap: 0 4px;
|
|
1172
|
+
}
|
|
1173
|
+
/* The five shades under each theme colour stack touching, one column per
|
|
1174
|
+
colour; the base row stands a little apart above them, as in Excel. */
|
|
1175
|
+
.swatches .swatch:nth-child(-n + 10) { margin-bottom: 4px; }
|
|
1176
|
+
.swatches .swatch.shade { border-radius: 0; border-top-width: 0; }
|
|
1177
|
+
.swatches .swatch.shade:nth-child(n + 11):nth-child(-n + 20) { border-top-width: 1px; }
|
|
1178
|
+
.swatch {
|
|
1179
|
+
width: 18px;
|
|
1180
|
+
height: 18px;
|
|
1181
|
+
padding: 0;
|
|
1182
|
+
border: 1px solid rgba(0, 0, 0, 0.18);
|
|
1183
|
+
border-radius: 2px;
|
|
1184
|
+
cursor: pointer;
|
|
1185
|
+
}
|
|
1186
|
+
.swatch:hover { outline: 2px solid var(--sg-accent, #107c41); outline-offset: 1px; position: relative; z-index: 1; }
|
|
1187
|
+
.swatch.no-colour {
|
|
1188
|
+
cursor: default;
|
|
1189
|
+
background:
|
|
1190
|
+
linear-gradient(to top right,
|
|
1191
|
+
transparent calc(50% - 1px), var(--sg-danger, #dc2626) 50%,
|
|
1192
|
+
transparent calc(50% + 1px)),
|
|
1193
|
+
var(--sg-bg, #fff);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
.popup-group {
|
|
1197
|
+
display: flex;
|
|
1198
|
+
flex-direction: column;
|
|
1199
|
+
padding: 6px 8px 2px;
|
|
1200
|
+
}
|
|
1201
|
+
</style>
|