bo-grid 0.2.0 → 0.8.0

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.
@@ -2,6 +2,7 @@ import type { Snippet } from 'svelte';
2
2
  import type { ColumnDef, GridRow, SortState, CellEditEvent } from './column';
3
3
  import { type GridTheme } from './theme';
4
4
  import { type AggKind } from './aggregate';
5
+ import { type ColumnFilter } from './filtering';
5
6
  import type { RowSource } from './source';
6
7
  type $$ComponentProps = {
7
8
  rows: GridRow[];
@@ -21,8 +22,19 @@ type $$ComponentProps = {
21
22
  /** Called with the selected row ids whenever the row-selection set changes. */
22
23
  onRowSelectionChange?: (selectedIds: Array<string | number>) => void;
23
24
  /** Column keys to hide (controlled). Build your own column-picker UI and
24
- drive this prop — the grid stays presentation-only. */
25
+ drive this prop — the grid stays presentation-only. Composed (union) with
26
+ columns the user hides at runtime via the column menu. */
25
27
  hiddenColumns?: string[];
28
+ /** Called with all currently-hidden column keys whenever the runtime set
29
+ changes (column menu hide/show). */
30
+ onColumnVisibilityChange?: (hidden: string[]) => void;
31
+ /** Enable a per-column header menu (a ⋮ trigger) with sort, hide and (with
32
+ `filterMenu`) filter actions. Default false. */
33
+ columnMenu?: boolean;
34
+ /** Show a "Columns" button that opens a panel to toggle column visibility
35
+ (the place to restore columns hidden via the menu). Lazy-loaded. Default
36
+ false. */
37
+ columnsPanel?: boolean;
26
38
  /** Return extra CSS class(es) for a data row (e.g. to colour by value).
27
39
  Style them via `:global(.your-class)` since rows live inside the grid. */
28
40
  rowClass?: (row: GridRow) => string | undefined;
@@ -38,6 +50,12 @@ type $$ComponentProps = {
38
50
  sort?: SortState[];
39
51
  /** Called with the new sort order whenever a header is clicked. */
40
52
  onSortChange?: (sort: SortState[]) => void;
53
+ /** Controlled column filters (keyed by column key). When set, the grid
54
+ reflects these and reports changes via `onFilterChange` instead of holding
55
+ its own. Omit for uncontrolled filtering. */
56
+ columnFilters?: Record<string, ColumnFilter>;
57
+ /** Called with the full column-filter map whenever a header filter changes. */
58
+ onFilterChange?: (filters: Record<string, ColumnFilter>) => void;
41
59
  /** Show a pinned totals row: each column with a `groupAgg` shows that
42
60
  aggregate over all (filtered) rows. In-memory mode only. Default false. */
43
61
  footer?: boolean;
@@ -54,6 +72,20 @@ type $$ComponentProps = {
54
72
  /** Show a per-column filter input row under the header. Rows must match every
55
73
  non-empty column filter (AND). In-memory mode only. Default false. */
56
74
  filterRow?: boolean;
75
+ /** Enable a per-column header filter menu (lazy-loaded on first open). Each
76
+ filterable column shows a funnel; the menu's control matches the column
77
+ type (text/number/date). Override or disable per column with `col.filter`.
78
+ Works in source mode too (filters are delegated to the `RowSource`); set
79
+ filters need in-memory data. Default false. */
80
+ filterMenu?: boolean;
81
+ /** Show a built-in quick-filter search box above the grid that matches across
82
+ all column values (ANDed with the `filter` prop). In-memory mode only.
83
+ Default false. */
84
+ quickFilter?: boolean;
85
+ /** Show an Excel-style fill handle at the selection's bottom-right corner;
86
+ drag it to copy the selected value(s) across the extended range (editable
87
+ columns only). In-memory mode only. Default false. */
88
+ fillHandle?: boolean;
57
89
  /** Message shown when there are no rows. Default 'No matching rows'. */
58
90
  emptyMessage?: string;
59
91
  /** Show a loading overlay over the grid (for consumer-driven async work in
@@ -0,0 +1,117 @@
1
+ <script lang="ts">
2
+ // Floating columns panel: toggle column visibility (and restore hidden ones).
3
+ // Lazy-loaded by Grid; presentation-only — the parent owns the visibility set.
4
+ let {
5
+ columns,
6
+ hidden,
7
+ x,
8
+ y,
9
+ onToggle,
10
+ onShowAll,
11
+ onClose,
12
+ }: {
13
+ columns: Array<{ key: string; header: string }>;
14
+ hidden: string[];
15
+ x: number;
16
+ y: number;
17
+ onToggle: (key: string) => void;
18
+ onShowAll: () => void;
19
+ onClose: () => void;
20
+ } = $props();
21
+
22
+ let search = $state('');
23
+ const shown = $derived(
24
+ columns.filter((c) => c.header.toLowerCase().includes(search.trim().toLowerCase())),
25
+ );
26
+ </script>
27
+
28
+ <div
29
+ class="bo-toolpanel"
30
+ role="dialog"
31
+ tabindex="-1"
32
+ aria-label="Columns"
33
+ style="left:{x}px;top:{y}px;"
34
+ onpointerdown={(e) => e.stopPropagation()}
35
+ onkeydown={(e) => e.key === 'Escape' && onClose()}
36
+ >
37
+ <div class="bo-tp-head">
38
+ <span>Columns</span>
39
+ <button type="button" class="bo-tp-link" onclick={onShowAll}>Show all</button>
40
+ </div>
41
+ <input class="bo-tp-search" type="search" bind:value={search} placeholder="search…" aria-label="Search columns" />
42
+ <div class="bo-tp-list">
43
+ {#each shown as col (col.key)}
44
+ <label class="bo-tp-opt">
45
+ <input type="checkbox" checked={!hidden.includes(col.key)} onchange={() => onToggle(col.key)} />
46
+ <span>{col.header}</span>
47
+ </label>
48
+ {/each}
49
+ </div>
50
+ </div>
51
+
52
+ <style>
53
+ .bo-toolpanel {
54
+ position: fixed;
55
+ z-index: 30;
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: 7px;
59
+ width: 200px;
60
+ padding: 10px;
61
+ background: var(--bo-header-bg);
62
+ border: 0.5px solid var(--bo-border);
63
+ border-radius: 8px;
64
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
65
+ font-size: 12px;
66
+ color: var(--bo-text);
67
+ }
68
+ .bo-tp-head {
69
+ display: flex;
70
+ align-items: baseline;
71
+ justify-content: space-between;
72
+ font-weight: 600;
73
+ color: var(--bo-text-dim);
74
+ }
75
+ .bo-tp-link {
76
+ padding: 0;
77
+ font: inherit;
78
+ font-size: 11px;
79
+ color: var(--bo-up);
80
+ background: none;
81
+ border: 0;
82
+ cursor: pointer;
83
+ }
84
+ .bo-tp-link:hover {
85
+ text-decoration: underline;
86
+ }
87
+ .bo-tp-search {
88
+ width: 100%;
89
+ padding: 5px 7px;
90
+ font: inherit;
91
+ color: var(--bo-text);
92
+ background: var(--bo-bg);
93
+ border: 0.5px solid var(--bo-border);
94
+ border-radius: 5px;
95
+ }
96
+ .bo-tp-list {
97
+ display: flex;
98
+ flex-direction: column;
99
+ max-height: 220px;
100
+ overflow-y: auto;
101
+ }
102
+ .bo-tp-opt {
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 7px;
106
+ padding: 4px 4px;
107
+ cursor: pointer;
108
+ white-space: nowrap;
109
+ }
110
+ .bo-tp-opt:hover {
111
+ background: var(--bo-row-hover);
112
+ }
113
+ .bo-tp-opt span {
114
+ overflow: hidden;
115
+ text-overflow: ellipsis;
116
+ }
117
+ </style>
@@ -0,0 +1,15 @@
1
+ type $$ComponentProps = {
2
+ columns: Array<{
3
+ key: string;
4
+ header: string;
5
+ }>;
6
+ hidden: string[];
7
+ x: number;
8
+ y: number;
9
+ onToggle: (key: string) => void;
10
+ onShowAll: () => void;
11
+ onClose: () => void;
12
+ };
13
+ declare const ToolPanel: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type ToolPanel = ReturnType<typeof ToolPanel>;
15
+ export default ToolPanel;
@@ -1,6 +1,7 @@
1
1
  import type { Candle } from '../types';
2
2
  import { type DateStyle } from '../format/format';
3
3
  import type { AggKind } from './aggregate';
4
+ import type { FilterKind } from './filtering';
4
5
  export type Align = 'left' | 'right';
5
6
  interface ColBase {
6
7
  /** Field on the row to read for this column's value. */
@@ -52,6 +53,10 @@ interface ColBase {
52
53
  /** Parent header label. Consecutive columns sharing a `group` render under a
53
54
  spanning header. Best with fixed-width columns. */
54
55
  group?: string;
56
+ /** Header filter-menu control for this column (requires `filterMenu` on
57
+ <Grid>). Defaults to the column's type; `'set'` shows a value checklist;
58
+ `false` disables filtering for this column. */
59
+ filter?: false | FilterKind;
55
60
  }
56
61
  export interface CellEditEvent {
57
62
  row: GridRow;
@@ -24,7 +24,7 @@ export function isSortable(col) {
24
24
  return col.type !== 'sparkline' && col.sortable !== false;
25
25
  }
26
26
  export function isEditable(col) {
27
- return !!col.editable && col.type !== 'sparkline' && col.type !== 'date' && col.type !== 'custom';
27
+ return !!col.editable && col.type !== 'sparkline' && col.type !== 'custom';
28
28
  }
29
29
  function rawCompare(a, b) {
30
30
  if (typeof a === 'number' && typeof b === 'number')
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Structured per-column filter model (v0.3). Pure and dependency-free so it can
3
+ * be unit-tested and reused by both the in-memory `view` and a server source.
4
+ * The header filter-menu UI (lazy-loaded) writes these; `passesFilters` applies
5
+ * them. Filtering is a snapshot operation, not a per-frame one.
6
+ */
7
+ import type { ColumnDef, GridRow } from './column';
8
+ export type FilterKind = 'text' | 'number' | 'date' | 'set';
9
+ export type TextOp = 'contains' | 'notContains' | 'equals' | 'starts' | 'ends';
10
+ export type NumberOp = 'eq' | 'ne' | 'lt' | 'le' | 'gt' | 'ge' | 'between';
11
+ export type DateOp = 'before' | 'after' | 'on' | 'between';
12
+ export type ColumnFilter = {
13
+ kind: 'text';
14
+ op: TextOp;
15
+ q: string;
16
+ } | {
17
+ kind: 'number';
18
+ op: NumberOp;
19
+ a: number;
20
+ b?: number;
21
+ } | {
22
+ kind: 'date';
23
+ op: DateOp;
24
+ a: number;
25
+ b?: number;
26
+ } | {
27
+ kind: 'set';
28
+ excluded: string[];
29
+ };
30
+ /** Pick the default filter control for a column from its type. */
31
+ export declare function defaultFilterKind(col: ColumnDef): FilterKind;
32
+ /** A fresh, inactive filter of the given kind (the menu's starting state). */
33
+ export declare function emptyFilter(kind: FilterKind): ColumnFilter;
34
+ /** Whether a filter actually constrains anything (else it's a no-op). */
35
+ export declare function isFilterActive(f: ColumnFilter | undefined | null): boolean;
36
+ /** Does one cell value satisfy one filter? An inactive filter passes everything. */
37
+ export declare function matchesFilter(value: unknown, f: ColumnFilter): boolean;
38
+ /** AND across every active per-column filter. */
39
+ export declare function passesFilters(row: GridRow, filters: Record<string, ColumnFilter>): boolean;
40
+ /** Sorted unique string values for a column — the set-filter checklist. */
41
+ export declare function distinctValues(rows: readonly GridRow[], key: string): string[];
@@ -0,0 +1,107 @@
1
+ import { isNumeric } from './column';
2
+ const DAY = 86_400_000;
3
+ /** Pick the default filter control for a column from its type. */
4
+ export function defaultFilterKind(col) {
5
+ if (col.type === 'date')
6
+ return 'date';
7
+ if (isNumeric(col))
8
+ return 'number';
9
+ return 'text';
10
+ }
11
+ /** A fresh, inactive filter of the given kind (the menu's starting state). */
12
+ export function emptyFilter(kind) {
13
+ switch (kind) {
14
+ case 'number':
15
+ return { kind: 'number', op: 'eq', a: NaN };
16
+ case 'date':
17
+ return { kind: 'date', op: 'on', a: NaN };
18
+ case 'set':
19
+ return { kind: 'set', excluded: [] };
20
+ default:
21
+ return { kind: 'text', op: 'contains', q: '' };
22
+ }
23
+ }
24
+ /** Whether a filter actually constrains anything (else it's a no-op). */
25
+ export function isFilterActive(f) {
26
+ if (!f)
27
+ return false;
28
+ switch (f.kind) {
29
+ case 'text':
30
+ return f.q.trim().length > 0;
31
+ case 'number':
32
+ case 'date':
33
+ return Number.isFinite(f.a) && (f.op !== 'between' || Number.isFinite(f.b));
34
+ case 'set':
35
+ return f.excluded.length > 0;
36
+ }
37
+ }
38
+ /** Does one cell value satisfy one filter? An inactive filter passes everything. */
39
+ export function matchesFilter(value, f) {
40
+ if (!isFilterActive(f))
41
+ return true;
42
+ switch (f.kind) {
43
+ case 'text': {
44
+ const hay = String(value ?? '').toLowerCase();
45
+ const q = f.q.trim().toLowerCase();
46
+ if (f.op === 'contains')
47
+ return hay.includes(q);
48
+ if (f.op === 'notContains')
49
+ return !hay.includes(q);
50
+ if (f.op === 'equals')
51
+ return hay === q;
52
+ if (f.op === 'starts')
53
+ return hay.startsWith(q);
54
+ return hay.endsWith(q); // 'ends'
55
+ }
56
+ case 'set':
57
+ return !f.excluded.includes(String(value ?? ''));
58
+ case 'number':
59
+ case 'date': {
60
+ // Empty/blank cells aren't numbers (Number(null)/Number('') coerce to 0),
61
+ // so exclude them explicitly while a number/date filter is active.
62
+ if (value === null || value === undefined || value === '')
63
+ return false;
64
+ const n = Number(value);
65
+ if (!Number.isFinite(n))
66
+ return false; // non-numeric is excluded while active
67
+ if (f.kind === 'date' && f.op === 'on') {
68
+ return Math.floor(n / DAY) === Math.floor(f.a / DAY); // same (UTC) day
69
+ }
70
+ switch (f.op) {
71
+ case 'eq':
72
+ return n === f.a;
73
+ case 'ne':
74
+ return n !== f.a;
75
+ case 'lt':
76
+ case 'before':
77
+ return n < f.a;
78
+ case 'le':
79
+ return n <= f.a;
80
+ case 'gt':
81
+ case 'after':
82
+ return n > f.a;
83
+ case 'ge':
84
+ return n >= f.a;
85
+ case 'between':
86
+ return n >= f.a && n <= (f.b ?? Infinity);
87
+ }
88
+ }
89
+ }
90
+ return true; // unreachable fallback
91
+ }
92
+ /** AND across every active per-column filter. */
93
+ export function passesFilters(row, filters) {
94
+ for (const key in filters) {
95
+ const f = filters[key];
96
+ if (isFilterActive(f) && !matchesFilter(row[key], f))
97
+ return false;
98
+ }
99
+ return true;
100
+ }
101
+ /** Sorted unique string values for a column — the set-filter checklist. */
102
+ export function distinctValues(rows, key) {
103
+ const seen = new Set();
104
+ for (const row of rows)
105
+ seen.add(String(row[key] ?? ''));
106
+ return [...seen].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
107
+ }
@@ -1,4 +1,5 @@
1
1
  import type { GridRow, SortState } from './column';
2
+ import { type ColumnFilter } from './filtering';
2
3
  export interface RowRange {
3
4
  /** First row index (inclusive). */
4
5
  start: number;
@@ -13,6 +14,8 @@ export interface RowSourceParams {
13
14
  /** Full sort order (primary first) for multi-column sort. May be empty. */
14
15
  sorts?: SortState[];
15
16
  filter: string;
17
+ /** Structured per-column filters (header filter menu), keyed by column key. */
18
+ columnFilters?: Record<string, ColumnFilter>;
16
19
  }
17
20
  export interface RowSourceResult {
18
21
  /** Rows for the requested range, in order. */
@@ -1,4 +1,5 @@
1
1
  import { compareBySorts } from './column';
2
+ import { passesFilters } from './filtering';
2
3
  /**
3
4
  * Adapt an in-memory array to the RowSource interface — applies sort/filter and
4
5
  * slices the requested range. Useful as a real client-side adapter and for
@@ -12,6 +13,10 @@ export function createArraySource(all, opts = {}) {
12
13
  if (f && filterKeys && filterKeys.length > 0) {
13
14
  rows = rows.filter((r) => filterKeys.some((k) => String(r[k] ?? '').toLowerCase().includes(f)));
14
15
  }
16
+ const cf = params.columnFilters;
17
+ if (cf && Object.keys(cf).length > 0) {
18
+ rows = rows.filter((r) => passesFilters(r, cf));
19
+ }
15
20
  const sorts = params.sorts?.length ? params.sorts : params.sort ? [params.sort] : [];
16
21
  if (sorts.length > 0) {
17
22
  rows = [...rows].sort((a, b) => compareBySorts(a, b, sorts));
@@ -1,5 +1,6 @@
1
1
  import type { GridRow, SortState } from './column';
2
2
  import type { RowRange, RowSource } from './source';
3
+ import type { ColumnFilter } from './filtering';
3
4
  /**
4
5
  * Drives a RowSource for the grid: fetches the visible window, caches rows by
5
6
  * index, tracks the total, and guards against stale responses. Cache is keyed by
@@ -17,5 +18,5 @@ export declare class RowSourceController {
17
18
  constructor(source: RowSource);
18
19
  rowAt(index: number): GridRow | null;
19
20
  private keyOf;
20
- fetch(range: RowRange, sorts: SortState[], filter: string): Promise<void>;
21
+ fetch(range: RowRange, sorts: SortState[], filter: string, columnFilters?: Record<string, ColumnFilter>): Promise<void>;
21
22
  }
@@ -18,11 +18,12 @@ export class RowSourceController {
18
18
  rowAt(index) {
19
19
  return this.cache.get(index) ?? null;
20
20
  }
21
- keyOf(sorts, filter) {
22
- return `${sorts.map((s) => `${s.key}:${s.dir}`).join(',')}|${filter}`;
21
+ keyOf(sorts, filter, columnFilters) {
22
+ const cf = columnFilters ? JSON.stringify(columnFilters) : '';
23
+ return `${sorts.map((s) => `${s.key}:${s.dir}`).join(',')}|${filter}|${cf}`;
23
24
  }
24
- async fetch(range, sorts, filter) {
25
- const key = this.keyOf(sorts, filter);
25
+ async fetch(range, sorts, filter, columnFilters) {
26
+ const key = this.keyOf(sorts, filter, columnFilters);
26
27
  if (key !== this.key) {
27
28
  this.key = key;
28
29
  this.cache.clear();
@@ -40,7 +41,7 @@ export class RowSourceController {
40
41
  return;
41
42
  const id = ++this.reqId;
42
43
  this.loading = true;
43
- const res = await this.source.getRows({ range, sort: sorts[0] ?? null, sorts, filter });
44
+ const res = await this.source.getRows({ range, sort: sorts[0] ?? null, sorts, filter, columnFilters });
44
45
  if (id !== this.reqId)
45
46
  return; // a newer request superseded this one
46
47
  this.total = res.total;
@@ -20,6 +20,16 @@ export interface GridTheme {
20
20
  headerH?: string;
21
21
  mono?: string;
22
22
  sans?: string;
23
+ /** Native-control color scheme: themes checkboxes, date pickers, number
24
+ spinners, search-clear buttons and scrollbars. Defaults to dark. */
25
+ scheme?: 'light' | 'dark';
26
+ /** Outer corner radius (e.g. '8px', '0', '14px'). Default 8px. */
27
+ radius?: string;
28
+ /** Cell/row font size (e.g. '13px', '12px'). Default 13px. */
29
+ fontSize?: string;
30
+ /** Horizontal cell padding — the main density lever (e.g. '8px', '12px').
31
+ Pair with `rowHeight` for a fully compact or roomy look. Default 8px. */
32
+ cellPad?: string;
23
33
  }
24
34
  /** Serialize a theme to a CSS `--bo-grid-*: …;` declaration string. */
25
35
  export declare function themeVars(theme: GridTheme): string;
@@ -15,6 +15,10 @@ const VARS = {
15
15
  headerH: '--bo-grid-header-h',
16
16
  mono: '--bo-grid-mono',
17
17
  sans: '--bo-grid-sans',
18
+ scheme: '--bo-grid-scheme',
19
+ radius: '--bo-grid-radius',
20
+ fontSize: '--bo-grid-font-size',
21
+ cellPad: '--bo-grid-cell-pad',
18
22
  };
19
23
  /** Serialize a theme to a CSS `--bo-grid-*: …;` declaration string. */
20
24
  export function themeVars(theme) {
@@ -40,6 +44,7 @@ export const darkTheme = {
40
44
  amber: '#f59e0b',
41
45
  selFill: 'rgba(99,102,241,0.16)',
42
46
  selBorder: '#6366f1',
47
+ scheme: 'dark',
43
48
  };
44
49
  // A deliberate light palette (not an inverted dark one): near-white surfaces,
45
50
  // stronger green/red for contrast on light, subtle borders.
@@ -57,4 +62,5 @@ export const lightTheme = {
57
62
  amber: '#d97706',
58
63
  selFill: 'rgba(99,102,241,0.12)',
59
64
  selBorder: '#6366f1',
65
+ scheme: 'light',
60
66
  };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { default as Grid } from './grid/Grid.svelte';
2
2
  export { default as Sparkline } from './sparkline/Sparkline.svelte';
3
3
  export type { ColumnDef, Align, GridRow, SortDir, SortState, CellEditEvent } from './grid/column';
4
4
  export type { AggKind, AggResult } from './grid/aggregate';
5
+ export type { ColumnFilter, FilterKind, TextOp, NumberOp, DateOp } from './grid/filtering';
5
6
  export { fmtPrice, fmtPercent, fmtVolume, fmtDate } from './format/format';
6
7
  export type { DateStyle } from './format/format';
7
8
  export { aggregate } from './grid/aggregate';
@@ -16,7 +16,13 @@
16
16
  // sparklines run this, so the realtime feed never repaints off-screen charts.
17
17
  $effect(() => {
18
18
  const ctx = setupHiDpiCanvas(canvas, width, height);
19
- drawCandles(ctx, candles, width, height);
19
+ // Pull the grid's up/down colors so candles follow the active theme
20
+ // (inherited --bo-up/--bo-down; falls back to the built-in defaults).
21
+ const cs = getComputedStyle(canvas);
22
+ drawCandles(ctx, candles, width, height, {
23
+ up: cs.getPropertyValue('--bo-up').trim() || undefined,
24
+ down: cs.getPropertyValue('--bo-down').trim() || undefined,
25
+ });
20
26
  });
21
27
 
22
28
  const label = $derived(summarize(candles));
@@ -66,7 +72,7 @@
66
72
  font-family: var(--bo-mono, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace);
67
73
  font-size: 10px;
68
74
  color: var(--bo-text, #e5e5e5);
69
- background: #000;
75
+ background: var(--bo-header-bg, #0f0f0f);
70
76
  border: 0.5px solid var(--bo-border, rgba(255, 255, 255, 0.12));
71
77
  border-radius: 4px;
72
78
  pointer-events: none;
@@ -9,7 +9,10 @@ export declare function setupHiDpiCanvas(canvas: HTMLCanvasElement, cssW: number
9
9
  * Draw compact candlesticks. Body shows open→close, wick shows low→high.
10
10
  * Color by per-candle direction. Canvas (not SVG) so 100+ of these stay cheap.
11
11
  */
12
- export declare function drawCandles(ctx: CanvasRenderingContext2D, candles: Candle[], w: number, h: number): void;
12
+ export declare function drawCandles(ctx: CanvasRenderingContext2D, candles: Candle[], w: number, h: number, colors?: {
13
+ up?: string;
14
+ down?: string;
15
+ }): void;
13
16
  /** Screen-reader text — canvas alone is invisible to AT, so we narrate trend. */
14
17
  export declare function summarize(candles: Candle[]): string;
15
18
  /** Which candle index sits under an x offset (for hover tooltips). */
@@ -37,10 +37,12 @@ function yOf(price, ext, h, pad) {
37
37
  * Draw compact candlesticks. Body shows open→close, wick shows low→high.
38
38
  * Color by per-candle direction. Canvas (not SVG) so 100+ of these stay cheap.
39
39
  */
40
- export function drawCandles(ctx, candles, w, h) {
40
+ export function drawCandles(ctx, candles, w, h, colors = {}) {
41
41
  ctx.clearRect(0, 0, w, h);
42
42
  if (candles.length === 0)
43
43
  return;
44
+ const upColor = colors.up || UP;
45
+ const downColor = colors.down || DOWN;
44
46
  const ext = priceExtent(candles);
45
47
  const pad = 2;
46
48
  const n = candles.length;
@@ -50,8 +52,8 @@ export function drawCandles(ctx, candles, w, h) {
50
52
  const c = candles[i];
51
53
  const cx = i * slot + slot / 2;
52
54
  const up = c.close >= c.open;
53
- ctx.strokeStyle = up ? UP : DOWN;
54
- ctx.fillStyle = up ? UP : DOWN;
55
+ ctx.strokeStyle = up ? upColor : downColor;
56
+ ctx.fillStyle = up ? upColor : downColor;
55
57
  // wick
56
58
  ctx.beginPath();
57
59
  ctx.moveTo(Math.round(cx) + 0.5, yOf(c.high, ext, h, pad));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bo-grid",
3
- "version": "0.2.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Tiny, fast Svelte 5 data grid: canvas sparklines, batched realtime cell updates, and virtual scrolling. A free, fintech-focused alternative to heavyweight grids.",