@maestro-js/components 1.0.0-alpha.1 → 1.0.0-alpha.11

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.
Files changed (55) hide show
  1. package/commands/add.ts +16 -1
  2. package/dist/components.json +1 -1
  3. package/dist/index.js +14 -1
  4. package/package.json +5 -5
  5. package/registry.json +610 -802
  6. package/scripts/build.ts +11 -4
  7. package/src/components/alert-dialog.tsx +3 -3
  8. package/src/components/avatar.tsx +1 -1
  9. package/src/components/button-link.tsx +3 -0
  10. package/src/components/button.tsx +4 -0
  11. package/src/components/checkbox.tsx +7 -7
  12. package/src/components/chip.tsx +7 -7
  13. package/src/components/container.tsx +3 -3
  14. package/src/components/date-range.tsx +161 -0
  15. package/src/components/dialog.tsx +21 -7
  16. package/src/components/disclosure.tsx +1 -1
  17. package/src/components/drawer.tsx +19 -5
  18. package/src/components/form.tsx +27 -5
  19. package/src/components/helpers/form-field.tsx +7 -8
  20. package/src/components/helpers/get-button-classes.ts +23 -23
  21. package/src/components/helpers/headless-button.tsx +1 -1
  22. package/src/components/inline-alert.tsx +3 -3
  23. package/src/components/labeled-value.tsx +2 -2
  24. package/src/components/menu.tsx +60 -26
  25. package/src/components/month-day-input.tsx +2 -1
  26. package/src/components/multiselect.tsx +74 -70
  27. package/src/components/optional-link.tsx +15 -0
  28. package/src/components/radio.tsx +8 -8
  29. package/src/components/select.tsx +9 -8
  30. package/src/components/stepper.tsx +6 -6
  31. package/src/components/switch.tsx +7 -7
  32. package/src/components/table/headless-templated-row-table.ts +550 -0
  33. package/src/components/table/index.tsx +41 -0
  34. package/src/components/table/server-table.ts +143 -0
  35. package/src/components/table/table-actions.tsx +76 -0
  36. package/src/components/table/table-container.tsx +173 -0
  37. package/src/components/table/table-container.type-test.ts +155 -0
  38. package/src/components/table/table-filters/date-range-filter.tsx +227 -0
  39. package/src/components/table/table-filters/select-filter.tsx +211 -0
  40. package/src/components/table/table-filters/sort.tsx +85 -0
  41. package/src/components/table/table-primitives.tsx +226 -0
  42. package/src/components/table/table-toolbar.tsx +300 -0
  43. package/src/components/table/table-types.ts +61 -0
  44. package/src/components/table/use-client-table.ts +157 -0
  45. package/src/components/table/use-client-table.type-test.ts +217 -0
  46. package/src/components/table/use-server-table.ts +192 -0
  47. package/src/components/table/use-server-table.type-test.ts +174 -0
  48. package/src/components/tabs.tsx +1 -1
  49. package/src/components/tag-field.tsx +3 -3
  50. package/src/components/text-area.tsx +1 -1
  51. package/src/components/toast.tsx +6 -6
  52. package/src/components/toggle-button-group.tsx +2 -2
  53. package/src/utils/icons.d.ts +2 -1
  54. package/src/utils/use-query-state.ts +626 -0
  55. package/src/utils/use-tab-indicator.ts +9 -9
