@prairielearn/ui 1.4.0 → 1.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @prairielearn/ui
2
2
 
3
+ ## 1.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 20f25f7: Add nuqs utilities for URL query state management with server-side rendering support. Includes `NuqsAdapter` component and TanStack Table state parsers (`parseAsSortingState`, `parseAsColumnVisibilityStateWithColumns`, `parseAsColumnPinningState`, `parseAsNumericFilter`).
8
+
9
+ ## 1.5.0
10
+
11
+ ### Minor Changes
12
+
13
+ - bd5f2a1: Add a generic PresetFilterDropdown for customizable multi-column filters
14
+
3
15
  ## 1.4.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # `@prairielearn/ui`
2
2
 
3
- UI components and styles shared between PrairieLearn and PrairieTest.
3
+ UI components, utilities, and styles shared between PrairieLearn and PrairieTest.
4
4
 
5
- ## Examples
5
+ ## UI Component Examples
6
6
 
7
7
  ### TanstackTableCard
8
8
 
@@ -106,3 +106,61 @@ const tableOptions = {
106
106
  },
107
107
  };
108
108
  ```
109
+
110
+ ## nuqs Utilities
111
+
112
+ This package provides utilities for integrating [nuqs](https://nuqs.47ng.com/) (type-safe URL query state management) with server-side rendering and TanStack Table.
113
+
114
+ ### NuqsAdapter
115
+
116
+ `nuqs` needs to be aware of the current state of the URL search parameters during both server-side and client-side rendering. The `NuqsAdapter` component handles this by using a custom adapter on the server that reads from a provided `search` prop, while on the client it uses nuqs's built-in React adapter that reads directly from `location.search`.
117
+
118
+ ```tsx
119
+ import { NuqsAdapter } from '@prairielearn/ui';
120
+
121
+ // Wrap your component that uses nuqs hooks
122
+ <NuqsAdapter search={new URL(req.url).search}>
123
+ <MyTableComponent />
124
+ </NuqsAdapter>;
125
+ ```
126
+
127
+ ### TanStack Table State Parsers
128
+
129
+ The package provides custom parsers for syncing TanStack Table state with URL query parameters:
130
+
131
+ - **`parseAsSortingState`**: Syncs table sorting state with the URL. Format: `col:asc` or `col1:asc,col2:desc` for multi-column sorting.
132
+ - **`parseAsColumnVisibilityStateWithColumns(allColumns, defaultValueRef?)`**: Syncs column visibility. Parses comma-separated visible column IDs.
133
+ - **`parseAsColumnPinningState`**: Syncs left-pinned columns. Format: `col1,col2,col3`.
134
+ - **`parseAsNumericFilter`**: Syncs numeric filter values. URL format: `gte_5`, `lte_10`, `gt_3`, `lt_7`, `eq_5`, `empty`.
135
+
136
+ ```tsx
137
+ import {
138
+ parseAsSortingState,
139
+ parseAsColumnVisibilityStateWithColumns,
140
+ parseAsColumnPinningState,
141
+ parseAsNumericFilter,
142
+ } from '@prairielearn/ui';
143
+ import { useQueryState } from 'nuqs';
144
+
145
+ // Sorting state synced to URL
146
+ const [sorting, setSorting] = useQueryState('sort', parseAsSortingState.withDefault([]));
147
+
148
+ // Column visibility synced to URL
149
+ const allColumns = ['name', 'email', 'status'];
150
+ const [columnVisibility, setColumnVisibility] = useQueryState(
151
+ 'cols',
152
+ parseAsColumnVisibilityStateWithColumns(allColumns).withDefault({}),
153
+ );
154
+
155
+ // Column pinning synced to URL
156
+ const [columnPinning, setColumnPinning] = useQueryState(
157
+ 'pin',
158
+ parseAsColumnPinningState.withDefault({ left: [], right: [] }),
159
+ );
160
+
161
+ // Numeric filter synced to URL
162
+ const [scoreFilter, setScoreFilter] = useQueryState(
163
+ 'score',
164
+ parseAsNumericFilter.withDefault({ filterValue: '', emptyOnly: false }),
165
+ );
166
+ ```
@@ -11,7 +11,7 @@ import { type JSX } from 'preact/compat';
11
11
  */
12
12
  export declare function CategoricalColumnFilter<TData, TValue>({ column, allColumnValues, renderValueLabel, }: {
13
13
  column: Column<TData, TValue>;
14
- allColumnValues: TValue[];
14
+ allColumnValues: TValue[] | readonly TValue[];
15
15
  renderValueLabel?: (props: {
16
16
  value: TValue;
17
17
  isSelected: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"CategoricalColumnFilter.d.ts","sourceRoot":"","sources":["../../src/components/CategoricalColumnFilter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAEpD,OAAO,EAAE,KAAK,GAAG,EAAqB,MAAM,eAAe,CAAC;AAiB5D;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,EACrD,MAAM,EACN,eAAe,EACf,gBAA0C,GAC3C,EAAE;IACD,MAAM,EAAE,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IAC9B,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,CAAA;KAAE,KAAK,GAAG,CAAC,OAAO,CAAC;CACnF,eAoIA"}
1
+ {"version":3,"file":"CategoricalColumnFilter.d.ts","sourceRoot":"","sources":["../../src/components/CategoricalColumnFilter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAEpD,OAAO,EAAE,KAAK,GAAG,EAAqB,MAAM,eAAe,CAAC;AAiB5D;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,EACrD,MAAM,EACN,eAAe,EACf,gBAA0C,GAC3C,EAAE;IACD,MAAM,EAAE,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IAC9B,eAAe,EAAE,MAAM,EAAE,GAAG,SAAS,MAAM,EAAE,CAAC;IAC9C,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,CAAA;KAAE,KAAK,GAAG,CAAC,OAAO,CAAC;CACnF,eAoIA"}
@@ -1 +1 @@
1
- {"version":3,"file":"CategoricalColumnFilter.js","sourceRoot":"","sources":["../../src/components/CategoricalColumnFilter.tsx"],"names":[],"mappings":";AACA,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAY,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC5D,OAAO,QAAQ,MAAM,0BAA0B,CAAC;AAEhD,SAAS,eAAe,CACtB,eAAkB,EAClB,IAA2B,EAC3B,QAAwB;IAExB,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,OAAO,QAAQ,CAAC;IAClB,CAAC;IACD,OAAO,IAAI,GAAG,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAClE,CAAC;AAED,SAAS,uBAAuB,CAAI,EAAE,KAAK,EAAgB;IACzD,OAAO,eAAM,KAAK,EAAC,aAAa,YAAE,MAAM,CAAC,KAAK,CAAC,GAAQ,CAAC;AAC1D,CAAC;AACD;;;;;;;;GAQG;AACH,MAAM,UAAU,uBAAuB,CAAgB,EACrD,MAAM,EACN,eAAe,EACf,gBAAgB,GAAG,uBAAuB,GAK3C;IACC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAwB,SAAS,CAAC,CAAC;IAEnE,MAAM,QAAQ,GAAG,MAAM,CAAC,EAAE,CAAC;IAE3B,MAAM,KAAK,GACT,MAAM,CAAC,SAAS,CAAC,IAAI,EAAE,KAAK;QAC5B,CAAC,OAAO,MAAM,CAAC,SAAS,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAEtF,MAAM,kBAAkB,GAAG,MAAM,CAAC,cAAc,EAA0B,CAAC;IAE3E,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,EAAE;QAC5B,OAAO,eAAe,CAAC,eAAe,EAAE,IAAI,EAAE,IAAI,GAAG,CAAC,kBAAkB,CAAC,CAAC,CAAC;IAC7E,CAAC,EAAE,CAAC,IAAI,EAAE,eAAe,EAAE,kBAAkB,CAAC,CAAC,CAAC;IAEhD,MAAM,KAAK,GAAG,CAAC,OAA8B,EAAE,WAAwB,EAAE,EAAE;QACzE,MAAM,QAAQ,GAAG,eAAe,CAAC,eAAe,EAAE,OAAO,EAAE,WAAW,CAAC,CAAC;QACxE,OAAO,CAAC,OAAO,CAAC,CAAC;QACjB,MAAM,QAAQ,GAAG,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACtC,MAAM,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;IAClC,CAAC,CAAC;IAEF,MAAM,cAAc,GAAG,CAAC,KAAa,EAAE,EAAE;QACvC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC9B,IAAI,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YACnB,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACpB,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACjB,CAAC;QACD,KAAK,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACnB,CAAC,CAAC;IAEF,OAAO,CACL,MAAC,QAAQ,IAAC,KAAK,EAAC,KAAK,aACnB,KAAC,QAAQ,CAAC,MAAM,IACd,OAAO,EAAC,MAAM,EACd,KAAK,EAAC,gBAAgB,EACtB,EAAE,EAAE,UAAU,QAAQ,EAAE,gBACZ,UAAU,KAAK,CAAC,WAAW,EAAE,EAAE,EAC3C,KAAK,EAAE,UAAU,KAAK,CAAC,WAAW,EAAE,EAAE,YAEtC,YACE,KAAK,EAAE,IAAI,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,gBAAgB,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,iBAC3E,MAAM,GAClB,GACc,EAClB,MAAC,QAAQ,CAAC,IAAI,IAAC,KAAK,EAAC,KAAK,aACxB,eAAK,KAAK,EAAC,UAAU,aACnB,eAAK,KAAK,EAAC,wDAAwD,aACjE,cAAK,KAAK,EAAC,yBAAyB,YAAE,KAAK,GAAO,EAClD,iBACE,IAAI,EAAC,QAAQ,EACb,KAAK,EAAE,IAAI,CAAC,0CAA0C,EAAE;4CACtD,mDAAmD;4CACnD,2EAA2E;4CAC3E,SAAS,EAAE,QAAQ,CAAC,IAAI,KAAK,CAAC,IAAI,IAAI,KAAK,SAAS;yCACrD,CAAC,EACF,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,SAAS,EAAE,IAAI,GAAG,EAAE,CAAC,sBAGnC,IACL,EAEN,eAAK,KAAK,EAAC,mCAAmC,aAC5C,gBACE,IAAI,EAAC,OAAO,EACZ,KAAK,EAAC,WAAW,EACjB,IAAI,EAAE,UAAU,QAAQ,UAAU,EAClC,EAAE,EAAE,UAAU,QAAQ,UAAU,EAChC,YAAY,EAAC,KAAK,EAClB,OAAO,EAAE,IAAI,KAAK,SAAS,EAC3B,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,SAAS,EAAE,QAAQ,CAAC,GAC1C,EACF,gBAAO,KAAK,EAAC,yBAAyB,EAAC,GAAG,EAAE,UAAU,QAAQ,UAAU,YACtE,gBAAM,KAAK,EAAC,aAAa,aACtB,IAAI,KAAK,SAAS,IAAI,YAAG,KAAK,EAAC,qBAAqB,iBAAa,MAAM,GAAG,eAEtE,GACD,EAER,gBACE,IAAI,EAAC,OAAO,EACZ,KAAK,EAAC,WAAW,EACjB,IAAI,EAAE,UAAU,QAAQ,UAAU,EAClC,EAAE,EAAE,UAAU,QAAQ,UAAU,EAChC,YAAY,EAAC,KAAK,EAClB,OAAO,EAAE,IAAI,KAAK,SAAS,EAC3B,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,SAAS,EAAE,QAAQ,CAAC,GAC1C,EACF,gBAAO,KAAK,EAAC,yBAAyB,EAAC,GAAG,EAAE,UAAU,QAAQ,UAAU,YACtE,gBAAM,KAAK,EAAC,aAAa,aACtB,IAAI,KAAK,SAAS,IAAI,YAAG,KAAK,EAAC,qBAAqB,iBAAa,MAAM,GAAG,eAEtE,GACD,IACJ,IACF,EAEN,cACE,KAAK,EAAC,6BAA6B,EACnC,KAAK,EAAE;4BACL,qEAAqE;4BACrE,gCAAgC;4BAChC,oBAAoB,EAAE,aAAa;yBACpC,YAEA,eAAe,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;4BAC7B,MAAM,UAAU,GAAG,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;4BACvC,OAAO,CACL,cAAiB,KAAK,EAAC,iDAAiD,YACtE,eAAK,KAAK,EAAC,YAAY,aACrB,gBACE,KAAK,EAAC,kBAAkB,EACxB,IAAI,EAAC,UAAU,EACf,OAAO,EAAE,UAAU,EACnB,EAAE,EAAE,GAAG,QAAQ,IAAI,KAAK,EAAE,EAC1B,QAAQ,EAAE,GAAG,EAAE,CAAC,cAAc,CAAC,KAAK,CAAC,GACrC,EACF,gBAAO,KAAK,EAAC,4BAA4B,EAAC,GAAG,EAAE,GAAG,QAAQ,IAAI,KAAK,EAAE,YAClE,gBAAgB,CAAC;gDAChB,KAAK;gDACL,UAAU;6CACX,CAAC,GACI,IACJ,IAfE,KAAK,CAgBT,CACP,CAAC;wBACJ,CAAC,CAAC,GACE,IACQ,IACP,CACZ,CAAC;AACJ,CAAC","sourcesContent":["import type { Column } from '@tanstack/react-table';\nimport clsx from 'clsx';\nimport { type JSX, useMemo, useState } from 'preact/compat';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\nfunction computeSelected<T extends readonly any[]>(\n allStatusValues: T,\n mode: 'include' | 'exclude',\n selected: Set<T[number]>,\n) {\n if (mode === 'include') {\n return selected;\n }\n return new Set(allStatusValues.filter((s) => !selected.has(s)));\n}\n\nfunction defaultRenderValueLabel<T>({ value }: { value: T }) {\n return <span class=\"text-nowrap\">{String(value)}</span>;\n}\n/**\n * A component that allows the user to filter a categorical column.\n * The filter mode always defaults to \"include\".\n *\n * @param params\n * @param params.column - The TanStack Table column object\n * @param params.allColumnValues - The values to filter by\n * @param params.renderValueLabel - A function that renders the label for a value\n */\nexport function CategoricalColumnFilter<TData, TValue>({\n column,\n allColumnValues,\n renderValueLabel = defaultRenderValueLabel,\n}: {\n column: Column<TData, TValue>;\n allColumnValues: TValue[];\n renderValueLabel?: (props: { value: TValue; isSelected: boolean }) => JSX.Element;\n}) {\n const [mode, setMode] = useState<'include' | 'exclude'>('include');\n\n const columnId = column.id;\n\n const label =\n column.columnDef.meta?.label ??\n (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);\n\n const columnValuesFilter = column.getFilterValue() as TValue[] | undefined;\n\n const selected = useMemo(() => {\n return computeSelected(allColumnValues, mode, new Set(columnValuesFilter));\n }, [mode, allColumnValues, columnValuesFilter]);\n\n const apply = (newMode: 'include' | 'exclude', newSelected: Set<TValue>) => {\n const selected = computeSelected(allColumnValues, newMode, newSelected);\n setMode(newMode);\n const newValue = Array.from(selected);\n column.setFilterValue(newValue);\n };\n\n const toggleSelected = (value: TValue) => {\n const set = new Set(selected);\n if (set.has(value)) {\n set.delete(value);\n } else {\n set.add(value);\n }\n apply(mode, set);\n };\n\n return (\n <Dropdown align=\"end\">\n <Dropdown.Toggle\n variant=\"link\"\n class=\"text-muted p-0\"\n id={`filter-${columnId}`}\n aria-label={`Filter ${label.toLowerCase()}`}\n title={`Filter ${label.toLowerCase()}`}\n >\n <i\n class={clsx('bi', selected.size > 0 ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel')}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu class=\"p-0\">\n <div class=\"p-3 pb-0\">\n <div class=\"d-flex align-items-center justify-content-between mb-2\">\n <div class=\"fw-semibold text-nowrap\">{label}</div>\n <button\n type=\"button\"\n class={clsx('btn btn-link btn-sm text-decoration-none', {\n // Hide the clear button if no filters are applied.\n // Use `visibility` instead of conditional rendering to avoid layout shift.\n invisible: selected.size === 0 && mode === 'include',\n })}\n onClick={() => apply('include', new Set())}\n >\n Clear\n </button>\n </div>\n\n <div class=\"btn-group btn-group-sm w-100 mb-2\">\n <input\n type=\"radio\"\n class=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-include`}\n autocomplete=\"off\"\n checked={mode === 'include'}\n onChange={() => apply('include', selected)}\n />\n <label class=\"btn btn-outline-primary\" for={`filter-${columnId}-include`}>\n <span class=\"text-nowrap\">\n {mode === 'include' && <i class=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Include\n </span>\n </label>\n\n <input\n type=\"radio\"\n class=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-exclude`}\n autocomplete=\"off\"\n checked={mode === 'exclude'}\n onChange={() => apply('exclude', selected)}\n />\n <label class=\"btn btn-outline-primary\" for={`filter-${columnId}-exclude`}>\n <span class=\"text-nowrap\">\n {mode === 'exclude' && <i class=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Exclude\n </span>\n </label>\n </div>\n </div>\n\n <div\n class=\"list-group list-group-flush\"\n style={{\n // This is needed to prevent the last item's background from covering\n // the dropdown's border radius.\n '--bs-list-group-bg': 'transparent',\n }}\n >\n {allColumnValues.map((value) => {\n const isSelected = selected.has(value);\n return (\n <div key={value} class=\"list-group-item d-flex align-items-center gap-3\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n checked={isSelected}\n id={`${columnId}-${value}`}\n onChange={() => toggleSelected(value)}\n />\n <label class=\"form-check-label fw-normal\" for={`${columnId}-${value}`}>\n {renderValueLabel({\n value,\n isSelected,\n })}\n </label>\n </div>\n </div>\n );\n })}\n </div>\n </Dropdown.Menu>\n </Dropdown>\n );\n}\n"]}
1
+ {"version":3,"file":"CategoricalColumnFilter.js","sourceRoot":"","sources":["../../src/components/CategoricalColumnFilter.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { Column } from '@tanstack/react-table';\nimport clsx from 'clsx';\nimport { type JSX, useMemo, useState } from 'preact/compat';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\nfunction computeSelected<T extends readonly any[]>(\n allStatusValues: T,\n mode: 'include' | 'exclude',\n selected: Set<T[number]>,\n) {\n if (mode === 'include') {\n return selected;\n }\n return new Set(allStatusValues.filter((s) => !selected.has(s)));\n}\n\nfunction defaultRenderValueLabel<T>({ value }: { value: T }) {\n return <span class=\"text-nowrap\">{String(value)}</span>;\n}\n/**\n * A component that allows the user to filter a categorical column.\n * The filter mode always defaults to \"include\".\n *\n * @param params\n * @param params.column - The TanStack Table column object\n * @param params.allColumnValues - The values to filter by\n * @param params.renderValueLabel - A function that renders the label for a value\n */\nexport function CategoricalColumnFilter<TData, TValue>({\n column,\n allColumnValues,\n renderValueLabel = defaultRenderValueLabel,\n}: {\n column: Column<TData, TValue>;\n allColumnValues: TValue[] | readonly TValue[];\n renderValueLabel?: (props: { value: TValue; isSelected: boolean }) => JSX.Element;\n}) {\n const [mode, setMode] = useState<'include' | 'exclude'>('include');\n\n const columnId = column.id;\n\n const label =\n column.columnDef.meta?.label ??\n (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);\n\n const columnValuesFilter = column.getFilterValue() as TValue[] | undefined;\n\n const selected = useMemo(() => {\n return computeSelected(allColumnValues, mode, new Set(columnValuesFilter));\n }, [mode, allColumnValues, columnValuesFilter]);\n\n const apply = (newMode: 'include' | 'exclude', newSelected: Set<TValue>) => {\n const selected = computeSelected(allColumnValues, newMode, newSelected);\n setMode(newMode);\n const newValue = Array.from(selected);\n column.setFilterValue(newValue);\n };\n\n const toggleSelected = (value: TValue) => {\n const set = new Set(selected);\n if (set.has(value)) {\n set.delete(value);\n } else {\n set.add(value);\n }\n apply(mode, set);\n };\n\n return (\n <Dropdown align=\"end\">\n <Dropdown.Toggle\n variant=\"link\"\n class=\"text-muted p-0\"\n id={`filter-${columnId}`}\n aria-label={`Filter ${label.toLowerCase()}`}\n title={`Filter ${label.toLowerCase()}`}\n >\n <i\n class={clsx('bi', selected.size > 0 ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel')}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu class=\"p-0\">\n <div class=\"p-3 pb-0\">\n <div class=\"d-flex align-items-center justify-content-between mb-2\">\n <div class=\"fw-semibold text-nowrap\">{label}</div>\n <button\n type=\"button\"\n class={clsx('btn btn-link btn-sm text-decoration-none', {\n // Hide the clear button if no filters are applied.\n // Use `visibility` instead of conditional rendering to avoid layout shift.\n invisible: selected.size === 0 && mode === 'include',\n })}\n onClick={() => apply('include', new Set())}\n >\n Clear\n </button>\n </div>\n\n <div class=\"btn-group btn-group-sm w-100 mb-2\">\n <input\n type=\"radio\"\n class=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-include`}\n autocomplete=\"off\"\n checked={mode === 'include'}\n onChange={() => apply('include', selected)}\n />\n <label class=\"btn btn-outline-primary\" for={`filter-${columnId}-include`}>\n <span class=\"text-nowrap\">\n {mode === 'include' && <i class=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Include\n </span>\n </label>\n\n <input\n type=\"radio\"\n class=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-exclude`}\n autocomplete=\"off\"\n checked={mode === 'exclude'}\n onChange={() => apply('exclude', selected)}\n />\n <label class=\"btn btn-outline-primary\" for={`filter-${columnId}-exclude`}>\n <span class=\"text-nowrap\">\n {mode === 'exclude' && <i class=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Exclude\n </span>\n </label>\n </div>\n </div>\n\n <div\n class=\"list-group list-group-flush\"\n style={{\n // This is needed to prevent the last item's background from covering\n // the dropdown's border radius.\n '--bs-list-group-bg': 'transparent',\n }}\n >\n {allColumnValues.map((value) => {\n const isSelected = selected.has(value);\n return (\n <div key={value} class=\"list-group-item d-flex align-items-center gap-3\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n checked={isSelected}\n id={`${columnId}-${value}`}\n onChange={() => toggleSelected(value)}\n />\n <label class=\"form-check-label fw-normal\" for={`${columnId}-${value}`}>\n {renderValueLabel({\n value,\n isSelected,\n })}\n </label>\n </div>\n </div>\n );\n })}\n </div>\n </Dropdown.Menu>\n </Dropdown>\n );\n}\n"]}
@@ -0,0 +1,19 @@
1
+ import type { ColumnFiltersState, Table } from '@tanstack/react-table';
2
+ /**
3
+ * A dropdown component that allows users to select from preset filter configurations.
4
+ * The selected state is derived from the table's current column filters.
5
+ * If no preset matches, a "Custom" option is shown as selected.
6
+ *
7
+ * Currently, this component expects that the filters states are arrays.
8
+ */
9
+ export declare function PresetFilterDropdown<OptionName extends string, TData>({ table, options, label, onSelect, }: {
10
+ /** The TanStack Table instance */
11
+ table: Table<TData>;
12
+ /** Mapping of option names to their filter configurations */
13
+ options: Record<OptionName, ColumnFiltersState>;
14
+ /** Label prefix for the dropdown button (e.g., "Filter") */
15
+ label?: string;
16
+ /** Callback when an option is selected, useful for side effects like column visibility */
17
+ onSelect?: (optionName: OptionName) => void;
18
+ }): import("preact/compat").JSX.Element;
19
+ //# sourceMappingURL=PresetFilterDropdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PresetFilterDropdown.d.ts","sourceRoot":"","sources":["../../src/components/PresetFilterDropdown.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AAyDvE;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,SAAS,MAAM,EAAE,KAAK,EAAE,EACrE,KAAK,EACL,OAAO,EACP,KAAgB,EAChB,QAAQ,GACT,EAAE;IACD,kCAAkC;IAClC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;IACpB,6DAA6D;IAC7D,OAAO,EAAE,MAAM,CAAC,UAAU,EAAE,kBAAkB,CAAC,CAAC;IAChD,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,CAAC;CAC7C,uCA4EA"}
@@ -0,0 +1,93 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@prairielearn/preact-cjs/jsx-runtime";
2
+ import { useMemo } from 'preact/compat';
3
+ import { ButtonGroup, Dropdown } from 'react-bootstrap';
4
+ /**
5
+ * Compares two filter values for deep equality using JSON serialization.
6
+ */
7
+ function filtersEqual(a, b) {
8
+ return JSON.stringify(a) === JSON.stringify(b);
9
+ }
10
+ /**
11
+ * Extracts all unique column IDs referenced across all preset options.
12
+ */
13
+ function getRelevantColumnIds(options) {
14
+ const columnIds = new Set();
15
+ for (const filters of Object.values(options)) {
16
+ for (const filter of filters) {
17
+ columnIds.add(filter.id);
18
+ }
19
+ }
20
+ return columnIds;
21
+ }
22
+ /**
23
+ * Gets the current filter values for the relevant columns from the table.
24
+ */
25
+ function getRelevantFilters(table, relevantColumnIds) {
26
+ const allFilters = table.getState().columnFilters;
27
+ return allFilters.filter((f) => relevantColumnIds.has(f.id));
28
+ }
29
+ /**
30
+ * Checks if the current filters match a preset's filters.
31
+ * Both must have the same column IDs with equal values.
32
+ */
33
+ function filtersMatchPreset(current, preset) {
34
+ // If lengths differ, they don't match
35
+ if (current.length !== preset.length)
36
+ return false;
37
+ // For empty presets, current must also be empty
38
+ if (preset.length === 0)
39
+ return current.length === 0;
40
+ // Check that every preset filter exists in current with the same value
41
+ for (const presetFilter of preset) {
42
+ const currentFilter = current.find((f) => f.id === presetFilter.id);
43
+ if (!currentFilter || !filtersEqual(currentFilter.value, presetFilter.value)) {
44
+ return false;
45
+ }
46
+ }
47
+ return true;
48
+ }
49
+ /**
50
+ * A dropdown component that allows users to select from preset filter configurations.
51
+ * The selected state is derived from the table's current column filters.
52
+ * If no preset matches, a "Custom" option is shown as selected.
53
+ *
54
+ * Currently, this component expects that the filters states are arrays.
55
+ */
56
+ export function PresetFilterDropdown({ table, options, label = 'Filter', onSelect, }) {
57
+ const relevantColumnIds = getRelevantColumnIds(options);
58
+ const currentRelevantFilters = useMemo(() => getRelevantFilters(table, relevantColumnIds), [table, relevantColumnIds]);
59
+ // Find which option matches the current filters
60
+ const selectedOption = useMemo(() => {
61
+ for (const [optionName, presetFilters] of Object.entries(options)) {
62
+ if (filtersMatchPreset(currentRelevantFilters, presetFilters)) {
63
+ return optionName;
64
+ }
65
+ }
66
+ return null; // No preset matches - custom filter state
67
+ }, [options, currentRelevantFilters]);
68
+ const handleOptionClick = (optionName) => {
69
+ const presetFilters = options[optionName];
70
+ // Get current filters, removing any that are in our relevant columns
71
+ const currentFilters = table.getState().columnFilters;
72
+ const preservedFilters = currentFilters.filter((f) => !relevantColumnIds.has(f.id));
73
+ // For columns not in the preset, explicitly set empty filter to clear them
74
+ // This ensures the table's onColumnFiltersChange handler can sync the cleared state
75
+ const clearedFilters = Array.from(relevantColumnIds)
76
+ .filter((colId) => !presetFilters.some((f) => f.id === colId))
77
+ .map((colId) => ({
78
+ id: colId,
79
+ // TODO: This expects that we are only clearing filters whose state is an array.
80
+ value: [],
81
+ }));
82
+ // Combine preserved filters with the new preset filters and cleared filters
83
+ const newFilters = [...preservedFilters, ...presetFilters, ...clearedFilters];
84
+ table.setColumnFilters(newFilters);
85
+ onSelect?.(optionName);
86
+ };
87
+ const displayLabel = selectedOption ?? 'Custom';
88
+ return (_jsxs(Dropdown, { as: ButtonGroup, children: [_jsxs(Dropdown.Toggle, { variant: "tanstack-table", children: [_jsx("i", { class: "bi bi-funnel me-2", "aria-hidden": "true" }), label, ": ", displayLabel] }), _jsxs(Dropdown.Menu, { children: [Object.keys(options).map((optionName) => {
89
+ const isSelected = selectedOption === optionName;
90
+ return (_jsxs(Dropdown.Item, { as: "button", type: "button", active: isSelected, onClick: () => handleOptionClick(optionName), children: [_jsx("i", { class: `bi ${isSelected ? 'bi-check-circle-fill' : 'bi-circle'} me-2` }), optionName] }, optionName));
91
+ }), selectedOption === null && (_jsxs(Dropdown.Item, { as: "button", type: "button", active: true, disabled: true, children: [_jsx("i", { class: "bi bi-check-circle-fill me-2" }), "Custom"] }))] })] }));
92
+ }
93
+ //# sourceMappingURL=PresetFilterDropdown.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PresetFilterDropdown.js","sourceRoot":"","sources":["../../src/components/PresetFilterDropdown.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { ColumnFiltersState, Table } from '@tanstack/react-table';\nimport { useMemo } from 'preact/compat';\nimport { ButtonGroup, Dropdown } from 'react-bootstrap';\n\n/**\n * Compares two filter values for deep equality using JSON serialization.\n */\nfunction filtersEqual(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b);\n}\n\n/**\n * Extracts all unique column IDs referenced across all preset options.\n */\nfunction getRelevantColumnIds(options: Record<string, ColumnFiltersState>): Set<string> {\n const columnIds = new Set<string>();\n for (const filters of Object.values(options)) {\n for (const filter of filters) {\n columnIds.add(filter.id);\n }\n }\n return columnIds;\n}\n\n/**\n * Gets the current filter values for the relevant columns from the table.\n */\nfunction getRelevantFilters<TData>(\n table: Table<TData>,\n relevantColumnIds: Set<string>,\n): ColumnFiltersState {\n const allFilters = table.getState().columnFilters;\n return allFilters.filter((f) => relevantColumnIds.has(f.id));\n}\n\n/**\n * Checks if the current filters match a preset's filters.\n * Both must have the same column IDs with equal values.\n */\nfunction filtersMatchPreset(current: ColumnFiltersState, preset: ColumnFiltersState): boolean {\n // If lengths differ, they don't match\n if (current.length !== preset.length) return false;\n\n // For empty presets, current must also be empty\n if (preset.length === 0) return current.length === 0;\n\n // Check that every preset filter exists in current with the same value\n for (const presetFilter of preset) {\n const currentFilter = current.find((f) => f.id === presetFilter.id);\n if (!currentFilter || !filtersEqual(currentFilter.value, presetFilter.value)) {\n return false;\n }\n }\n\n return true;\n}\n\n/**\n * A dropdown component that allows users to select from preset filter configurations.\n * The selected state is derived from the table's current column filters.\n * If no preset matches, a \"Custom\" option is shown as selected.\n *\n * Currently, this component expects that the filters states are arrays.\n */\nexport function PresetFilterDropdown<OptionName extends string, TData>({\n table,\n options,\n label = 'Filter',\n onSelect,\n}: {\n /** The TanStack Table instance */\n table: Table<TData>;\n /** Mapping of option names to their filter configurations */\n options: Record<OptionName, ColumnFiltersState>;\n /** Label prefix for the dropdown button (e.g., \"Filter\") */\n label?: string;\n /** Callback when an option is selected, useful for side effects like column visibility */\n onSelect?: (optionName: OptionName) => void;\n}) {\n const relevantColumnIds = getRelevantColumnIds(options);\n\n const currentRelevantFilters = useMemo(\n () => getRelevantFilters(table, relevantColumnIds),\n [table, relevantColumnIds],\n );\n\n // Find which option matches the current filters\n const selectedOption = useMemo<OptionName | null>(() => {\n for (const [optionName, presetFilters] of Object.entries(options)) {\n if (filtersMatchPreset(currentRelevantFilters, presetFilters as ColumnFiltersState)) {\n return optionName as OptionName;\n }\n }\n return null; // No preset matches - custom filter state\n }, [options, currentRelevantFilters]);\n\n const handleOptionClick = (optionName: OptionName) => {\n const presetFilters = options[optionName];\n\n // Get current filters, removing any that are in our relevant columns\n const currentFilters = table.getState().columnFilters;\n const preservedFilters = currentFilters.filter((f) => !relevantColumnIds.has(f.id));\n\n // For columns not in the preset, explicitly set empty filter to clear them\n // This ensures the table's onColumnFiltersChange handler can sync the cleared state\n const clearedFilters = Array.from(relevantColumnIds)\n .filter((colId) => !presetFilters.some((f) => f.id === colId))\n .map((colId) => ({\n id: colId,\n // TODO: This expects that we are only clearing filters whose state is an array.\n value: [],\n }));\n\n // Combine preserved filters with the new preset filters and cleared filters\n const newFilters = [...preservedFilters, ...presetFilters, ...clearedFilters];\n table.setColumnFilters(newFilters);\n\n onSelect?.(optionName);\n };\n\n const displayLabel = selectedOption ?? 'Custom';\n\n return (\n <Dropdown as={ButtonGroup}>\n <Dropdown.Toggle variant=\"tanstack-table\">\n <i class=\"bi bi-funnel me-2\" aria-hidden=\"true\" />\n {label}: {displayLabel}\n </Dropdown.Toggle>\n <Dropdown.Menu>\n {Object.keys(options).map((optionName) => {\n const isSelected = selectedOption === optionName;\n return (\n <Dropdown.Item\n key={optionName}\n as=\"button\"\n type=\"button\"\n active={isSelected}\n onClick={() => handleOptionClick(optionName as OptionName)}\n >\n <i class={`bi ${isSelected ? 'bi-check-circle-fill' : 'bi-circle'} me-2`} />\n {optionName}\n </Dropdown.Item>\n );\n })}\n {/* Show Custom option only when no preset matches */}\n {selectedOption === null && (\n <Dropdown.Item as=\"button\" type=\"button\" active disabled>\n <i class=\"bi bi-check-circle-fill me-2\" />\n Custom\n </Dropdown.Item>\n )}\n </Dropdown.Menu>\n </Dropdown>\n );\n}\n"]}
@@ -0,0 +1,52 @@
1
+ import type { ColumnPinningState, SortingState, VisibilityState } from '@tanstack/table-core';
2
+ import React from 'preact/compat';
3
+ import type { NumericColumnFilterValue } from './NumericInputColumnFilter.js';
4
+ /**
5
+ * `nuqs` needs to be aware of the current state of the URL search parameters
6
+ * during both server-side and client-side rendering. To make this work with
7
+ * our server-side rendering setup, we use a custom adapter that should be
8
+ * provided with the value of `new URL(...).search` on the server side. On the
9
+ * client, we use `NuqsReactAdapter`, which will read directly from `location.search`.
10
+ */
11
+ export declare function NuqsAdapter({ children, search }: {
12
+ children: React.ReactNode;
13
+ search: string;
14
+ }): React.JSX.Element;
15
+ /**
16
+ * Parses and serializes TanStack Table SortingState to/from a URL query string.
17
+ * Used for reflecting table sort order in the URL.
18
+ *
19
+ * Example: `sort=col:asc` <-> `[{ id: 'col', desc: false }]`
20
+ */
21
+ export declare const parseAsSortingState: import("nuqs").SingleParserBuilder<SortingState>;
22
+ /**
23
+ * Returns a parser for TanStack Table VisibilityState for a given set of columns.
24
+ * Parses a comma-separated list of visible columns from a query string, e.g. 'a,b'.
25
+ * Serializes to a comma-separated list of visible columns, omitting if all are visible.
26
+ * Used for reflecting column visibility in the URL.
27
+ *
28
+ * @param allColumns - Array of all column IDs
29
+ * @param defaultValueRef - A ref object with a `current` property that contains the default visibility state.
30
+ */
31
+ export declare function parseAsColumnVisibilityStateWithColumns(allColumns: string[], defaultValueRef?: React.RefObject<VisibilityState>): import("nuqs").SingleParserBuilder<VisibilityState>;
32
+ /**
33
+ * Parses and serializes TanStack Table ColumnPinningState to/from a URL query string.
34
+ * Used for reflecting pinned columns in the URL.
35
+ *
36
+ * Right pins aren't supported; an empty array is always returned to allow
37
+ * this hook's value to be used directly in `state.columnPinning` in `useReactTable`.
38
+ *
39
+ * Example: `a,b` <-> `{ left: ['a', 'b'], right: [] }`
40
+ */
41
+ export declare const parseAsColumnPinningState: import("nuqs").SingleParserBuilder<ColumnPinningState>;
42
+ /**
43
+ * Parses and serializes numeric filter strings to/from URL-friendly format.
44
+ * Used for numeric column filters with comparison operators.
45
+ *
46
+ * Internal format: `>=5`, `<=10`, `>3`, `<7`, `=5`
47
+ * URL format: `gte_5`, `lte_10`, `gt_3`, `lt_7`, `eq_5`, `empty`
48
+ *
49
+ * Example: `gte_5` <-> `>=5`
50
+ */
51
+ export declare const parseAsNumericFilter: import("nuqs").SingleParserBuilder<NumericColumnFilterValue>;
52
+ //# sourceMappingURL=nuqs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nuqs.d.ts","sourceRoot":"","sources":["../../src/components/nuqs.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAO9F,OAAO,KAAK,MAAM,eAAe,CAAC;AAElC,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AAgB9E;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE;IAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,qBAY9F;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,kDA8B9B,CAAC;AAEH;;;;;;;;GAQG;AACH,wBAAgB,uCAAuC,CACrD,UAAU,EAAE,MAAM,EAAE,EACpB,eAAe,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,eAAe,CAAC,uDAyCnD;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,wDAmBpC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,8DAoD/B,CAAC"}
@@ -0,0 +1,212 @@
1
+ import { jsx as _jsx } from "@prairielearn/preact-cjs/jsx-runtime";
2
+ import { createParser } from 'nuqs';
3
+ import { unstable_createAdapterProvider, } from 'nuqs/adapters/custom';
4
+ import { NuqsAdapter as NuqsReactAdapter } from 'nuqs/adapters/react';
5
+ import React from 'preact/compat';
6
+ const AdapterContext = React.createContext('');
7
+ function useExpressAdapterContext() {
8
+ const context = React.useContext(AdapterContext);
9
+ return {
10
+ searchParams: new URLSearchParams(context),
11
+ // This will never be called on the server, so it can be a no-op.
12
+ updateUrl: () => { },
13
+ };
14
+ }
15
+ const NuqsExpressAdapter = unstable_createAdapterProvider(useExpressAdapterContext);
16
+ /**
17
+ * `nuqs` needs to be aware of the current state of the URL search parameters
18
+ * during both server-side and client-side rendering. To make this work with
19
+ * our server-side rendering setup, we use a custom adapter that should be
20
+ * provided with the value of `new URL(...).search` on the server side. On the
21
+ * client, we use `NuqsReactAdapter`, which will read directly from `location.search`.
22
+ */
23
+ export function NuqsAdapter({ children, search }) {
24
+ if (typeof location === 'undefined') {
25
+ // We're rendering on the server.
26
+ return (_jsx(AdapterContext.Provider, { value: search, children: _jsx(NuqsExpressAdapter, { children: children }) }));
27
+ }
28
+ // We're rendering on the client.
29
+ return _jsx(NuqsReactAdapter, { children: children });
30
+ }
31
+ /**
32
+ * Parses and serializes TanStack Table SortingState to/from a URL query string.
33
+ * Used for reflecting table sort order in the URL.
34
+ *
35
+ * Example: `sort=col:asc` <-> `[{ id: 'col', desc: false }]`
36
+ */
37
+ export const parseAsSortingState = createParser({
38
+ parse(queryValue) {
39
+ if (!queryValue)
40
+ return [];
41
+ return queryValue
42
+ .split(',')
43
+ .map((part) => {
44
+ const [id, dir] = part.split(':');
45
+ if (!id)
46
+ return undefined;
47
+ if (dir === 'asc' || dir === 'desc') {
48
+ return { id, desc: dir === 'desc' };
49
+ }
50
+ return undefined;
51
+ })
52
+ .filter((v) => !!v);
53
+ },
54
+ serialize(value) {
55
+ // `null` indicates that the value should be omitted from the URL.
56
+ // @ts-expect-error - `null` is not assignable to type `string`.
57
+ if (value.length === 0)
58
+ return null;
59
+ return value
60
+ .filter((v) => v.id)
61
+ .map((v) => `${v.id}:${v.desc ? 'desc' : 'asc'}`)
62
+ .join(',');
63
+ },
64
+ eq(a, b) {
65
+ return (a.length === b.length &&
66
+ a.every((item, index) => item.id === b[index].id && item.desc === b[index].desc));
67
+ },
68
+ });
69
+ /**
70
+ * Returns a parser for TanStack Table VisibilityState for a given set of columns.
71
+ * Parses a comma-separated list of visible columns from a query string, e.g. 'a,b'.
72
+ * Serializes to a comma-separated list of visible columns, omitting if all are visible.
73
+ * Used for reflecting column visibility in the URL.
74
+ *
75
+ * @param allColumns - Array of all column IDs
76
+ * @param defaultValueRef - A ref object with a `current` property that contains the default visibility state.
77
+ */
78
+ export function parseAsColumnVisibilityStateWithColumns(allColumns, defaultValueRef) {
79
+ const parser = createParser({
80
+ parse(queryValue) {
81
+ const shown = queryValue.length > 0
82
+ ? new Set(queryValue.split(',').filter(Boolean))
83
+ : new Set(allColumns);
84
+ const result = {};
85
+ for (const col of allColumns) {
86
+ result[col] = shown.has(col);
87
+ }
88
+ return result;
89
+ },
90
+ serialize(value) {
91
+ // We can't use `eq` to compare with the current default values from the
92
+ // ref. `eq` appears to be used as part of an optimization to avoid rerenders
93
+ // if the column set hasn't changed, so if it return `true`, we wouldn't be
94
+ // able to update the actual visible columns after changing the defaults if
95
+ // the new column set is equal to the default set of columns.
96
+ //
97
+ // Instead, we rely on the (undocumented) ability of `serialize` to return
98
+ // `null` to indicate that the value should be omitted from the URL.
99
+ // @ts-expect-error - `null` is not assignable to type `string`.
100
+ if (parser.eq(value, defaultValueRef?.current ?? {}))
101
+ return null;
102
+ // Only output columns that are visible
103
+ const visible = Object.keys(value).filter((col) => value[col]);
104
+ return visible.join(',');
105
+ },
106
+ eq(value, defaultValue) {
107
+ const valueKeys = Object.keys(value);
108
+ const defaultValueKeys = Object.keys(defaultValue);
109
+ const result = valueKeys.length === defaultValueKeys.length &&
110
+ valueKeys.every((col) => value[col] === defaultValue[col]);
111
+ return result;
112
+ },
113
+ });
114
+ return parser;
115
+ }
116
+ /**
117
+ * Parses and serializes TanStack Table ColumnPinningState to/from a URL query string.
118
+ * Used for reflecting pinned columns in the URL.
119
+ *
120
+ * Right pins aren't supported; an empty array is always returned to allow
121
+ * this hook's value to be used directly in `state.columnPinning` in `useReactTable`.
122
+ *
123
+ * Example: `a,b` <-> `{ left: ['a', 'b'], right: [] }`
124
+ */
125
+ export const parseAsColumnPinningState = createParser({
126
+ parse(queryValue) {
127
+ if (!queryValue)
128
+ return { left: [], right: [] };
129
+ // Format: col1,col2,col3 (all left-pinned columns)
130
+ return {
131
+ left: queryValue.split(',').filter(Boolean),
132
+ right: [],
133
+ };
134
+ },
135
+ serialize(value) {
136
+ if (!value.left)
137
+ return '';
138
+ return value.left.join(',');
139
+ },
140
+ eq(a, b) {
141
+ const aLeft = Array.isArray(a.left) ? a.left : [];
142
+ const bLeft = Array.isArray(b.left) ? b.left : [];
143
+ if (aLeft.length !== bLeft.length)
144
+ return false;
145
+ return aLeft.every((v, i) => v === bLeft[i]);
146
+ },
147
+ });
148
+ /**
149
+ * Parses and serializes numeric filter strings to/from URL-friendly format.
150
+ * Used for numeric column filters with comparison operators.
151
+ *
152
+ * Internal format: `>=5`, `<=10`, `>3`, `<7`, `=5`
153
+ * URL format: `gte_5`, `lte_10`, `gt_3`, `lt_7`, `eq_5`, `empty`
154
+ *
155
+ * Example: `gte_5` <-> `>=5`
156
+ */
157
+ export const parseAsNumericFilter = createParser({
158
+ parse(queryValue) {
159
+ if (!queryValue)
160
+ return { filterValue: '', emptyOnly: false };
161
+ // Parse format: {operator}_{value}
162
+ const match = queryValue.match(/^(gte|lte|gt|lt|eq)_(.+)$/);
163
+ if (!match) {
164
+ if (queryValue === 'empty') {
165
+ return { filterValue: '', emptyOnly: true };
166
+ }
167
+ return { filterValue: '', emptyOnly: false };
168
+ }
169
+ const [, opCode, value] = match;
170
+ const opMap = {
171
+ gte: '>=',
172
+ lte: '<=',
173
+ gt: '>',
174
+ lt: '<',
175
+ eq: '=',
176
+ };
177
+ const operator = opMap[opCode];
178
+ if (!operator)
179
+ return { filterValue: '', emptyOnly: false };
180
+ return { filterValue: `${operator}${value}`, emptyOnly: false };
181
+ },
182
+ serialize(value) {
183
+ const { filterValue, emptyOnly } = value;
184
+ if (emptyOnly)
185
+ return 'empty';
186
+ if (filterValue.length === 0) {
187
+ return 'empty';
188
+ }
189
+ // Serialize format: internal (>=5) -> URL (gte_5)
190
+ const match = filterValue.match(/^(>=|<=|>|<|=)(.+)$/);
191
+ // @ts-expect-error - `null` is not assignable to type `string`.
192
+ if (!match)
193
+ return null;
194
+ const [, operator, val] = match;
195
+ const opMap = {
196
+ '>=': 'gte',
197
+ '<=': 'lte',
198
+ '>': 'gt',
199
+ '<': 'lt',
200
+ '=': 'eq',
201
+ };
202
+ const opCode = opMap[operator];
203
+ // @ts-expect-error - `null` is not assignable to type `string`.
204
+ if (!opCode)
205
+ return null;
206
+ return `${opCode}_${val}`;
207
+ },
208
+ eq(a, b) {
209
+ return a.filterValue === b.filterValue && a.emptyOnly === b.emptyOnly;
210
+ },
211
+ });
212
+ //# sourceMappingURL=nuqs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nuqs.js","sourceRoot":"","sources":["../../src/components/nuqs.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { ColumnPinningState, SortingState, VisibilityState } from '@tanstack/table-core';\nimport { createParser } from 'nuqs';\nimport {\n type unstable_AdapterInterface,\n unstable_createAdapterProvider,\n} from 'nuqs/adapters/custom';\nimport { NuqsAdapter as NuqsReactAdapter } from 'nuqs/adapters/react';\nimport React from 'preact/compat';\n\nimport type { NumericColumnFilterValue } from './NumericInputColumnFilter.js';\n\nconst AdapterContext = React.createContext('');\n\nfunction useExpressAdapterContext(): unstable_AdapterInterface {\n const context = React.useContext(AdapterContext);\n\n return {\n searchParams: new URLSearchParams(context),\n // This will never be called on the server, so it can be a no-op.\n updateUrl: () => {},\n };\n}\n\nconst NuqsExpressAdapter = unstable_createAdapterProvider(useExpressAdapterContext);\n\n/**\n * `nuqs` needs to be aware of the current state of the URL search parameters\n * during both server-side and client-side rendering. To make this work with\n * our server-side rendering setup, we use a custom adapter that should be\n * provided with the value of `new URL(...).search` on the server side. On the\n * client, we use `NuqsReactAdapter`, which will read directly from `location.search`.\n */\nexport function NuqsAdapter({ children, search }: { children: React.ReactNode; search: string }) {\n if (typeof location === 'undefined') {\n // We're rendering on the server.\n return (\n <AdapterContext.Provider value={search}>\n <NuqsExpressAdapter>{children}</NuqsExpressAdapter>\n </AdapterContext.Provider>\n );\n }\n\n // We're rendering on the client.\n return <NuqsReactAdapter>{children}</NuqsReactAdapter>;\n}\n\n/**\n * Parses and serializes TanStack Table SortingState to/from a URL query string.\n * Used for reflecting table sort order in the URL.\n *\n * Example: `sort=col:asc` <-> `[{ id: 'col', desc: false }]`\n */\nexport const parseAsSortingState = createParser<SortingState>({\n parse(queryValue) {\n if (!queryValue) return [];\n return queryValue\n .split(',')\n .map((part) => {\n const [id, dir] = part.split(':');\n if (!id) return undefined;\n if (dir === 'asc' || dir === 'desc') {\n return { id, desc: dir === 'desc' };\n }\n return undefined;\n })\n .filter((v): v is { id: string; desc: boolean } => !!v);\n },\n serialize(value): string {\n // `null` indicates that the value should be omitted from the URL.\n // @ts-expect-error - `null` is not assignable to type `string`.\n if (value.length === 0) return null;\n return value\n .filter((v) => v.id)\n .map((v) => `${v.id}:${v.desc ? 'desc' : 'asc'}`)\n .join(',');\n },\n eq(a, b) {\n return (\n a.length === b.length &&\n a.every((item, index) => item.id === b[index].id && item.desc === b[index].desc)\n );\n },\n});\n\n/**\n * Returns a parser for TanStack Table VisibilityState for a given set of columns.\n * Parses a comma-separated list of visible columns from a query string, e.g. 'a,b'.\n * Serializes to a comma-separated list of visible columns, omitting if all are visible.\n * Used for reflecting column visibility in the URL.\n *\n * @param allColumns - Array of all column IDs\n * @param defaultValueRef - A ref object with a `current` property that contains the default visibility state.\n */\nexport function parseAsColumnVisibilityStateWithColumns(\n allColumns: string[],\n defaultValueRef?: React.RefObject<VisibilityState>,\n) {\n const parser = createParser<VisibilityState>({\n parse(queryValue: string) {\n const shown =\n queryValue.length > 0\n ? new Set(queryValue.split(',').filter(Boolean))\n : new Set(allColumns);\n const result: VisibilityState = {};\n for (const col of allColumns) {\n result[col] = shown.has(col);\n }\n return result;\n },\n serialize(value): string {\n // We can't use `eq` to compare with the current default values from the\n // ref. `eq` appears to be used as part of an optimization to avoid rerenders\n // if the column set hasn't changed, so if it return `true`, we wouldn't be\n // able to update the actual visible columns after changing the defaults if\n // the new column set is equal to the default set of columns.\n //\n // Instead, we rely on the (undocumented) ability of `serialize` to return\n // `null` to indicate that the value should be omitted from the URL.\n // @ts-expect-error - `null` is not assignable to type `string`.\n if (parser.eq(value, defaultValueRef?.current ?? {})) return null;\n\n // Only output columns that are visible\n const visible = Object.keys(value).filter((col) => value[col]);\n return visible.join(',');\n },\n eq(value, defaultValue) {\n const valueKeys = Object.keys(value);\n const defaultValueKeys = Object.keys(defaultValue);\n const result =\n valueKeys.length === defaultValueKeys.length &&\n valueKeys.every((col) => value[col] === defaultValue[col]);\n return result;\n },\n });\n\n return parser;\n}\n\n/**\n * Parses and serializes TanStack Table ColumnPinningState to/from a URL query string.\n * Used for reflecting pinned columns in the URL.\n *\n * Right pins aren't supported; an empty array is always returned to allow\n * this hook's value to be used directly in `state.columnPinning` in `useReactTable`.\n *\n * Example: `a,b` <-> `{ left: ['a', 'b'], right: [] }`\n */\nexport const parseAsColumnPinningState = createParser<ColumnPinningState>({\n parse(queryValue) {\n if (!queryValue) return { left: [], right: [] };\n // Format: col1,col2,col3 (all left-pinned columns)\n return {\n left: queryValue.split(',').filter(Boolean),\n right: [],\n };\n },\n serialize(value) {\n if (!value.left) return '';\n return value.left.join(',');\n },\n eq(a, b) {\n const aLeft = Array.isArray(a.left) ? a.left : [];\n const bLeft = Array.isArray(b.left) ? b.left : [];\n if (aLeft.length !== bLeft.length) return false;\n return aLeft.every((v, i) => v === bLeft[i]);\n },\n});\n\n/**\n * Parses and serializes numeric filter strings to/from URL-friendly format.\n * Used for numeric column filters with comparison operators.\n *\n * Internal format: `>=5`, `<=10`, `>3`, `<7`, `=5`\n * URL format: `gte_5`, `lte_10`, `gt_3`, `lt_7`, `eq_5`, `empty`\n *\n * Example: `gte_5` <-> `>=5`\n */\nexport const parseAsNumericFilter = createParser<NumericColumnFilterValue>({\n parse(queryValue) {\n if (!queryValue) return { filterValue: '', emptyOnly: false };\n // Parse format: {operator}_{value}\n const match = queryValue.match(/^(gte|lte|gt|lt|eq)_(.+)$/);\n if (!match) {\n if (queryValue === 'empty') {\n return { filterValue: '', emptyOnly: true };\n }\n return { filterValue: '', emptyOnly: false };\n }\n const [, opCode, value] = match;\n const opMap: Record<string, string> = {\n gte: '>=',\n lte: '<=',\n gt: '>',\n lt: '<',\n eq: '=',\n };\n const operator = opMap[opCode];\n if (!operator) return { filterValue: '', emptyOnly: false };\n return { filterValue: `${operator}${value}`, emptyOnly: false };\n },\n serialize(value): string {\n const { filterValue, emptyOnly } = value;\n\n if (emptyOnly) return 'empty';\n\n if (filterValue.length === 0) {\n return 'empty';\n }\n\n // Serialize format: internal (>=5) -> URL (gte_5)\n const match = filterValue.match(/^(>=|<=|>|<|=)(.+)$/);\n // @ts-expect-error - `null` is not assignable to type `string`.\n if (!match) return null;\n const [, operator, val] = match;\n const opMap: Record<string, string> = {\n '>=': 'gte',\n '<=': 'lte',\n '>': 'gt',\n '<': 'lt',\n '=': 'eq',\n };\n const opCode = opMap[operator];\n // @ts-expect-error - `null` is not assignable to type `string`.\n if (!opCode) return null;\n return `${opCode}_${val}`;\n },\n eq(a, b) {\n return a.filterValue === b.filterValue && a.emptyOnly === b.emptyOnly;\n },\n});\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=nuqs.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nuqs.test.d.ts","sourceRoot":"","sources":["../../src/components/nuqs.test.ts"],"names":[],"mappings":""}