mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 10:33:22 +00:00
Filter table with client side selection (#4616)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Narrow a table or card grid to the reader's content selection: choosing an option in a tab or a select button now filters any select column that offers it. The table says what it has been narrowed to, and offers a Clear that drops the selection.
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
} from './search';
|
||||
import { shouldShowTableSearch } from './shouldShowSearch';
|
||||
import { StickyViewGrid } from './StickyViewGrid';
|
||||
import { TableSearchEmpty, TableSearchInput, TableSearchProvider } from './TableSearch';
|
||||
import {
|
||||
TableSearchEmpty,
|
||||
TableSearchInput,
|
||||
TableSearchProvider,
|
||||
TableSelectionFilter,
|
||||
} from './TableSearch';
|
||||
import { ViewCards } from './ViewCards';
|
||||
import { ViewGrid, ViewGridHeader } from './ViewGrid';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -48,9 +53,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
searchOverride: block.data.search,
|
||||
isPrint: context.mode === 'print',
|
||||
});
|
||||
const searchRecords = showSearch
|
||||
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
|
||||
: [];
|
||||
const selectColumns = getTableSelectColumns(block);
|
||||
// Also needed when the search bar is hidden: a reader's content selection can narrow a select
|
||||
// column from a tab or picker elsewhere on the page, and without records there is nothing to
|
||||
// match against. A table with no select column can never be narrowed that way, so it skips.
|
||||
const searchRecords =
|
||||
showSearch || selectColumns.length > 0
|
||||
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
|
||||
: [];
|
||||
const cellMergeLayout = createTableCellMergeLayout(
|
||||
block,
|
||||
records.map(([recordId]) => recordId)
|
||||
@@ -59,6 +69,7 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
return (
|
||||
<TableSearchProvider
|
||||
records={searchRecords}
|
||||
selectColumns={selectColumns}
|
||||
recordGroups={
|
||||
block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined
|
||||
}
|
||||
@@ -66,10 +77,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
<div className={tcls(style, 'flex flex-col gap-3')}>
|
||||
{showSearch ? (
|
||||
<TableSearchInput
|
||||
selectColumns={getTableSelectColumns(block)}
|
||||
selectColumns={selectColumns}
|
||||
checkboxColumns={getTableCheckboxColumns(block)}
|
||||
/>
|
||||
) : null}
|
||||
{/* Sits under the filter control it relates to, and tight against the search bar,
|
||||
so it reads as part of the filter controls rather than a caption on the table.
|
||||
Standalone — cards, short grids — there is no control to sit under. */}
|
||||
<TableSelectionFilter className={showSearch ? '-mt-1.5 justify-end' : undefined} />
|
||||
<TableView
|
||||
{...props}
|
||||
isOffscreen={isOffscreen}
|
||||
|
||||
@@ -10,8 +10,16 @@ import {
|
||||
type TableSearchRecordData,
|
||||
getVisibleTableRecordIds,
|
||||
} from './searchMatch';
|
||||
import {
|
||||
type SlugFilterEntry,
|
||||
getAppliedSlugFilter,
|
||||
reconcileSelectedOptions,
|
||||
resolveSlugFilter,
|
||||
slugFilterKey,
|
||||
} from './slugFilter';
|
||||
import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { selectStore } from '@/lib/select';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
@@ -37,6 +45,13 @@ type TableSearchContextValue = {
|
||||
visibleIds: ReadonlySet<string> | null;
|
||||
/** True when there are records but the active filters match none of them. */
|
||||
isEmpty: boolean;
|
||||
/**
|
||||
* Select columns the reader's content selection narrowed and the reader has left alone.
|
||||
* A column they have since filtered themselves drops out, since the table no longer shows it.
|
||||
*/
|
||||
slugFilter: SlugFilterEntry[];
|
||||
/** Drop the selection driving {@link slugFilter}, site-wide. The only thing that clears one. */
|
||||
clearSlugFilter: () => void;
|
||||
};
|
||||
|
||||
const TableSearchContext = React.createContext<TableSearchContextValue | null>(null);
|
||||
@@ -47,16 +62,32 @@ const TableSearchContext = React.createContext<TableSearchContextValue | null>(n
|
||||
export function TableSearchProvider(props: {
|
||||
records?: TableSearchRecordData[];
|
||||
recordGroups?: readonly (readonly string[])[];
|
||||
/** Select columns of this table, so the reader's content selection can narrow them. */
|
||||
selectColumns?: TableSelectColumn[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { records = [], recordGroups = [] } = props;
|
||||
const { records = [], recordGroups = [], selectColumns = [] } = props;
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({}));
|
||||
const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
// The selection that drives this lives outside the table — a tab, a select button or a picker
|
||||
// elsewhere on the page — so this synchronises with it rather than deriving from it.
|
||||
const slugFilter = useSlugFilter(selectColumns);
|
||||
|
||||
// Columns the selection narrowed last time round. Kept so a new selection — or clearing it —
|
||||
// undoes the previous one, rather than leaving a filter the reader can no longer account for.
|
||||
const narrowedColumns = React.useRef<string[]>([]);
|
||||
|
||||
const toggleOption = React.useCallback((column: string, value: string) => {
|
||||
// The reader is taking this column over, so the selection no longer owns it: a later clear
|
||||
// must leave their choice of options alone. Their change stays local — the filter is this
|
||||
// table's, for this visit, while the selection is site-wide and persists, so only the
|
||||
// clear beside the notice touches it.
|
||||
narrowedColumns.current = narrowedColumns.current.filter((narrowed) => narrowed !== column);
|
||||
|
||||
setSelectedOptions((previous) => {
|
||||
const values = new Set(previous[column]);
|
||||
if (values.has(value)) {
|
||||
@@ -87,6 +118,33 @@ export function TableSearchProvider(props: {
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const previouslyNarrowed = narrowedColumns.current;
|
||||
narrowedColumns.current = slugFilter.map((entry) => entry.column);
|
||||
|
||||
setSelectedOptions((previous) =>
|
||||
reconcileSelectedOptions(previous, previouslyNarrowed, slugFilter)
|
||||
);
|
||||
}, [slugFilter]);
|
||||
|
||||
// What the notice may speak for: the columns the selection narrowed and the reader has left
|
||||
// alone. A column they have since filtered themselves still has an active slug, but the table
|
||||
// is no longer showing it, so the notice must not claim it.
|
||||
const appliedSlugFilter = React.useMemo(
|
||||
() => getAppliedSlugFilter(slugFilter, selectedOptions),
|
||||
[slugFilter, selectedOptions]
|
||||
);
|
||||
|
||||
// Clearing goes through the store rather than local state: the selection is what persists, so
|
||||
// only dropping it there stops the filter coming back on the next load. It is site-wide, so a
|
||||
// tab elsewhere on the page reverts to its default too. This is the only thing that clears a
|
||||
// selection — changing the filter never does.
|
||||
const clearSlugFilter = React.useCallback(() => {
|
||||
for (const entry of appliedSlugFilter) {
|
||||
selectStore.deactivate(entry.slug);
|
||||
}
|
||||
}, [appliedSlugFilter]);
|
||||
|
||||
// Match every record once, here, rather than in each row — rows just look themselves up by id.
|
||||
const visibleIds = React.useMemo(
|
||||
() =>
|
||||
@@ -112,8 +170,20 @@ export function TableSearchProvider(props: {
|
||||
toggleCheckbox,
|
||||
visibleIds,
|
||||
isEmpty,
|
||||
slugFilter: appliedSlugFilter,
|
||||
clearSlugFilter,
|
||||
}),
|
||||
[query, selectedOptions, toggleOption, checkedColumns, toggleCheckbox, visibleIds, isEmpty]
|
||||
[
|
||||
query,
|
||||
selectedOptions,
|
||||
toggleOption,
|
||||
checkedColumns,
|
||||
toggleCheckbox,
|
||||
visibleIds,
|
||||
isEmpty,
|
||||
appliedSlugFilter,
|
||||
clearSlugFilter,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -121,6 +191,40 @@ export function TableSearchProvider(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader's selection, reduced to the columns of *this* table.
|
||||
*
|
||||
* Subscribes once and returns a string rather than an object: `useSyncExternalStore` compares
|
||||
* snapshots by identity, so a fresh object each call would loop. It also means a selection that
|
||||
* changes nothing for this table re-renders nothing — the reason `useSelect` stopped exposing the
|
||||
* recency list in the first place.
|
||||
*/
|
||||
function useSlugFilter(selectColumns: TableSelectColumn[]): SlugFilterEntry[] {
|
||||
const columnsKey = selectColumns
|
||||
.map(
|
||||
(column) =>
|
||||
`${column.id}:${column.options.map((option) => `${option.value}=${option.label}`).join('|')}`
|
||||
)
|
||||
.join(';');
|
||||
|
||||
const getKey = React.useCallback(
|
||||
() => slugFilterKey(resolveSlugFilter(selectColumns, selectStore.getState().slugs)),
|
||||
// `selectColumns` is a fresh array each render; its contents are what matter.
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
[columnsKey]
|
||||
);
|
||||
|
||||
const filterKey = React.useSyncExternalStore(selectStore.subscribe, getKey, getKey);
|
||||
|
||||
// The key is only an identity: rebuild the filter itself when it moves, rather than parsing the
|
||||
// key back apart, since an option value can be any string an import gave it.
|
||||
return React.useMemo(
|
||||
() => resolveSlugFilter(selectColumns, selectStore.getState().slugs),
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
[filterKey]
|
||||
);
|
||||
}
|
||||
|
||||
function useTableSearch(): TableSearchContextValue {
|
||||
const context = React.useContext(TableSearchContext);
|
||||
if (!context) {
|
||||
@@ -193,6 +297,49 @@ export function TableSearchEmpty(props: { className?: ClassValue }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the selection narrowing this table, and lets the reader drop it.
|
||||
*
|
||||
* Deliberately worded around the *selection* rather than the filter: the column dropdown beside it
|
||||
* shows the same column as active, but clearing there only resets local state and the filter returns
|
||||
* on the next load. This is the control that actually undoes it.
|
||||
*
|
||||
* Rendered independently of the search bar. `shouldShowTableSearch` leaves the filter controls off
|
||||
* cards, off grids below the row threshold, and off any table whose author turned search off — and
|
||||
* in every one of those a narrowed table would otherwise just read as missing rows.
|
||||
*/
|
||||
export function TableSelectionFilter(props: { className?: ClassValue }) {
|
||||
const language = useLanguage();
|
||||
const { slugFilter, clearSlugFilter } = useTableSearch();
|
||||
|
||||
if (slugFilter.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls('flex flex-wrap items-center gap-2 text-sm text-tint', props.className)}
|
||||
>
|
||||
<Icon icon="filter" className="size-3 shrink-0" />
|
||||
<span>
|
||||
{tString(
|
||||
language,
|
||||
'table_filtered_by_selection',
|
||||
slugFilter.map((entry) => entry.label).join(', ')
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="xsmall"
|
||||
icon="xmark"
|
||||
iconOnly
|
||||
label={tString(language, 'table_clear_selection')}
|
||||
onClick={clearSlugFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank multi-select dropdown button for a single select column. Becomes `active` while any
|
||||
* of its options are selected.
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type { TableSelectColumn } from './search';
|
||||
import {
|
||||
getAppliedSlugFilter,
|
||||
getOptionSlug,
|
||||
reconcileSelectedOptions,
|
||||
resolveSlugFilter,
|
||||
slugFilterKey,
|
||||
} from './slugFilter';
|
||||
|
||||
/**
|
||||
* A select column as the editor writes one: each option carries an opaque generated `value` and the
|
||||
* author's wording as its `label`. Tests must not conflate the two — matching on `value` looks
|
||||
* right against fixtures that reuse the label and matches nothing against real content.
|
||||
*/
|
||||
function column(id: string, labels: string[]): TableSelectColumn {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
options: labels.map((label, index) => ({
|
||||
value: `${id}-key-${index}`,
|
||||
label,
|
||||
color: 'blue',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The opaque value of the option an author labelled `label`. */
|
||||
function valueOf(column: TableSelectColumn, label: string): string {
|
||||
const option = column.options.find((option) => option.label === label);
|
||||
if (!option) {
|
||||
throw new Error(`no option labelled ${label}`);
|
||||
}
|
||||
return option.value;
|
||||
}
|
||||
|
||||
describe('resolveSlugFilter', () => {
|
||||
const platform = column('platform', ['macOS', 'Windows', 'Linux']);
|
||||
const status = column('status', ['Done', 'To do']);
|
||||
|
||||
it('matches the option label, and resolves to its opaque value', () => {
|
||||
expect(resolveSlugFilter([platform], ['macos'])).toEqual([
|
||||
{
|
||||
column: 'platform',
|
||||
value: valueOf(platform, 'macOS'),
|
||||
label: 'macOS',
|
||||
slug: 'macos',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the label and slug needed to show and clear the filter', () => {
|
||||
// The reader is told what narrowed the table, and the slug is what clearing deactivates.
|
||||
const [entry] = resolveSlugFilter([platform], ['windows']);
|
||||
expect(entry?.label).toBe('Windows');
|
||||
expect(entry?.slug).toBe('windows');
|
||||
});
|
||||
|
||||
it('never matches the opaque value itself', () => {
|
||||
expect(resolveSlugFilter([platform], [valueOf(platform, 'macOS')])).toEqual([]);
|
||||
});
|
||||
|
||||
it('slugifies the label the way every other select surface does', () => {
|
||||
const languages = column('language', ['Node.js', 'C++', 'Windows 10']);
|
||||
expect(resolveSlugFilter([languages], ['node.js'])[0]?.label).toBe('Node.js');
|
||||
expect(resolveSlugFilter([languages], ['c++'])[0]?.label).toBe('C++');
|
||||
expect(resolveSlugFilter([languages], ['windows-10'])[0]?.label).toBe('Windows 10');
|
||||
});
|
||||
|
||||
it('falls back to the value when an option has no label', () => {
|
||||
const legacy: TableSelectColumn = {
|
||||
id: 'legacy',
|
||||
label: 'legacy',
|
||||
options: [{ value: 'macos', label: '', color: 'blue' }],
|
||||
};
|
||||
expect(resolveSlugFilter([legacy], ['macos'])).toEqual([
|
||||
{ column: 'legacy', value: 'macos', label: 'macos', slug: 'macos' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a column the selection says nothing about', () => {
|
||||
const entries = resolveSlugFilter([platform, status], ['macos']);
|
||||
expect(entries.map((entry) => entry.column)).toEqual(['platform']);
|
||||
});
|
||||
|
||||
it('narrows several columns at once when the selection covers both', () => {
|
||||
const entries = resolveSlugFilter([platform, status], ['macos', 'done']);
|
||||
expect(entries.map((entry) => entry.value)).toEqual([
|
||||
valueOf(platform, 'macOS'),
|
||||
valueOf(status, 'Done'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes the most recently activated when a column offers several active options', () => {
|
||||
// Most-recent-first, so `windows` wins over `macos` — the rule tabs resolve with.
|
||||
expect(resolveSlugFilter([platform], ['windows', 'macos'])[0]?.value).toBe(
|
||||
valueOf(platform, 'Windows')
|
||||
);
|
||||
expect(resolveSlugFilter([platform], ['macos', 'windows'])[0]?.value).toBe(
|
||||
valueOf(platform, 'macOS')
|
||||
);
|
||||
});
|
||||
|
||||
it('filters nothing when the selection matches no column', () => {
|
||||
expect(resolveSlugFilter([platform, status], ['python'])).toEqual([]);
|
||||
expect(resolveSlugFilter([platform], [])).toEqual([]);
|
||||
expect(resolveSlugFilter([], ['macos'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOptionSlug', () => {
|
||||
// Shared by the matcher and by the write-back that moves the selection when a reader changes a
|
||||
// governed column, so the two can never disagree about what an option answers to.
|
||||
it('slugifies the label, and falls back to the value', () => {
|
||||
expect(getOptionSlug({ value: 'key-0', label: 'macOS' })).toBe('macos');
|
||||
expect(getOptionSlug({ value: 'key-1', label: 'Windows 10' })).toBe('windows-10');
|
||||
expect(getOptionSlug({ value: 'macos', label: '' })).toBe('macos');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileSelectedOptions', () => {
|
||||
const entry = (column: string, value: string) => ({
|
||||
column,
|
||||
value,
|
||||
label: value,
|
||||
slug: value,
|
||||
});
|
||||
|
||||
it('drops the column when the selection is cleared', () => {
|
||||
const previous = { platform: new Set(['macos-value']) };
|
||||
expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({});
|
||||
});
|
||||
|
||||
it('leaves filters the reader set themselves', () => {
|
||||
const previous = {
|
||||
platform: new Set(['macos-value']),
|
||||
status: new Set(['done-value']),
|
||||
};
|
||||
expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({
|
||||
status: new Set(['done-value']),
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the previous selection rather than adding to it', () => {
|
||||
const previous = { platform: new Set(['macos-value']) };
|
||||
expect(
|
||||
reconcileSelectedOptions(previous, ['platform'], [entry('platform', 'windows-value')])
|
||||
).toEqual({ platform: new Set(['windows-value']) });
|
||||
});
|
||||
|
||||
it('gives the same answer however many times it is applied', () => {
|
||||
// React may invoke a state updater more than once with the same input. An earlier version
|
||||
// tracked the narrowed columns inside the updater, so the second pass saw them already
|
||||
// cleared, took the early return and handed back the *unchanged* state — silently undoing
|
||||
// a clear while leaving an apply working, which is exactly how it presented.
|
||||
const previous = { platform: new Set(['macos-value']) };
|
||||
const once = reconcileSelectedOptions(previous, ['platform'], []);
|
||||
const twice = reconcileSelectedOptions(previous, ['platform'], []);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
it('is a no-op when there is nothing to narrow and nothing to undo', () => {
|
||||
const previous = { status: new Set(['done-value']) };
|
||||
expect(reconcileSelectedOptions(previous, [], [])).toBe(previous);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppliedSlugFilter', () => {
|
||||
const macos = { column: 'platform', value: 'macos-value', label: 'macOS', slug: 'macos' };
|
||||
|
||||
it('speaks for a column the reader has left alone', () => {
|
||||
const selected = { platform: new Set(['macos-value']) };
|
||||
expect(getAppliedSlugFilter([macos], selected)).toEqual([macos]);
|
||||
});
|
||||
|
||||
it('drops a column the reader has filtered to something else', () => {
|
||||
// Changing the filter deliberately leaves the selection active, so the slug is still there;
|
||||
// the notice just must not claim a match the table is no longer showing.
|
||||
const selected = { platform: new Set(['windows-value']) };
|
||||
expect(getAppliedSlugFilter([macos], selected)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a column the reader has widened to several options', () => {
|
||||
const selected = { platform: new Set(['macos-value', 'windows-value']) };
|
||||
expect(getAppliedSlugFilter([macos], selected)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a column the reader has cleared', () => {
|
||||
expect(getAppliedSlugFilter([macos], {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugFilterKey', () => {
|
||||
const entry = (column: string, value: string) => ({
|
||||
column,
|
||||
value,
|
||||
label: value,
|
||||
slug: value,
|
||||
});
|
||||
|
||||
it('is stable whatever order the columns resolve in', () => {
|
||||
expect(slugFilterKey([entry('platform', 'macos'), entry('status', 'done')])).toBe(
|
||||
slugFilterKey([entry('status', 'done'), entry('platform', 'macos')])
|
||||
);
|
||||
});
|
||||
|
||||
it('changes when the selection moves', () => {
|
||||
expect(slugFilterKey([entry('platform', 'macos')])).not.toBe(
|
||||
slugFilterKey([entry('platform', 'linux')])
|
||||
);
|
||||
});
|
||||
|
||||
it('is empty when nothing is filtered', () => {
|
||||
expect(slugFilterKey([])).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { TableSelectColumn } from './search';
|
||||
import type { SelectedOptions } from './searchMatch';
|
||||
import { slugifySelectValue } from '@/lib/select';
|
||||
|
||||
/** One select column narrowed by the reader's content selection. */
|
||||
export interface SlugFilterEntry {
|
||||
/** Id of the select column being narrowed. */
|
||||
column: string;
|
||||
/** The option's opaque value, which the record matcher compares against. */
|
||||
value: string;
|
||||
/** The option's author-typed label, shown to the reader. */
|
||||
label: string;
|
||||
/** The active slug that narrowed the column, so the reader can clear it again. */
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `select` slug an option answers to.
|
||||
*
|
||||
* A table option's `value` is an opaque generated key, so it is the author-typed `label` that names
|
||||
* it — the same wording a tab title or select button would carry, put through the same slugifier so
|
||||
* a "macOS" column option and a "macOS" tab resolve to the one slug. Options with no label fall back
|
||||
* to the raw value, mirroring how a cell renders one.
|
||||
*/
|
||||
export function getOptionSlug(option: { value: string; label: string }): string {
|
||||
return slugifySelectValue(option.label || option.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which option of each select column the reader's current selection points at.
|
||||
*
|
||||
* A table's select column declares its own option set, so a slug only filters a column that
|
||||
* actually offers it: a reader who picked `macos` narrows a Platform column to macOS and leaves a
|
||||
* Status column alone. Where several of a column's options are active at once, the most recently
|
||||
* activated wins — the same rule tabs resolve with, so a table and the tabs beside it agree.
|
||||
*/
|
||||
export function resolveSlugFilter(
|
||||
columns: TableSelectColumn[],
|
||||
slugs: string[]
|
||||
): SlugFilterEntry[] {
|
||||
const entries: SlugFilterEntry[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
let best: SlugFilterEntry | undefined;
|
||||
let bestRank = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const option of column.options) {
|
||||
const slug = getOptionSlug(option);
|
||||
if (!slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rank = slugs.indexOf(slug);
|
||||
if (rank >= 0 && rank < bestRank) {
|
||||
bestRank = rank;
|
||||
best = {
|
||||
column: column.id,
|
||||
value: option.value,
|
||||
label: option.label || option.value,
|
||||
slug,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
entries.push(best);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Stable identity for a filter, so it is only rebuilt when the selection actually moves. */
|
||||
export function slugFilterKey(entries: SlugFilterEntry[]): string {
|
||||
return entries
|
||||
.map((entry) => `${entry.column}=${entry.value}`)
|
||||
.sort()
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the columns the selection narrows into the reader's own filters.
|
||||
*
|
||||
* Kept pure, and given the previously narrowed columns rather than reading them from a ref, so it
|
||||
* can be applied more than once without changing the answer — React may invoke a state updater
|
||||
* twice, and an earlier version tracked those columns inside the updater itself, which silently
|
||||
* undid a clear on the second pass.
|
||||
*
|
||||
* Only columns the selection narrowed last time are dropped; anything the reader filtered by hand
|
||||
* is left exactly as it was.
|
||||
*/
|
||||
export function reconcileSelectedOptions(
|
||||
previous: SelectedOptions,
|
||||
previouslyNarrowed: readonly string[],
|
||||
slugFilter: readonly SlugFilterEntry[]
|
||||
): SelectedOptions {
|
||||
if (previouslyNarrowed.length === 0 && slugFilter.length === 0) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const next = { ...previous };
|
||||
for (const column of previouslyNarrowed) {
|
||||
delete next[column];
|
||||
}
|
||||
for (const entry of slugFilter) {
|
||||
next[entry.column] = new Set([entry.value]);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow the filter to the columns the table is *still* showing.
|
||||
*
|
||||
* A reader can change any of these columns by hand, and that deliberately leaves the selection
|
||||
* alone — the filter is this table's, for this visit, while the selection is site-wide and
|
||||
* persists. So the notice has to stop speaking for a column that no longer matches, rather than
|
||||
* describing a view the reader has since changed.
|
||||
*/
|
||||
export function getAppliedSlugFilter(
|
||||
slugFilter: readonly SlugFilterEntry[],
|
||||
selectedOptions: SelectedOptions
|
||||
): SlugFilterEntry[] {
|
||||
return slugFilter.filter((entry) => {
|
||||
const values = selectedOptions[entry.column];
|
||||
return values?.size === 1 && values.has(entry.value);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export const ar: TranslationLanguage = {
|
||||
switch_to_system_theme: 'التبديل إلى سمة النظام',
|
||||
search: 'بحث',
|
||||
clear: 'مسح',
|
||||
table_filtered_by_selection: 'يطابق الاختيار: ${1}',
|
||||
table_clear_selection: 'مسح الاختيار',
|
||||
tags: 'الوسوم',
|
||||
search_back: 'العودة إلى نتائج البحث',
|
||||
search_or_ask: 'اسأل أو ابحث',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const bg: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Превключване към системната тема',
|
||||
search: 'Търсене',
|
||||
clear: 'Изчистване',
|
||||
table_filtered_by_selection: 'Съответства на избора: ${1}',
|
||||
table_clear_selection: 'Изчисти избора',
|
||||
tags: 'Етикети',
|
||||
search_back: 'Назад към резултатите от търсенето',
|
||||
search_or_ask: 'Попитайте или търсете',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const cs: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Přepnout na systémový motiv',
|
||||
search: 'Hledat',
|
||||
clear: 'Vymazat',
|
||||
table_filtered_by_selection: 'Odpovídá výběru: ${1}',
|
||||
table_clear_selection: 'Zrušit výběr',
|
||||
tags: 'Štítky',
|
||||
search_back: 'Zpět na výsledky hledání',
|
||||
search_or_ask: 'Zeptat se nebo hledat',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const da: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Skift til systemtema',
|
||||
search: 'Søg',
|
||||
clear: 'Ryd',
|
||||
table_filtered_by_selection: 'Matcher valg: ${1}',
|
||||
table_clear_selection: 'Ryd valg',
|
||||
tags: 'Tags',
|
||||
search_back: 'Tilbage til søgeresultater',
|
||||
search_or_ask: 'Spørg eller søg',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const de: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Zum Systemmodus wechseln',
|
||||
search: 'Suche',
|
||||
clear: 'Löschen',
|
||||
table_filtered_by_selection: 'Entspricht Auswahl: ${1}',
|
||||
table_clear_selection: 'Auswahl zurücksetzen',
|
||||
tags: 'Tags',
|
||||
search_back: 'Zurück zu den Suchergebnissen',
|
||||
search_or_ask: 'Fragen oder Suchen',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const el: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Αλλαγή στο θέμα συστήματος',
|
||||
search: 'Αναζήτηση',
|
||||
clear: 'Εκκαθάριση',
|
||||
table_filtered_by_selection: 'Ταιριάζει με την επιλογή: ${1}',
|
||||
table_clear_selection: 'Εκκαθάριση επιλογής',
|
||||
tags: 'Ετικέτες',
|
||||
search_back: 'Επιστροφή στα αποτελέσματα αναζήτησης',
|
||||
search_or_ask: 'Ρωτήστε ή αναζητήστε',
|
||||
|
||||
@@ -9,6 +9,8 @@ export const en = {
|
||||
switch_to_system_theme: 'Switch to system theme',
|
||||
search: 'Search',
|
||||
clear: 'Clear',
|
||||
table_filtered_by_selection: 'Matches selection: ${1}',
|
||||
table_clear_selection: 'Clear selection',
|
||||
tags: 'Tags',
|
||||
search_back: 'Back to search results',
|
||||
search_or_ask: 'Ask or search',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const es: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Cambiar a tema del sistema',
|
||||
search: 'Buscar',
|
||||
clear: 'Limpiar',
|
||||
table_filtered_by_selection: 'Coincide con la selección: ${1}',
|
||||
table_clear_selection: 'Borrar selección',
|
||||
tags: 'Etiquetas',
|
||||
search_back: 'Volver a los resultados de búsqueda',
|
||||
search_or_ask: 'Preguntar o Buscar',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const et: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Lülitu süsteemi teemale',
|
||||
search: 'Otsi',
|
||||
clear: 'Tühjenda',
|
||||
table_filtered_by_selection: 'Vastab valikule: ${1}',
|
||||
table_clear_selection: 'Tühjenda valik',
|
||||
tags: 'Sildid',
|
||||
search_back: 'Tagasi otsingutulemuste juurde',
|
||||
search_or_ask: 'Küsi või otsi',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const fi: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Vaihda järjestelmän teemaan',
|
||||
search: 'Haku',
|
||||
clear: 'Tyhjennä',
|
||||
table_filtered_by_selection: 'Vastaa valintaa: ${1}',
|
||||
table_clear_selection: 'Tyhjennä valinta',
|
||||
tags: 'Tunnisteet',
|
||||
search_back: 'Takaisin hakutuloksiin',
|
||||
search_or_ask: 'Kysy tai hae',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const fr: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Utiliser le thème du système',
|
||||
search: 'Rechercher',
|
||||
clear: 'Effacer',
|
||||
table_filtered_by_selection: 'Correspond à la sélection : ${1}',
|
||||
table_clear_selection: 'Effacer la sélection',
|
||||
tags: 'Étiquettes',
|
||||
search_back: 'Retour aux résultats de recherche',
|
||||
search_or_ask: 'Rechercher',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const he: TranslationLanguage = {
|
||||
switch_to_system_theme: 'מעבר לערכת הנושא של המערכת',
|
||||
search: 'חיפוש',
|
||||
clear: 'ניקוי',
|
||||
table_filtered_by_selection: 'תואם לבחירה: ${1}',
|
||||
table_clear_selection: 'נקה בחירה',
|
||||
tags: 'תגיות',
|
||||
search_back: 'חזרה לתוצאות החיפוש',
|
||||
search_or_ask: 'שאלה או חיפוש',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const hi: TranslationLanguage = {
|
||||
switch_to_system_theme: 'सिस्टम थीम पर जाएं',
|
||||
search: 'खोजें',
|
||||
clear: 'साफ करें',
|
||||
table_filtered_by_selection: 'चयन से मेल खाता है: ${1}',
|
||||
table_clear_selection: 'चयन साफ़ करें',
|
||||
tags: 'टैग',
|
||||
search_back: 'खोज परिणामों पर वापस जाएं',
|
||||
search_or_ask: 'पूछें या खोजें',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const hr: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Prebaci na temu sustava',
|
||||
search: 'Pretraži',
|
||||
clear: 'Očisti',
|
||||
table_filtered_by_selection: 'Odgovara odabiru: ${1}',
|
||||
table_clear_selection: 'Očisti odabir',
|
||||
tags: 'Oznake',
|
||||
search_back: 'Natrag na rezultate pretraživanja',
|
||||
search_or_ask: 'Pitaj ili pretraži',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const hu: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Váltás rendszer témára',
|
||||
search: 'Keresés',
|
||||
clear: 'Törlés',
|
||||
table_filtered_by_selection: 'Megfelel a kijelölésnek: ${1}',
|
||||
table_clear_selection: 'Kijelölés törlése',
|
||||
tags: 'Címkék',
|
||||
search_back: 'Vissza a keresési eredményekhez',
|
||||
search_or_ask: 'Kérdezés vagy keresés',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const id: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Beralih ke tema sistem',
|
||||
search: 'Cari',
|
||||
clear: 'Bersihkan',
|
||||
table_filtered_by_selection: 'Sesuai pilihan: ${1}',
|
||||
table_clear_selection: 'Hapus pilihan',
|
||||
tags: 'Tag',
|
||||
search_back: 'Kembali ke hasil pencarian',
|
||||
search_or_ask: 'Tanya atau cari',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const it: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Passa al tema di sistema',
|
||||
search: 'Cerca',
|
||||
clear: 'Cancella',
|
||||
table_filtered_by_selection: 'Corrisponde alla selezione: ${1}',
|
||||
table_clear_selection: 'Cancella selezione',
|
||||
tags: 'Tag',
|
||||
search_back: 'Torna ai risultati di ricerca',
|
||||
search_or_ask: 'Chiedi o cerca',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ja: TranslationLanguage = {
|
||||
switch_to_system_theme: 'システムのテーマに切り替え',
|
||||
search: '検索',
|
||||
clear: 'クリア',
|
||||
table_filtered_by_selection: '選択に一致: ${1}',
|
||||
table_clear_selection: '選択をクリア',
|
||||
tags: 'タグ',
|
||||
search_back: '検索結果に戻る',
|
||||
search_or_ask: '質問または検索',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ko: TranslationLanguage = {
|
||||
switch_to_system_theme: '시스템 테마로 전환',
|
||||
search: '검색',
|
||||
clear: '지우기',
|
||||
table_filtered_by_selection: '선택과 일치: ${1}',
|
||||
table_clear_selection: '선택 지우기',
|
||||
tags: '태그',
|
||||
search_back: '검색 결과로 돌아가기',
|
||||
search_or_ask: '질문 또는 검색',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const lt: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Perjungti į sistemos temą',
|
||||
search: 'Ieškoti',
|
||||
clear: 'Išvalyti',
|
||||
table_filtered_by_selection: 'Atitinka pasirinkimą: ${1}',
|
||||
table_clear_selection: 'Išvalyti pasirinkimą',
|
||||
tags: 'Žymos',
|
||||
search_back: 'Grįžti į paieškos rezultatus',
|
||||
search_or_ask: 'Klausti arba ieškoti',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const lv: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Pārslēgt uz sistēmas motīvu',
|
||||
search: 'Meklēt',
|
||||
clear: 'Notīrīt',
|
||||
table_filtered_by_selection: 'Atbilst izvēlei: ${1}',
|
||||
table_clear_selection: 'Notīrīt izvēli',
|
||||
tags: 'Birkas',
|
||||
search_back: 'Atpakaļ uz meklēšanas rezultātiem',
|
||||
search_or_ask: 'Jautāt vai meklēt',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ms: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Tukar kepada tema sistem',
|
||||
search: 'Cari',
|
||||
clear: 'Kosongkan',
|
||||
table_filtered_by_selection: 'Sepadan dengan pilihan: ${1}',
|
||||
table_clear_selection: 'Kosongkan pilihan',
|
||||
tags: 'Tag',
|
||||
search_back: 'Kembali ke hasil carian',
|
||||
search_or_ask: 'Tanya atau cari',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const nl: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Schakel over naar systeemmodus',
|
||||
search: 'Zoeken',
|
||||
clear: 'Wissen',
|
||||
table_filtered_by_selection: 'Komt overeen met selectie: ${1}',
|
||||
table_clear_selection: 'Selectie wissen',
|
||||
tags: 'Tags',
|
||||
search_back: 'Terug naar zoekresultaten',
|
||||
search_or_ask: 'Zoek of vraag',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const no: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Bytt til systemtema',
|
||||
search: 'Søk',
|
||||
clear: 'Tøm',
|
||||
table_filtered_by_selection: 'Samsvarer med valg: ${1}',
|
||||
table_clear_selection: 'Fjern valg',
|
||||
tags: 'Tagger',
|
||||
search_back: 'Tilbake til søkeresultater',
|
||||
search_or_ask: 'Spør eller søk',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const pl: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Przełącz na motyw systemowy',
|
||||
search: 'Szukaj',
|
||||
clear: 'Wyczyść',
|
||||
table_filtered_by_selection: 'Zgodne z wyborem: ${1}',
|
||||
table_clear_selection: 'Wyczyść wybór',
|
||||
tags: 'Tagi',
|
||||
search_back: 'Wróć do wyników wyszukiwania',
|
||||
search_or_ask: 'Zapytaj lub wyszukaj',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const pt_br: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Mudar para configuração do sistema',
|
||||
search: 'Buscar',
|
||||
clear: 'Limpar',
|
||||
table_filtered_by_selection: 'Corresponde à seleção: ${1}',
|
||||
table_clear_selection: 'Limpar seleção',
|
||||
tags: 'Tags',
|
||||
search_back: 'Voltar aos resultados da busca',
|
||||
search_or_ask: 'Perguntar ou buscar',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const pt: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Mudar para o tema do sistema',
|
||||
search: 'Pesquisar',
|
||||
clear: 'Limpar',
|
||||
table_filtered_by_selection: 'Corresponde à seleção: ${1}',
|
||||
table_clear_selection: 'Limpar seleção',
|
||||
tags: 'Etiquetas',
|
||||
search_back: 'Voltar aos resultados da pesquisa',
|
||||
search_or_ask: 'Perguntar ou pesquisar',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ro: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Comută la tema sistemului',
|
||||
search: 'Caută',
|
||||
clear: 'Șterge',
|
||||
table_filtered_by_selection: 'Se potrivește cu selecția: ${1}',
|
||||
table_clear_selection: 'Șterge selecția',
|
||||
tags: 'Etichete',
|
||||
search_back: 'Înapoi la rezultatele căutării',
|
||||
search_or_ask: 'Întreabă sau caută',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ru: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Переключиться на системную тему',
|
||||
search: 'Поиск',
|
||||
clear: 'Очистить',
|
||||
table_filtered_by_selection: 'Соответствует выбору: ${1}',
|
||||
table_clear_selection: 'Очистить выбор',
|
||||
tags: 'Теги',
|
||||
search_back: 'Вернуться к результатам поиска',
|
||||
search_or_ask: 'Найти или спросить',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const sk: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Prepnúť na systémový motív',
|
||||
search: 'Hľadať',
|
||||
clear: 'Vymazať',
|
||||
table_filtered_by_selection: 'Zodpovedá výberu: ${1}',
|
||||
table_clear_selection: 'Zrušiť výber',
|
||||
tags: 'Značky',
|
||||
search_back: 'Späť na výsledky vyhľadávania',
|
||||
search_or_ask: 'Opýtať sa alebo hľadať',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const sl: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Preklopi na sistemsko temo',
|
||||
search: 'Išči',
|
||||
clear: 'Počisti',
|
||||
table_filtered_by_selection: 'Ustreza izbiri: ${1}',
|
||||
table_clear_selection: 'Počisti izbiro',
|
||||
tags: 'Oznake',
|
||||
search_back: 'Nazaj na rezultate iskanja',
|
||||
search_or_ask: 'Vprašaj ali išči',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const sv: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Byt till systemtema',
|
||||
search: 'Sök',
|
||||
clear: 'Rensa',
|
||||
table_filtered_by_selection: 'Matchar val: ${1}',
|
||||
table_clear_selection: 'Rensa val',
|
||||
tags: 'Taggar',
|
||||
search_back: 'Tillbaka till sökresultat',
|
||||
search_or_ask: 'Fråga eller sök',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const th: TranslationLanguage = {
|
||||
switch_to_system_theme: 'เปลี่ยนเป็นธีมของระบบ',
|
||||
search: 'ค้นหา',
|
||||
clear: 'ล้าง',
|
||||
table_filtered_by_selection: 'ตรงกับการเลือก: ${1}',
|
||||
table_clear_selection: 'ล้างการเลือก',
|
||||
tags: 'แท็ก',
|
||||
search_back: 'กลับไปยังผลการค้นหา',
|
||||
search_or_ask: 'ถามหรือค้นหา',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const tr: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Sistem temasına geç',
|
||||
search: 'Ara',
|
||||
clear: 'Temizle',
|
||||
table_filtered_by_selection: 'Seçimle eşleşiyor: ${1}',
|
||||
table_clear_selection: 'Seçimi temizle',
|
||||
tags: 'Etiketler',
|
||||
search_back: 'Arama sonuçlarına geri dön',
|
||||
search_or_ask: 'Sor veya ara',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const uk: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Перемкнути на системну тему',
|
||||
search: 'Пошук',
|
||||
clear: 'Очистити',
|
||||
table_filtered_by_selection: 'Відповідає вибору: ${1}',
|
||||
table_clear_selection: 'Очистити вибір',
|
||||
tags: 'Теги',
|
||||
search_back: 'Назад до результатів пошуку',
|
||||
search_or_ask: 'Запитати або шукати',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const vi: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Chuyển sang giao diện hệ thống',
|
||||
search: 'Tìm kiếm',
|
||||
clear: 'Xóa',
|
||||
table_filtered_by_selection: 'Khớp với lựa chọn: ${1}',
|
||||
table_clear_selection: 'Xóa lựa chọn',
|
||||
tags: 'Thẻ',
|
||||
search_back: 'Quay lại kết quả tìm kiếm',
|
||||
search_or_ask: 'Hỏi hoặc tìm kiếm',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const yue: TranslationLanguage = {
|
||||
switch_to_system_theme: '切換到系統主題',
|
||||
search: '搜尋',
|
||||
clear: '清除',
|
||||
table_filtered_by_selection: '符合選擇:${1}',
|
||||
table_clear_selection: '清除選擇',
|
||||
tags: '標籤',
|
||||
search_back: '返回搜尋結果',
|
||||
search_or_ask: '發問或搜尋',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const zh_tw: TranslationLanguage = {
|
||||
switch_to_system_theme: '切換至系統主題',
|
||||
search: '搜尋',
|
||||
clear: '清除',
|
||||
table_filtered_by_selection: '符合選擇:${1}',
|
||||
table_clear_selection: '清除選擇',
|
||||
tags: '標籤',
|
||||
search_back: '返回搜尋結果',
|
||||
search_or_ask: '詢問或搜尋',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const zh: TranslationLanguage = {
|
||||
switch_to_system_theme: '切换到系统主题',
|
||||
search: '搜索',
|
||||
clear: '清除',
|
||||
table_filtered_by_selection: '符合选择:${1}',
|
||||
table_clear_selection: '清除选择',
|
||||
tags: '标签',
|
||||
search_back: '返回搜索结果',
|
||||
search_or_ask: '询问或搜索',
|
||||
|
||||
Reference in New Issue
Block a user