@@ -0,0 +1,143 @@
1
+ import { matchSorter, type MatchSorterOptions } from 'match-sorter'
2
+ import { SearchParams } from '../../utils/use-query-state'
3
+ import type { SearchParams as SearchParamsTypes } from '../../utils/use-query-state'
4
+ import type { HeadlessTemplatedRowTable } from './headless-templated-row-table'
5
+
6
+ export declare namespace ServerTable {
7
+ type RowData = HeadlessTemplatedRowTable.RowData
8
+
9
+ type ServerTableOptions<TData extends RowData, FC extends Record<string, any>, SC extends Record<string, any>> = {
10
+ rows: TData[]
11
+ url: string | URL
12
+ filters?: FilterFieldMap<TData, FC>
13
+ sorts?: HeadlessTemplatedRowTable.SortConfig<TData, SC>
14
+ defaultSort?: HeadlessTemplatedRowTable.SortState<SC>
15
+ search?: MatchSorterOptions<TData>
16
+ pagination?: number
17
+ }
18
+
19
+ type ServerTable<TData extends RowData, FC extends Record<string, any>, SC extends Record<string, any>> = {
20
+ rows: TData[]
21
+ totalRows: number
22
+ filters: SerializedFilterCodecs<FilterFieldMap<TData, FC>>
23
+ sorts: SerializedSortCodec<SC>
24
+ search: SearchParamsTypes.CodecDef<string | null> | null
25
+ pagination: {
26
+ limit: SearchParamsTypes.CodecDef<number | null>
27
+ skip: SearchParamsTypes.CodecDef<number | null>
28
+ } | null
29
+ }
30
+
31
+ // ---- Filtering ----
32
+
33
+ type FilterField<TData extends RowData, Name extends string = string, Value = any> = {
34
+ filter: HeadlessTemplatedRowTable.FilterPredicate<TData, Name, NonNullable<Value>>
35
+ codec: SearchParamsTypes.Codec<SearchParamsTypes.CodecDef<Value>>
36
+ }
37
+
38
+ type FilterFieldMap<TData extends RowData, FC extends Record<string, any> = Record<string, any>> = {
39
+ [K in keyof FC & string]: FilterField<TData, K, FC[K]>
40
+ }
41
+
42
+ type ExtractFilterValue<C extends FilterField<any, any, any>> = Parameters<C['filter']>[1]
43
+
44
+ type FilterValues<TData extends RowData, Def extends FilterFieldMap<TData>> = {
45
+ [key in keyof Def]: ExtractFilterValue<Def[key]> | null | undefined
46
+ }
47
+
48
+ type SerializedFilterCodecs<Def extends FilterFieldMap<any>> = {
49
+ [K in keyof Def & string]: SearchParamsTypes.SerializedCodec<Def[K]['codec']>
50
+ }
51
+
52
+ // ---- Sorting ----
53
+
54
+ type SortCodec<SC extends Record<string, any>> = SearchParamsTypes.Codec<
55
+ SearchParamsTypes.CodecDef<(keyof SC & string) | null>
56
+ >
57
+
58
+ type SerializedSortCodec<SC extends Record<string, any>> = SearchParamsTypes.SerializedCodec<SortCodec<SC>> & {
59
+ values: readonly (keyof SC & string)[]
60
+ }
61
+ }
62
+
63
+ export function createServerTable<
64
+ TData extends ServerTable.RowData,
65
+ FC extends Record<string, any> = {},
66
+ SC extends Record<string, any> = {}
67
+ >(config: ServerTable.ServerTableOptions<TData, FC, SC>): ServerTable.ServerTable<TData, FC, SC> {
68
+ type Def = ServerTable.FilterFieldMap<TData, FC>
69
+ let rows = config.rows
70
+
71
+ // Build a unified loader from all feature codecs and read state from URL
72
+ const filterDef = config.filters
73
+ const filterKeys = filterDef ? (Object.keys(filterDef) as (keyof FC & string)[]) : []
74
+ const sortKeys = config.sorts ? (Object.keys(config.sorts) as (keyof SC & string)[]) : []
75
+
76
+ // Build codecs once
77
+ const sortCodec = config.sorts
78
+ ? SearchParams.codecs.enum(sortKeys).default(config.defaultSort ?? null)
79
+ : SearchParams.codecs.enum([] as string[])
80
+ const searchCodec = config.search ? SearchParams.codecs.string().default('') : null
81
+ const limitCodec = config.pagination != null ? SearchParams.codecs.int().default(config.pagination) : null
82
+ const skipCodec = config.pagination != null ? SearchParams.codecs.int().default(0) : null
83
+
84
+ const loader = {
85
+ ...Object.fromEntries(filterKeys.map((key) => [key, filterDef![key]!.codec])),
86
+ ...(config.sorts ? { sort: sortCodec } : {}),
87
+ ...(searchCodec ? { search: searchCodec } : {}),
88
+ ...(limitCodec && skipCodec ? { limit: limitCodec, skip: skipCodec } : {})
89
+ }
90
+
91
+ const params = SearchParams.getSearchParamsState(config.url, loader)
92
+
93
+ // Filtering
94
+ const filterCodecs = filterDef
95
+ ? (Object.fromEntries(
96
+ filterKeys.map((key) => [key, SearchParams.serializeCodec(filterDef[key]!.codec)])
97
+ ) as ServerTable.SerializedFilterCodecs<Def>)
98
+ : ({} as ServerTable.SerializedFilterCodecs<Def>)
99
+
100
+ rows = rows.filter((row) =>
101
+ filterKeys.every((f) => {
102
+ const value = (params as Record<string, any>)[f]
103
+ if (value == null) return true
104
+ return filterDef![f]!.filter(row, value, f)
105
+ })
106
+ )
107
+
108
+ // Search
109
+ if (config.search && searchCodec) {
110
+ const query = (params as any).search as string
111
+ if (query) {
112
+ rows = query.split(' ').reduce((results, term) => matchSorter(results, term, config.search!), rows)
113
+ }
114
+ }
115
+
116
+ // Sort
117
+ if (config.sorts) {
118
+ const sortKey = ((params as any).sort as string) || null
119
+ if (sortKey && config.sorts[sortKey as keyof SC & string]) {
120
+ rows = [...rows].sort((a, b) => config.sorts![sortKey as keyof SC & string]!(a, b, sortKey))
121
+ }
122
+ }
123
+
124
+ // Pagination
125
+ const totalRows = rows.length
126
+ if (config.pagination != null) {
127
+ const limit = ((params as any).limit as number) ?? config.pagination
128
+ const skip = ((params as any).skip as number) ?? 0
129
+ rows = rows.slice(skip, skip + limit)
130
+ }
131
+
132
+ return {
133
+ rows,
134
+ totalRows,
135
+ filters: filterCodecs,
136
+ sorts: SearchParams.serializeCodec(sortCodec) as ServerTable.SerializedSortCodec<SC>,
137
+ search: searchCodec ? SearchParams.serializeCodec(searchCodec) : null,
138
+ pagination:
139
+ limitCodec && skipCodec
140
+ ? { limit: SearchParams.serializeCodec(limitCodec), skip: SearchParams.serializeCodec(skipCodec) }
141
+ : null
142
+ }
143
+ }
@@ -0,0 +1,76 @@
1
+ import React from 'react'
2
+ import { twMerge } from 'tailwind-merge'
3
+ import { Button, type ButtonProps } from '../button'
4
+ import { Menu, type MenuProps, type MenuItemProps, type MenuLinkItemProps } from '../menu'
5
+ import { Icon } from '../icon'
6
+
7
+ // ── TableActionButton ────────────────────────────────────────────────────────
8
+ // Primary action button for the table toolbar (e.g. "Add person").
9
+ // Defaults to contained/primary/sm — pass variant/color/size to override.
10
+
11
+ export type TableActionButtonProps = ButtonProps
12
+
13
+ export function TableActionButton({ size = 'md', children, ...props }: TableActionButtonProps) {
14
+ return (
15
+ <Button size={size} {...props}>
16
+ {children}
17
+ </Button>
18
+ )
19
+ }
20
+
21
+ // ── TableMenu ────────────────────────────────────────────────────────────────
22
+ // Secondary actions dropdown for the table toolbar (e.g. "More").
23
+ // Renders a neutral outlined trigger with a chevron, and a standard Menu popover.
24
+
25
+ export type TableMenuProps = {
26
+ label?: string
27
+ children?: React.ReactNode
28
+ placement?: MenuProps['placement']
29
+ }
30
+
31
+ function TableMenuMain({ label = 'More', children, placement }: TableMenuProps) {
32
+ return (
33
+ <Menu
34
+ Button={
35
+ <Button variant="outlined" color="neutral" size="md">
36
+ {label}
37
+ <Icon name="chevron-down" className="size-4 text-neutral-400" />
38
+ </Button>
39
+ }
40
+ placement={placement}
41
+ >
42
+ {children}
43
+ </Menu>
44
+ )
45
+ }
46
+
47
+ export const TableMenu = Object.assign(TableMenuMain, {
48
+ Item: Menu.Item,
49
+ LinkItem: Menu.LinkItem,
50
+ Group: Menu.Group,
51
+ Separator: Menu.Separator
52
+ })
53
+
54
+ // ── TableSelectionAction ────────────────────────────────────────────────────
55
+ // Compact action button for the multi-select selection bar context.
56
+
57
+ export function TableSelectionActions({ children }: { children: React.ReactNode }) {
58
+ return <div className="flex items-center gap-1">{children}</div>
59
+ }
60
+
61
+ export type TableSelectionActionProps = React.ComponentProps<'button'>
62
+
63
+ export function TableSelectionAction({ children, className, ...props }: TableSelectionActionProps) {
64
+ return (
65
+ <button
66
+ type="button"
67
+ {...props}
68
+ className={twMerge(
69
+ 'inline-flex items-center rounded px-2 py-0.5 text-sm font-medium text-primary-700 hover:text-primary-900 hover:bg-primary-100 disabled:opacity-50 disabled:cursor-not-allowed',
70
+ className
71
+ )}
72
+ >
73
+ {children}
74
+ </button>
75
+ )
76
+ }
@@ -0,0 +1,173 @@
1
+ import {
2
+ TableBody,
3
+ TableCell,
4
+ TableDescription,
5
+ TableFooter,
6
+ TableMain,
7
+ TableRow,
8
+ TableRowContextProvider,
9
+ TableTitle,
10
+ TableTitleLink
11
+ } from './table-primitives'
12
+ import type { StandardTable } from './table-types'
13
+ import { TableHeader, type TableHeaderProps, TableSearch } from './table-toolbar'
14
+ import { TableDateRangeFilter } from './table-filters/date-range-filter'
15
+ import { TableSelectSort } from './table-filters/sort'
16
+ import { TableSelectFilter } from './table-filters/select-filter'
17
+ import type { TableFilterLocalization, TableLocalization } from './table-types'
18
+ import { twMerge } from 'tailwind-merge'
19
+ import React from 'react'
20
+ import type { Iso } from 'iso-fns'
21
+
22
+ export function TableContainer<TTable extends StandardTable>({
23
+ table,
24
+ localization,
25
+ children,
26
+ actions,
27
+ noRowElement = <StandardNoRows />,
28
+ multiSelect,
29
+ multiSelectActions,
30
+ hideActionsOnMobile = true,
31
+ stickyHeader = true,
32
+ hideSort,
33
+ hideSearch,
34
+ tableBodyClassName,
35
+ headerAction,
36
+ hideResultCount,
37
+ totalRows: totalRowsProp,
38
+ ...props
39
+ }: React.ComponentProps<'div'> & {
40
+ table: TTable
41
+ localization: TableLocalization<ReturnType<TTable['getFilterConfig']>, ReturnType<TTable['getSortConfig']>>
42
+ actions?: React.ReactNode
43
+ noRowElement?: React.ReactNode
44
+ multiSelect?: boolean
45
+ hideActionsOnMobile?: boolean
46
+ hideSort?: boolean
47
+ hideSearch?: boolean
48
+ stickyHeader?: boolean
49
+ multiSelectActions?: React.ReactNode
50
+ tableBodyClassName?: string
51
+ headerAction?: TableHeaderProps['action']
52
+ hideResultCount?: boolean
53
+ totalRows?: number
54
+ }) {
55
+ const rows = table.getRows()
56
+ const filterConfig = table.getFilterConfig()
57
+ const sortConfig = table.getSortConfig()
58
+ const filterKeys = Object.keys(filterConfig).filter((k) => k in localization.filters)
59
+ const sortKeys = Object.keys(sortConfig).filter((k) => k !== 'mostRelevant' && k in localization.sorts)
60
+
61
+ const hasFilters = React.useMemo(() => {
62
+ const filters = table.state.filters ?? {}
63
+ const hasActiveFilters = Object.entries(filters).some(([, value]) => value != null)
64
+ const hasSort = table.state.sort != null
65
+ const hasSearch = (table.state.search ?? '') !== ''
66
+ return hasActiveFilters || hasSort || hasSearch
67
+ }, [table.state.filters, table.state.sort, table.state.search])
68
+
69
+ return (
70
+ <div {...props}>
71
+ {/* Search + actions outside the card */}
72
+ <TableSearch
73
+ table={table}
74
+ placeholder={localization.searchPlaceholder}
75
+ actions={actions}
76
+ hasFilters={hasFilters}
77
+ hideSearch={hideSearch}
78
+ hideActionsOnMobile={hideActionsOnMobile}
79
+ />
80
+
81
+ {/* Bordered card container */}
82
+ <div className="rounded-lg border border-gray-950/10 overflow-hidden">
83
+ <TableMain className={twMerge(stickyHeader && 'relative')}>
84
+ <TableHeader
85
+ table={table}
86
+ multiSelect={multiSelect}
87
+ multiSelectActions={multiSelectActions}
88
+ sticky={stickyHeader}
89
+ hide={!!(hideSort && filterKeys.length === 0 && !multiSelect)}
90
+ action={headerAction}
91
+ >
92
+ <div className={twMerge('flex flex-nowrap items-center gap-x-1', hideSort && 'mr-1')}>
93
+ {filterKeys.map((f) => {
94
+ const filter = localization.filters[f] as TableFilterLocalization
95
+ if (filter.type === 'date-range') {
96
+ return (
97
+ <TableDateRangeFilter
98
+ table={table}
99
+ key={f}
100
+ name={f}
101
+ label={filter.label}
102
+ options={filter.options}
103
+ value={table.state.filters[f] as { startDate: Iso.Date; endDate: Iso.Date }}
104
+ />
105
+ )
106
+ } else {
107
+ return (
108
+ <TableSelectFilter
109
+ table={table}
110
+ label={filter.label}
111
+ name={f}
112
+ options={filter.options}
113
+ autoComplete={filter.autoComplete}
114
+ value={table.state.filters[f]}
115
+ key={f}
116
+ />
117
+ )
118
+ }
119
+ })}
120
+ </div>
121
+ <div className={twMerge('flex flex-nowrap items-center ml-1 mr-1', hideSort && 'hidden')}>
122
+ <TableSelectSort
123
+ table={table}
124
+ value={table.state.sort}
125
+ options={sortKeys.map((s) => ({ value: s, label: localization.sorts[s]?.label ?? s }))}
126
+ search={table.state.search}
127
+ />
128
+ </div>
129
+ </TableHeader>
130
+ <TableRowContextProvider value={{ table, multiSelect }}>
131
+ {rows.length ? <TableBody className={tableBodyClassName}>{children}</TableBody> : noRowElement}
132
+ </TableRowContextProvider>
133
+ {rows.length ? <TableFooter table={table} hideResultCount={hideResultCount} /> : null}
134
+ </TableMain>
135
+ </div>
136
+ </div>
137
+ )
138
+ }
139
+
140
+ export function BasicTable({
141
+ table,
142
+ noRowElement = <StandardNoRows />,
143
+ children,
144
+ shouldHideFooter,
145
+ ...props
146
+ }: React.ComponentProps<'div'> & {
147
+ table: StandardTable
148
+ noRowElement?: React.ReactNode
149
+ shouldHideFooter?: boolean
150
+ }) {
151
+ const rows = table.getRows()
152
+ return (
153
+ <div {...props}>
154
+ <div className="rounded-lg border border-gray-950/10 overflow-hidden">
155
+ <TableMain>
156
+ <TableRowContextProvider value={{ table }}>
157
+ {rows.length ? <TableBody>{children}</TableBody> : noRowElement}
158
+ </TableRowContextProvider>
159
+ {rows.length > 0 && !shouldHideFooter ? <TableFooter table={table} /> : null}
160
+ </TableMain>
161
+ </div>
162
+ </div>
163
+ )
164
+ }
165
+
166
+ function StandardNoRows() {
167
+ return (
168
+ <div className="text-center py-8">
169
+ <h3 className="mt-2 text-sm font-semibold text-gray-900">No results</h3>
170
+ <p className="mt-1 text-sm text-gray-500">No results were found matching your query.</p>
171
+ </div>
172
+ )
173
+ }
@@ -0,0 +1,155 @@
1
+ import type { StandardTable, TableLocalization, TableFilterLocalization } from './table-types'
2
+ import type { Iso } from 'iso-fns'
3
+
4
+ // =============================================================================
5
+ // Helpers
6
+ // =============================================================================
7
+
8
+ type Expect<T extends true> = T
9
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false
10
+
11
+ // =============================================================================
12
+ // Setup — concrete filter/sort configs
13
+ // =============================================================================
14
+
15
+ type StatusValue = 'active' | 'inactive'
16
+ type DateRangeValue = { startDate: Iso.Date; endDate: Iso.Date }
17
+
18
+ type FC = { status: StatusValue; dateRange: DateRangeValue }
19
+ type SC = { newest: null; oldest: null }
20
+
21
+ type MyTable = StandardTable<FC, SC>
22
+ type MyLocalization = TableLocalization<FC, SC>
23
+
24
+ // =============================================================================
25
+ // TableFilterLocalization discriminates on value type
26
+ // =============================================================================
27
+
28
+ // String union → DefaultTableFilter (has options array with typed values, no `type` discriminant)
29
+ const _selectFilter: TableFilterLocalization<StatusValue> = {
30
+ label: 'Status',
31
+ options: [{ label: 'Active', value: 'active' }, { label: 'Inactive', value: 'inactive' }]
32
+ }
33
+
34
+ // Date range → DateRangeTableFilter (type: 'date-range', no value-typed options)
35
+ const _dateFilter: TableFilterLocalization<DateRangeValue> = {
36
+ label: 'Date range',
37
+ type: 'date-range'
38
+ }
39
+
40
+ // date-range value rejects DefaultTableFilter form
41
+ const _dateFilterRejectsSelect: TableFilterLocalization<DateRangeValue> = {
42
+ label: 'Date range',
43
+ type: 'date-range',
44
+ // @ts-expect-error — DefaultTableFilter 'options' shape is not valid for date-range
45
+ options: [{ label: 'Last 7 days', value: 'last7' }]
46
+ }
47
+
48
+ // string-union value rejects DateRangeTableFilter form
49
+ const _selectFilterRejectsDateRange: TableFilterLocalization<StatusValue> = {
50
+ label: 'Status',
51
+ // @ts-expect-error — type: 'date-range' is not valid for a select filter
52
+ type: 'date-range'
53
+ }
54
+
55
+ // =============================================================================
56
+ // TableLocalization requires the exact filter + sort keys
57
+ // =============================================================================
58
+
59
+ type _FiltersHasKeys = Expect<Equal<keyof MyLocalization['filters'], 'status' | 'dateRange'>>
60
+ type _SortsHasKeys = Expect<Equal<keyof MyLocalization['sorts'], 'newest' | 'oldest'>>
61
+ type _SortShape = Expect<Equal<MyLocalization['sorts']['newest'], { label: string }>>
62
+
63
+ // =============================================================================
64
+ // Full valid localization object
65
+ // =============================================================================
66
+
67
+ const _valid: MyLocalization = {
68
+ filters: {
69
+ status: {
70
+ label: 'Status',
71
+ options: [
72
+ { label: 'Active', value: 'active' },
73
+ { label: 'Inactive', value: 'inactive' }
74
+ ]
75
+ },
76
+ dateRange: {
77
+ label: 'Date range',
78
+ type: 'date-range'
79
+ }
80
+ },
81
+ sorts: {
82
+ newest: { label: 'Newest' },
83
+ oldest: { label: 'Oldest' }
84
+ },
85
+ searchPlaceholder: 'Search...'
86
+ }
87
+
88
+ // =============================================================================
89
+ // Negative cases
90
+ // =============================================================================
91
+
92
+ declare function checkLocalization(l: MyLocalization): void
93
+
94
+ // Missing a required filter key
95
+ checkLocalization({
96
+ // @ts-expect-error — 'dateRange' is missing from filters
97
+ filters: {
98
+ status: { label: 'Status', options: [{ label: 'Active', value: 'active' }] }
99
+ },
100
+ sorts: { newest: { label: 'Newest' }, oldest: { label: 'Oldest' } }
101
+ })
102
+
103
+ // Missing a required sort key
104
+ checkLocalization({
105
+ filters: {
106
+ status: { label: 'Status', options: [{ label: 'Active', value: 'active' }] },
107
+ dateRange: { label: 'Date range', type: 'date-range' }
108
+ },
109
+ // @ts-expect-error — 'oldest' is missing from sorts
110
+ sorts: { newest: { label: 'Newest' } }
111
+ })
112
+
113
+ // Wrong option value type — status options must use StatusValue
114
+ checkLocalization({
115
+ filters: {
116
+ status: {
117
+ label: 'Status',
118
+ // @ts-expect-error — 'unknown' is not assignable to StatusValue
119
+ options: [{ label: 'Unknown', value: 'unknown' }]
120
+ },
121
+ dateRange: { label: 'Date range', type: 'date-range' }
122
+ },
123
+ sorts: { newest: { label: 'Newest' }, oldest: { label: 'Oldest' } }
124
+ })
125
+
126
+ // Wrong filter form — dateRange requires DateRangeTableFilter, not DefaultTableFilter
127
+ checkLocalization({
128
+ filters: {
129
+ status: { label: 'Status', options: [{ label: 'Active', value: 'active' }] },
130
+ dateRange: {
131
+ label: 'Date range',
132
+ // @ts-expect-error — select-style options array is not valid for date-range filter
133
+ options: [{ label: 'Last 7 days', value: 'last7' }]
134
+ }
135
+ },
136
+ sorts: { newest: { label: 'Newest' }, oldest: { label: 'Oldest' } }
137
+ })
138
+
139
+ // =============================================================================
140
+ // TableContainer generic inference — localization is derived from table type
141
+ // =============================================================================
142
+
143
+ type InferredLocalization<T extends StandardTable> = TableLocalization<
144
+ ReturnType<T['getFilterConfig']>,
145
+ ReturnType<T['getSortConfig']>
146
+ >
147
+
148
+ // Inferred localization from MyTable matches the manually specified one
149
+ type _Inferred = Expect<Equal<InferredLocalization<MyTable>, MyLocalization>>
150
+
151
+ // A table with no filters/sorts infers empty objects
152
+ type EmptyTable = StandardTable<{}, {}>
153
+ type EmptyLocalization = InferredLocalization<EmptyTable>
154
+ type _EmptyFilters = Expect<Equal<EmptyLocalization['filters'], {}>>
155
+ type _EmptySorts = Expect<Equal<EmptyLocalization['sorts'], {}>>