@gntik-ai/blocks 0.8.0 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gntik-ai/blocks",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "gntik-ui blocks: page chrome, data display, tables, forms, feedback, billing, team and builder sections composed from @gntik-ai/ui.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "@gntik-ai/flow": "0.5.0",
41
41
  "@gntik-ai/icons": "0.5.0",
42
42
  "@gntik-ai/tokens": "0.2.1",
43
- "@gntik-ai/ui": "0.7.0",
43
+ "@gntik-ai/ui": "0.9.0",
44
44
  "lucide-react": "^1.48.0"
45
45
  },
46
46
  "devDependencies": {
@@ -95,3 +95,14 @@ describe('LogViewer', () => {
95
95
  expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument();
96
96
  });
97
97
  });
98
+
99
+ it.each([
100
+ ['Función', 'funcion', 'Función'],
101
+ ['İstanbul', 'stan', 'stan'],
102
+ ])('keeps and marks the original substring of %s', async (message, query, marked) => {
103
+ const user = userEvent.setup();
104
+ render(<LogViewer lines={[{ ...makeLogLines(1)[0]!, message, source: undefined }]} />);
105
+ await user.type(screen.getByRole('searchbox', { name: 'Search logs' }), query);
106
+ expect(renderedLines()).toHaveLength(1);
107
+ expect([...screen.getByRole('log').querySelectorAll('mark')].map((mark) => mark.textContent)).toEqual([marked]);
108
+ });
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState, type ReactNode, type UIEvent } from 'react';
2
2
  import { Check, Copy, Eye, Search } from '@gntik-ai/icons';
3
- import { IconButton, Input, Switch, Toggle, ToggleGroup, cn } from '@gntik-ai/ui';
3
+ import { IconButton, Input, Switch, Toggle, ToggleGroup, cn, foldText, matchRanges, useI18n } from '@gntik-ai/ui';
4
4
  import { logLines as defaultLines, type LogLevel, type LogLine } from './fixtures';
5
5
 
6
6
  export type { LogLevel, LogLine } from './fixtures';
@@ -43,24 +43,19 @@ export interface LogViewerProps {
43
43
  }
44
44
 
45
45
  /** Wraps case-insensitive matches of `query` in <mark>. */
