Select the types of activity you want to include in your feed.
Merge pull request 'feat(sheets): fit-and-finish — auto-format, color palette, color sync, horizontal fill (v0.58.0)' (#405) from feat/v0.58.0-sheets-fit-finish into main
···88## [Unreleased]
991010### Added
1111+- Sheets fit-and-finish (v0.58.0) — four Excel/Sheets-parity wins for daily spreadsheet work:
1212+ - **Auto-format on entry**: typing `$1,234.56`, `75%`, `2025-03-15`, or `1,234` in a cell now stores the parsed numeric value and stamps an appropriate format (`currency`, `percent`, `date`, `number`) instead of keeping the raw string. Preserves existing cell formats so explicit user choices aren't clobbered. New `src/sheets/auto-format.ts` module with 30 unit tests.
1313+ - **Preset color palette**: both the text-color and background-color pickers now have a ▾ dropdown beside the native `<input type="color">` showing 14 curated swatches (black/grays/white + 9 accent colors in a 7×2 grid). The native picker still works; the palette short-circuits it for the common case. New `src/sheets/color-palette.ts` module with 15 jsdom tests covering render, open/close, click-outside, and Escape.
1414+ - **Toolbar color sync**: selecting a styled cell now reflects its `s.color` and `s.bg` values in the toolbar color pickers (both the `<input>` value and the visible swatch). Previously the toolbar always showed black/white regardless of the cell's actual colors. New `updateColorPickerState()` in `toolbar-wiring.ts`, called on every selection change; normalizes 3-char hex and passes through non-hex (oklch, rgb) verbatim. 8 new jsdom tests.
1515+ - **Horizontal drag-to-fill**: the fill handle now supports left/right drag in addition to up/down. Axis is picked by whichever direction is further out of the source range (mirrors Excel/Sheets). Arithmetic sequences, text repeat, and formula column references all propagate correctly; `adjustFormulaRef` is called with `dCol` instead of `dRow` on the horizontal branch. Auto-scroll near the grid's left/right edges added. 8 new jsdom tests pin the contract (axis detection, value propagation, formula dCol adjustment, post-fill selection range). (#117)
1116- A11y: modal focus trap — `modalPrompt`/`modalConfirm` now wrap Tab/Shift+Tab within the dialog so keyboard users cannot tab out to background elements while a modal is open. A new exported `handleFocusTrap(event, dialog)` helper wraps focus between first/last focusable children and pulls focus back into the dialog if it escapes (e.g. user tabs to a background link). 8 new jsdom tests pin the contract (Tab wrap, Shift+Tab wrap, escape recovery, mid-tab no-op, non-Tab no-op, integration with modalPrompt + modalConfirm). Closes the last major keyboard-accessibility gap in the shared modal helper. (#690)
1217- A11y: landing-page icon-only buttons (theme toggle, user badge, search clear) now carry `aria-label` so screen readers can announce them — previously only `title` was set, which is unreliable for AT. The user badge also gets `role="button"` + `tabindex="0"` so keyboard users can reach it. Landing modals (username, folder, move) now declare `aria-labelledby` pointing at their title heading so modal opens announce context. (#690)
1318- A11y: slides Notes/Animations panel tabs now carry the full tablist semantics — `role="tablist"` on the container, `role="tab"` + `aria-selected` + `aria-controls` on each button, and `role="tabpanel"` + `aria-labelledby` on each content pane. The click handler keeps `aria-selected` in sync when tabs switch. 2 new jsdom regression tests pin the invariant. (#690)
···11+/**
22+ * Auto-format — detect + parse cell entries into a value + format pair.
33+ *
44+ * When a user types "$10", "10%", or "2025-03-15" into a sheet cell, the
55+ * intent is almost always to create a formatted number (currency, percentage,
66+ * date) rather than a raw string. This module centralises that detection so
77+ * `commitEdit()` applies the matching style flag and stores a numeric value
88+ * that participates in formulas correctly.
99+ *
1010+ * This runs ONLY when the target cell has no explicit user-set format, so
1111+ * typing "5" into a currency-formatted cell still lands as a plain number and
1212+ * the existing format wins. Users who want a literal string like "$hi" get
1313+ * it naturally: the regex requires digits.
1414+ */
1515+1616+/** Recognised auto-format kinds. `undefined` = no auto-format applied. */
1717+export type AutoFormatKind = 'currency' | 'percent' | 'date' | 'number' | undefined;
1818+1919+export interface AutoFormatResult {
2020+ /** Parsed value. Currency/percent/date produce numbers. Falls back to raw. */
2121+ value: string | number;
2222+ /** Format flag to set on cell `s.format`, or undefined to leave alone. */
2323+ format: AutoFormatKind;
2424+}
2525+2626+const CURRENCY_RE = /^([$€£¥])\s*(-?\d[\d,]*(?:\.\d+)?)$/;
2727+const PERCENT_RE = /^(-?\d+(?:\.\d+)?)%$/;
2828+const THOUSANDS_NUMBER_RE = /^-?\d{1,3}(?:,\d{3})+(?:\.\d+)?$/;
2929+const ISO_DATE_RE = /^\d{4}-\d{1,2}-\d{1,2}$/;
3030+const SLASH_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/;
3131+3232+/**
3333+ * Inspect a raw user-entered string and return a parsed value + format hint.
3434+ *
3535+ * Empty strings and formulas are passed through unchanged — the caller handles
3636+ * those earlier. Anything not matching a known pattern returns with
3737+ * `format: undefined` so the caller can apply its existing numeric-coercion
3838+ * fallback.
3939+ */
4040+export function detectAndParseEntry(raw: string): AutoFormatResult {
4141+ const trimmed = raw.trim();
4242+ if (trimmed === '') return { value: '', format: undefined };
4343+4444+ // Currency: "$1,234.56", "€100", "£-50"
4545+ const currencyMatch = trimmed.match(CURRENCY_RE);
4646+ if (currencyMatch) {
4747+ const num = Number(currencyMatch[2]!.replace(/,/g, ''));
4848+ if (!isNaN(num)) return { value: num, format: 'currency' };
4949+ }
5050+5151+ // Percent: "75%", "-12.5%"
5252+ const percentMatch = trimmed.match(PERCENT_RE);
5353+ if (percentMatch) {
5454+ const num = Number(percentMatch[1]!) / 100;
5555+ if (!isNaN(num)) return { value: num, format: 'percent' };
5656+ }
5757+5858+ // ISO date: "2025-03-15"
5959+ if (ISO_DATE_RE.test(trimmed)) {
6060+ const parsed = Date.parse(trimmed);
6161+ if (!isNaN(parsed)) return { value: parsed, format: 'date' };
6262+ }
6363+6464+ // Slash date: "3/15/2025" or "3/15/25"
6565+ if (SLASH_DATE_RE.test(trimmed)) {
6666+ const parsed = Date.parse(trimmed);
6767+ if (!isNaN(parsed)) return { value: parsed, format: 'date' };
6868+ }
6969+7070+ // Thousands-separated number: "1,234", "1,234.56"
7171+ if (THOUSANDS_NUMBER_RE.test(trimmed)) {
7272+ const num = Number(trimmed.replace(/,/g, ''));
7373+ if (!isNaN(num)) return { value: num, format: 'number' };
7474+ }
7575+7676+ // Plain number: let the caller's existing Number()-check handle it, but we
7777+ // still strip the format slot so the cell keeps whatever format it had.
7878+ return { value: raw, format: undefined };
7979+}
+32-4
src/sheets/cell-editing.ts
···99import { clearGridHighlights } from './range-highlight.js';
1010import { hideTooltip } from './formula-tooltip.js';
1111import { parseDateValue, showDatePicker } from './date-picker.js';
1212+import { detectAndParseEntry } from './auto-format.js';
12131314// ── Types ───────────────────────────────────────────────────
1415···104105 setCellData(id, { v: '', f: raw.slice(1) });
105106 } else {
106107 const existingData = getCellData(id);
107107- const numVal = Number(raw);
108108- let value: string | number = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw);
108108+ const existingFormat = existingData?.s?.format;
109109+ let value: string | number;
110110+ let autoFormat: string | undefined;
111111+112112+ // Auto-detect format for fresh entries ("$10" → currency, "75%" → percent,
113113+ // "2025-03-15" → date). Skip when the cell already has a user-set format
114114+ // so "5" typed into a currency cell stays a plain number.
115115+ if (!existingFormat) {
116116+ const detected = detectAndParseEntry(raw);
117117+ value = detected.value;
118118+ autoFormat = detected.format;
119119+ } else {
120120+ const numVal = Number(raw);
121121+ value = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw);
122122+ }
123123+124124+ // If no auto-format fired and no existing format, fall back to numeric
125125+ // coercion so "42" still lands as number 42.
126126+ if (autoFormat === undefined && typeof value === 'string' && value !== '') {
127127+ const numVal = Number(value);
128128+ if (!isNaN(numVal) && value.trim() !== '') value = numVal;
129129+ }
130130+109131 // If cell has date format and user typed a date string, parse it back to timestamp
110110- if (typeof value === 'string' && value !== '' && existingData?.s?.format === 'date') {
132132+ if (typeof value === 'string' && value !== '' && existingFormat === 'date') {
111133 const parsed = Date.parse(value);
112134 if (!isNaN(parsed)) value = parsed;
113135 }
114114- setCellData(id, { v: value, f: '' });
136136+137137+ if (autoFormat) {
138138+ const existingStyle = existingData?.s || {};
139139+ setCellData(id, { v: value, f: '', s: { ...existingStyle, format: autoFormat } });
140140+ } else {
141141+ setCellData(id, { v: value, f: '' });
142142+ }
115143 }
116144 input.remove();
117145 }
+103
src/sheets/color-palette.ts
···11+/**
22+ * Color Palette — preset color grid for the text-color and bg-color pickers.
33+ *
44+ * The native `<input type="color">` opens a full OS/browser picker which is
55+ * overkill when users usually just want one of ~15 reasonable defaults.
66+ * This module renders a small grid of presets above the picker, mirroring
77+ * the pattern used in Google Sheets / Excel / Docs.
88+ */
99+1010+/**
1111+ * Curated list of common spreadsheet colors. Kept minimal (14 entries so the
1212+ * grid renders 7×2) and theme-neutral so the same palette works in light and
1313+ * dark mode. Users who need something else can still use the native picker.
1414+ */
1515+export const PRESET_COLORS: ReadonlyArray<{ name: string; hex: string }> = [
1616+ { name: 'Black', hex: '#000000' },
1717+ { name: 'Dark gray', hex: '#4d4d4d' },
1818+ { name: 'Gray', hex: '#999999' },
1919+ { name: 'Light gray', hex: '#e6e6e6' },
2020+ { name: 'White', hex: '#ffffff' },
2121+ { name: 'Red', hex: '#e53935' },
2222+ { name: 'Orange', hex: '#fb8c00' },
2323+ { name: 'Yellow', hex: '#fdd835' },
2424+ { name: 'Green', hex: '#43a047' },
2525+ { name: 'Teal', hex: '#00acc1' },
2626+ { name: 'Blue', hex: '#1e88e5' },
2727+ { name: 'Indigo', hex: '#3949ab' },
2828+ { name: 'Purple', hex: '#8e24aa' },
2929+ { name: 'Pink', hex: '#d81b60' },
3030+];
3131+3232+export interface RenderPaletteDeps {
3333+ /** Called with the hex value when a user clicks a preset. */
3434+ onSelect: (hex: string) => void;
3535+}
3636+3737+/**
3838+ * Populate a `.tb-color-palette` container with preset swatches.
3939+ * Safe to call repeatedly — rebuilds from scratch each time.
4040+ */
4141+export function renderColorPalette(container: HTMLElement, deps: RenderPaletteDeps): void {
4242+ container.innerHTML = '';
4343+ for (const { name, hex } of PRESET_COLORS) {
4444+ const btn = document.createElement('button');
4545+ btn.type = 'button';
4646+ btn.className = 'tb-color-palette-swatch';
4747+ btn.style.background = hex;
4848+ btn.dataset.color = hex;
4949+ btn.title = name;
5050+ btn.setAttribute('aria-label', name);
5151+ btn.setAttribute('role', 'menuitem');
5252+ btn.addEventListener('click', () => deps.onSelect(hex));
5353+ container.appendChild(btn);
5454+ }
5555+}
5656+5757+/** Show the palette container. */
5858+export function openPalette(container: HTMLElement, toggle: HTMLElement): void {
5959+ container.hidden = false;
6060+ toggle.setAttribute('aria-expanded', 'true');
6161+}
6262+6363+/** Hide the palette container. */
6464+export function closePalette(container: HTMLElement, toggle: HTMLElement): void {
6565+ container.hidden = true;
6666+ toggle.setAttribute('aria-expanded', 'false');
6767+}
6868+6969+/**
7070+ * Wire a toggle button + palette container pair. The toggle opens/closes the
7171+ * palette; clicking a preset applies it and closes the palette; clicking
7272+ * outside the palette closes it.
7373+ */
7474+export function wireColorPalette(opts: {
7575+ toggle: HTMLElement;
7676+ container: HTMLElement;
7777+ onSelect: (hex: string) => void;
7878+}): void {
7979+ const { toggle, container, onSelect } = opts;
8080+ renderColorPalette(container, {
8181+ onSelect: (hex) => {
8282+ onSelect(hex);
8383+ closePalette(container, toggle);
8484+ },
8585+ });
8686+ toggle.addEventListener('click', (e) => {
8787+ e.stopPropagation();
8888+ if (container.hidden) openPalette(container, toggle);
8989+ else closePalette(container, toggle);
9090+ });
9191+ // Close when clicking outside.
9292+ document.addEventListener('click', (e) => {
9393+ if (container.hidden) return;
9494+ const target = e.target as HTMLElement;
9595+ if (target === toggle || toggle.contains(target)) return;
9696+ if (container.contains(target)) return;
9797+ closePalette(container, toggle);
9898+ });
9999+ // Close on Escape.
100100+ document.addEventListener('keydown', (e) => {
101101+ if (e.key === 'Escape' && !container.hidden) closePalette(container, toggle);
102102+ });
103103+}