46
- function highlight(text: string, query: string): ReactNode {
47
- if (!query) return text;
48
- const lower = text.toLowerCase();
49
- const q = query.toLowerCase();
46
+ function highlight(text: string, query: string, locale?: string): ReactNode {
50
47
  const parts: ReactNode[] = [];
51
48
  let from = 0;
52
- let at = lower.indexOf(q, from);
53
- while (at !== -1) {
54
- if (at > from) parts.push(text.slice(from, at));
49
+ for (const [start, end] of matchRanges(text, query, { locale })) {
50
+ parts.push(text.slice(from, start));
55
51
  parts.push(
56
- <mark key={at} className="rounded-sm bg-warning/30 text-foreground">
57
- {text.slice(at, at + q.length)}
52
+ <mark key={start} className="rounded-sm bg-warning/30 text-foreground">
53
+ {text.slice(start, end)}
58
54
  </mark>,
59
55
  );
60
- from = at + q.length;
61
- at = lower.indexOf(q, from);
56
+ from = end;
62
57
  }
63
- if (from < text.length) parts.push(text.slice(from));
58
+ parts.push(text.slice(from));
64
59
  return parts;
65
60
  }
66
61
 
@@ -88,6 +83,7 @@ export function LogViewer({
88
83
  titleAs: TitleTag = 'h3',
89
84
  className,
90
85
  }: LogViewerProps) {
86
+ const { locale } = useI18n();
91
87
  const titleId = useId();
92
88
  const viewport = useRef<HTMLDivElement>(null);
93
89
  const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@@ -103,14 +99,24 @@ export function LogViewer({
103
99
  return c;
104
100
  }, [lines]);
105
101
 
102
+ const foldedLines = useMemo(
103
+ () =>
104
+ lines.map((line) => ({
105
+ line,
106
+ message: foldText(line.message, locale),
107
+ source: foldText(line.source ?? '', locale),
108
+ })),
109
+ [lines, locale],
110
+ );
106
111
  const visible = useMemo(() => {
107
- const q = query.trim().toLowerCase();
108
- return lines.filter(
109
- (l) =>
110
- levels.includes(l.level) &&
111
- (!q || l.message.toLowerCase().includes(q) || (l.source ?? '').toLowerCase().includes(q)),
112
- );
113
- }, [lines, levels, query]);
112
+ const q = foldText(query.trim(), locale);
113
+ return foldedLines
114
+ .filter(
115
+ ({ line, message, source }) =>
116
+ levels.includes(line.level) && (!q || message.indexOf(q) !== -1 || source.indexOf(q) !== -1),
117
+ )
118
+ .map(({ line }) => line);
119
+ }, [foldedLines, levels, query, locale]);
114
120
 
115
121
  const total = visible.length * rowHeight;
116
122
  const maxScroll = Math.max(0, total - height);
@@ -213,11 +219,11 @@ export function LogViewer({
213
219
  </span>
214
220
  {line.source && (
215
221
  <span className="hidden shrink-0 text-muted-foreground sm:inline">
216
- [{highlight(line.source, query.trim())}]
222
+ [{highlight(line.source, query, locale)}]
217
223
  </span>
218
224
  )}
219
225
  <span className="min-w-0 flex-1 truncate text-foreground">
220
- {highlight(line.message, query.trim())}
226
+ {highlight(line.message, query, locale)}
221
227
  </span>
222
228
  {onSelectLine && (
223
229
  <IconButton
@@ -1,3 +1,5 @@
1
+ import Spanish from './examples/Spanish';
2
+ import { I18nProvider } from '@gntik-ai/ui';
1
3
  import { render, screen } from '@testing-library/react';
2
4
  import userEvent from '@testing-library/user-event';
3
5
  import { createRef } from 'react';
@@ -34,6 +36,15 @@ describe('PageHeader', () => {
34
36
  expect(screen.getByRole('heading', { level: 1 })).not.toHaveAttribute('tabindex');
35
37
  });
36
38
 
39
+ it('names the tablist from the active Spanish catalog', () => {
40
+ render(
41
+ <I18nProvider locale="es">
42
+ <PageHeader />
43
+ </I18nProvider>,
44
+ );
45
+ expect(screen.getByRole('tablist', { name: 'Secciones de la página' })).toBeInTheDocument();
46
+ });
47
+
37
48
  it('renders the default header', async () => {
38
49
  const { container } = render(<PageHeader />);
39
50
  expect(screen.getByRole('heading', { level: 1, name: 'Deployments' })).toBeInTheDocument();
@@ -66,3 +77,10 @@ describe('PageHeader', () => {
66
77
  expect(onTabChange).toHaveBeenCalledWith('domains');
67
78
  });
68
79
  });
80
+
81
+ it.each(['dark', 'light', 'high_contrast'])('Spanish PageHeader example is axe clean (%s)', async (theme) => {
82
+ document.documentElement.className = theme === 'light' ? '' : theme;
83
+ const { container } = render(<Spanish />);
84
+ expect(screen.getByRole('tablist', { name: 'Secciones de la página' })).toBeInTheDocument();
85
+ await expectNoAxeViolations(container);
86
+ });
@@ -7,6 +7,7 @@ import {
7
7
  TabsList,
8
8
  TabsTab,
9
9
  cn,
10
+ useI18n,
10
11
  type BreadcrumbItem,
11
12
  type StatusDefinition,
12
13
  } from '@gntik-ai/ui';
@@ -74,6 +75,7 @@ export function PageHeader({
74
75
  onTabChange,
75
76
  className,
76
77
  }: PageHeaderProps) {
78
+ const { t } = useI18n();
77
79
  const explicitPrimary = actions.findIndex((a) => a.variant === 'primary');
78
80
  const primaryIndex = explicitPrimary >= 0 ? explicitPrimary : actions.length - 1;
79
81
  const primary = actions[primaryIndex];
@@ -138,7 +140,7 @@ export function PageHeader({
138
140
  onValueChange={(value) => onTabChange?.(String(value))}
139
141
  className="mt-5"
140
142
  >
141
- <TabsList aria-label="Page sections" className="overflow-x-auto">
143
+ <TabsList aria-label={t('pageHeader.sections')} className="overflow-x-auto">
142
144
  {tabs.map((t) => (
143
145
  <TabsTab key={t.value} value={t.value} count={t.count}>
144
146
  {t.label}
@@ -5,6 +5,6 @@ export const meta: BlockMeta = {
5
5
  family: 'page-chrome',
6
6
  status: 'beta',
7
7
  description:
8
- 'Screen header with breadcrumb, title and status, description, meta row, actions that fold into a MoreMenu on small screens, and optional navigation tabs. titleRef targets the h1 for titleRef.current.focus(), adding tabIndex=-1 only when supplied so the title stays outside the Tab order. In a StatusLayout heading slot use as="div" and disable breadcrumb, meta, tabs, actions, status and description fixtures; the slot supplies the page’s only h1.',
8
+ 'Screen header with breadcrumb, title and status, description, meta row, actions that fold into a MoreMenu on small screens, and optional navigation tabs. titleRef targets the h1 for titleRef.current.focus(), adding tabIndex=-1 only when supplied so the title stays outside the Tab order. In a StatusLayout heading slot use as="div" and disable breadcrumb, meta, tabs, actions, status and description fixtures; the slot supplies the page’s only h1. The tablist name follows I18nProvider (en/es); the Spanish preview keeps caller content unchanged.',
9
9
  uses: ['Breadcrumbs', 'Button', 'MoreMenu', 'StatusTag', 'Tabs'],
10
10
  };
@@ -0,0 +1,11 @@
1
+ import { I18nProvider } from '@gntik-ai/ui';
2
+ import { PageHeader } from '../PageHeader';
3
+
4
+ /** Caller-supplied content is unchanged; the tablist name follows the active catalog. */
5
+ export default function Spanish() {
6
+ return (
7
+ <I18nProvider locale="es">
8
+ <PageHeader />
9
+ </I18nProvider>
10
+ );
11
+ }
@@ -233,6 +233,8 @@ it('replaces every pin option and the single-selection heading without translati
233
233
  const user = userEvent.setup({ pointerEventsCheck: 0 });
234
234
  const full: DataTableLabels = {
235
235
  ...labels,
236
+ booleanTrue: 'Sí',
237
+ booleanFalse: 'No',
236
238
  selectGroup: (group) => `Grupo ${group}`,
237
239
  groupRows: (count) => `${count} filas`,
238
240
  noGroup: 'Sin grupo',
@@ -0,0 +1,153 @@
1
+ import { I18nProvider } from '@gntik-ai/ui';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { expectNoAxeViolations } from '../../test/a11y';
5
+ import { DataTable } from './DataTable';
6
+ import type { CellValue, DataTableColumn } from './data-table-utils';
7
+
8
+ type Row = { id: string; value: CellValue };
9
+ const columns: DataTableColumn<Row>[] = [
10
+ { id: 'value', header: 'Value', accessor: (row) => row.value, sortable: true },
11
+ ];
12
+ const rowsOf = (...values: CellValue[]): Row[] => values.map((value, i) => ({ id: String(i), value }));
13
+ const cellTexts = () => screen.getAllByRole('cell').map((cell) => cell.textContent);
14
+
15
+ describe('DataTable default cell locale', () => {
16
+ it('reads boolean labels from the Spanish catalog', () => {
17
+ render(
18
+ <I18nProvider locale="es">
19
+ <DataTable rows={rowsOf(true, false)} columns={columns} selectable={false} paginated={false} />
20
+ </I18nProvider>,
21
+ );
22
+ expect(cellTexts()).toEqual(['Sí', 'No']);
23
+ });
24
+
25
+ it.each(['es', 'en'])('prefers per-instance boolean labels under %s', (locale) => {
26
+ render(
27
+ <I18nProvider locale={locale}>
28
+ <DataTable
29
+ rows={rowsOf(true, false)}
30
+ columns={columns}
31
+ selectable={false}
32
+ paginated={false}
33
+ labels={{ booleanTrue: 'On', booleanFalse: 'Off' }}
34
+ />
35
+ </I18nProvider>,
36
+ );
37
+ expect(cellTexts()).toEqual(['On', 'Off']);
38
+ });
39
+
40
+ it.each([
41
+ ['es', '17/10/2026'],
42
+ ['en-US', '10/17/2026'],
43
+ ])('formats valid dates and handles invalid dates under %s', (locale, expected) => {
44
+ render(
45
+ <I18nProvider locale={locale}>
46
+ <DataTable
47
+ rows={rowsOf(new Date(2026, 9, 17), new Date('x'))}
48
+ columns={columns}
49
+ selectable={false}
50
+ paginated={false}
51
+ />
52
+ </I18nProvider>,
53
+ );
54
+ expect(cellTexts()).toEqual([expected, '—']);
55
+ });
56
+
57
+ it.each([
58
+ ['es', ['1234,5', '1234567', '0,30000000000000004', 'NaN', 'Infinity', '-Infinity']],
59
+ ['en', ['1234.5', '1234567', '0.30000000000000004', 'NaN', 'Infinity', '-Infinity']],
60
+ ] as const)('keeps number digits and only localizes the decimal separator under %s', (locale, expected) => {
61
+ render(
62
+ <I18nProvider locale={locale}>
63
+ <DataTable
64
+ rows={rowsOf(1234.5, 1234567, 0.30000000000000004, NaN, Infinity, -Infinity)}
65
+ columns={columns}
66
+ selectable={false}
67
+ paginated={false}
68
+ />
69
+ </I18nProvider>,
70
+ );
71
+ expect(cellTexts()).toEqual(expected);
72
+ });
73
+
74
+ it('keeps English boolean labels under an English provider', () => {
75
+ render(
76
+ <I18nProvider locale="en">
77
+ <DataTable rows={rowsOf(true, false)} columns={columns} selectable={false} paginated={false} />
78
+ </I18nProvider>,
79
+ );
80
+ expect(cellTexts()).toEqual(['Yes', 'No']);
81
+ });
82
+
83
+ it('uses provider catalog overrides with individual label fallbacks', () => {
84
+ render(
85
+ <I18nProvider locale="es" messages={{ 'common.yes': 'Affirmative', 'common.no': 'Negative' }}>
86
+ <DataTable
87
+ rows={rowsOf(true, false)}
88
+ columns={columns}
89
+ selectable={false}
90
+ paginated={false}
91
+ labels={{ booleanTrue: 'On' }}
92
+ />
93
+ </I18nProvider>,
94
+ );
95
+ expect(cellTexts()).toEqual(['On', 'Negative']);
96
+ });
97
+
98
+ it('keeps runtime defaults outside a provider', () => {
99
+ const date = new Date(2026, 9, 17);
100
+ render(
101
+ <DataTable
102
+ rows={rowsOf(true, false, date, 1234.5, 1234567, 0.30000000000000004)}
103
+ columns={columns}
104
+ selectable={false}
105
+ paginated={false}
106
+ />,
107
+ );
108
+ expect(cellTexts()).toEqual(['Yes', 'No', date.toLocaleDateString(), '1234.5', '1234567', '0.30000000000000004']);
109
+ });
110
+
111
+ it('sorts raw numeric accessor values while showing Spanish decimals', async () => {
112
+ const user = userEvent.setup();
113
+ render(
114
+ <I18nProvider locale="es">
115
+ <DataTable rows={rowsOf(10.1, 2.2, 2.11)} columns={columns} selectable={false} paginated={false} />
116
+ </I18nProvider>,
117
+ );
118
+ await user.click(screen.getByRole('button', { name: 'Value' }));
119
+ expect(cellTexts()).toEqual(['2,11', '2,2', '10,1']);
120
+ await user.click(screen.getByRole('button', { name: 'Value' }));
121
+ expect(cellTexts()).toEqual(['10,1', '2,2', '2,11']);
122
+ });
123
+
124
+ it('leaves custom cell renderers in control', () => {
125
+ render(
126
+ <I18nProvider locale="es">
127
+ <DataTable
128
+ rows={rowsOf(true, new Date('x'), 1234.5)}
129
+ columns={[{ ...columns[0]!, cell: () => 'Custom value' }]}
130
+ selectable={false}
131
+ paginated={false}
132
+ />
133
+ </I18nProvider>,
134
+ );
135
+ expect(cellTexts()).toEqual(['Custom value', 'Custom value', 'Custom value']);
136
+ });
137
+
138
+ it.each(['dark', 'light', 'high_contrast'])('has no structural axe violations in %s', async (theme) => {
139
+ document.documentElement.className = theme;
140
+ const { container } = render(
141
+ <I18nProvider locale="es">
142
+ <DataTable
143
+ rows={rowsOf(true, false, new Date(2026, 9, 17), new Date('x'), 1234.5)}
144
+ columns={columns}
145
+ caption="Values"
146
+ selectable={false}
147
+ paginated={false}
148
+ />
149
+ </I18nProvider>,
150
+ );
151
+ await expectNoAxeViolations(container);
152
+ });
153
+ });
@@ -97,7 +97,7 @@ describe('DataTable controlled server pagination', () => {
97
97
  const onPageSizeChange = vi.fn();
98
98
  render(<DataTable {...server} onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} />);
99
99
  await user.click(screen.getByRole('combobox', { name: 'Rows per page' }));
100
- await user.click(screen.getByRole('option', { name: '50' }));
100
+ await user.click(await screen.findByRole('option', { name: '50' }));
101
101
  expect(onPageSizeChange.mock.calls).toEqual([[50]]);
102
102
  expect(onPageChange.mock.calls).toEqual([[1]]);
103
103
  expect(renderedIds()).toHaveLength(20);
@@ -5,6 +5,7 @@ import { expectNoAxeViolations } from '../../test/a11y';
5
5
  import { DataTable } from './DataTable';
6
6
  import {
7
7
  compareValues,
8
+ formatCell,
8
9
  groupRows,
9
10
  paginate,
10
11
  selectionState,
@@ -142,6 +143,21 @@ describe('DataTable', () => {
142
143
  });
143
144
 
144
145
  describe('data-table-utils', () => {
146
+ it('preserves one-argument default cell values', () => {
147
+ expect(formatCell(null)).toBe('—');
148
+ expect(formatCell(undefined)).toBe('—');
149
+ expect(formatCell(true)).toBe('Yes');
150
+ expect(formatCell(false)).toBe('No');
151
+ const date = new Date(2026, 9, 17);
152
+ expect(formatCell(date)).toBe(date.toLocaleDateString());
153
+ expect(formatCell(new Date('x'))).toBe('Invalid Date');
154
+ expect(formatCell(1234.5)).toBe(1234.5);
155
+ expect(formatCell(0.30000000000000004)).toBe(0.30000000000000004);
156
+ expect(formatCell(NaN)).toBeNaN();
157
+ expect(formatCell(Infinity)).toBe(Infinity);
158
+ expect(formatCell('Text')).toBe('Text');
159
+ });
160
+
145
161
  type Row = { id: string; n: number | null };
146
162
  const cols: DataTableColumn<Row>[] = [{ id: 'n', header: 'N', accessor: (r) => r.n }];
147
163
  const rows: Row[] = [
@@ -12,6 +12,8 @@ export const meta: BlockMeta = {
12
12
  'Opt-in paginationMode="server" accepts a controlled 1-based page, pageSize and required rowCount (known total), with onPageChange/onPageSizeChange requests. Supplied rows render in order without client sorting or slicing, and headers have no sort affordance. Single selection cannot use server mode. The footer stays visible for empty results, clamps stale pages for display without callbacks, and hides during loading. Page-size changes may also request a page to keep the first visible row on screen. The consumer owns fetching and reconciles stale pages. See examples/server-pagination.tsx for an items/total/page/pageSize envelope consumer that handles both callbacks.',
13
13
 
14
14
  labels: [
15
+ { key: 'booleanTrue', type: 'string', state: 'Default cell text for a true boolean value' },
16
+ { key: 'booleanFalse', type: 'string', state: 'Default cell text for a false boolean value' },
15
17
  {
16
18
  key: 'scrollRegion',
17
19
  type: 'string',
@@ -1,5 +1,15 @@
1
1
  import { ChevronRight } from '@gntik-ai/icons';
2
- import { Checkbox, cn, MoreMenu, TableCell, TableHead, TableRow, type MoreMenuAction, useI18n } from '@gntik-ai/ui';
2
+ import {
3
+ Checkbox,
4
+ cn,
5
+ MoreMenu,
6
+ TableCell,
7
+ TableHead,
8
+ TableRow,
9
+ type MoreMenuAction,
10
+ useI18n,
11
+ useOptionalI18n,
12
+ } from '@gntik-ai/ui';
3
13
  import type { CSSProperties, ReactNode } from 'react';
4
14
  import { stickyTint, type TableLayout } from './column-sizing';
5
15
  import {
@@ -68,6 +78,7 @@ export function DataTableRow<T>({
68
78
  style,
69
79
  }: DataTableRowProps<T>) {
70
80
  const { t } = useI18n();
81
+ const locale = useOptionalI18n()?.locale;
71
82
  const id = getRowId(row);
72
83
  const selectionColumn = selectable || singleSelection != null;
73
84
  const isSelected = singleSelection ? singleSelection.selectedId === id : selected.has(id);
@@ -95,7 +106,12 @@ export function DataTableRow<T>({
95
106
  )}
96
107
  {columns.map((c) => {
97
108
  const cl = layout?.columns.get(c.id);
98
- const content = c.cell ? c.cell(row) : formatCell(c.accessor?.(row));
109
+ const content = c.cell
110
+ ? c.cell(row)
111
+ : formatCell(c.accessor?.(row), {
112
+ locale,
113
+ booleanLabels: [labels?.booleanTrue ?? t('common.yes'), labels?.booleanFalse ?? t('common.no')],
114
+ });
99
115
  const editable = onCellEdit && editHintId && isCellEditable(c, row);
100
116
  return (
101
117
  <TableCell
@@ -147,11 +147,25 @@ export function toggleOne(id: string, selected: ReadonlySet<string>): Set<string
147
147
  return next;
148
148
  }
149
149
 
150
- /** Default cell text for a raw value. */
151
- export function formatCell(value: CellValue): ReactNode {
150
+ /** Optional locale and boolean text for default cells. */
151
+ export interface DataTableCellFormatOptions {
152
+ locale?: string;
153
+ booleanLabels?: [trueLabel: string, falseLabel: string];
154
+ }
155
+
156
+ /** Default cell text for a raw value. Omitting options preserves the legacy output. */
157
+ export function formatCell(value: CellValue, options?: DataTableCellFormatOptions): ReactNode {
152
158
  if (value == null) return '—';
153
- if (value instanceof Date) return value.toLocaleDateString();
154
- if (typeof value === 'boolean') return value ? 'Yes' : 'No';
159
+ if (value instanceof Date) {
160
+ if (!options) return value.toLocaleDateString();
161
+ if (Number.isNaN(value.getTime())) return '—';
162
+ return new Intl.DateTimeFormat(options.locale, { year: 'numeric', month: 'numeric', day: 'numeric' }).format(value);
163
+ }
164
+ if (typeof value === 'boolean') return (options?.booleanLabels ?? ['Yes', 'No'])[value ? 0 : 1];
165
+ if (typeof value === 'number' && Number.isFinite(value) && options?.locale !== undefined) {
166
+ const decimal = new Intl.NumberFormat(options.locale).formatToParts(1.1).find((part) => part.type === 'decimal');
167
+ return String(value).replace('.', decimal?.value ?? '.');
168
+ }
155
169
  return value;
156
170
  }
157
171
 
@@ -2,6 +2,8 @@ import type { PaginationFooterLabels } from '../PaginationFooter/PaginationFoote
2
2
 
3
3
  /** Per-instance table chrome, including the composed PaginationFooter. Caller data stays caller-owned. */
4
4
  export interface DataTableLabels extends PaginationFooterLabels {
5
+ booleanTrue: string;
6
+ booleanFalse: string;
5
7
  /** Non-virtual table overflow region; virtualized regions keep the caller caption. */
6
8
  scrollRegion: string;
7
9
  selectionCount: (count: number) => string;