@at-flux/astroflare 1.0.6 → 1.0.7

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/README.md CHANGED
@@ -50,6 +50,7 @@ import { forms } from "@at-flux/astroflare/core";
50
50
  - `FilterPills.astro` — Tag-colored filter chips with an `all` option and active-state styling
51
51
  - `Pager.astro` — Pagination UI primitive for both browser-only and link-driven query pagination
52
52
  - `CollectionQuery.astro` — Unified collection filtering/pagination component (client mode by default; URL-driven server mode with `useServer`)
53
+ - `CollectionFooterControls.astro` — Server-only row: optional `summary` slot, `Pager`, and page-size `<form>` (same query contract as `CollectionQuery` server mode)
53
54
 
54
55
  #### Component props reference
55
56
 
@@ -68,6 +69,7 @@ import { forms } from "@at-flux/astroflare/core";
68
69
  - `Pager.astro`: `pageCount`, `activePage`, `items`, `class`
69
70
  - `CollectionQuery.astro`: `useServer`, `pathname`, `query`, `totalPages`, `currentPage`, `filters`, `maxPageButtons`, `filtersClass`, `pagerClass`, `perPage`, `class`
70
71
  - when `useServer` is `true`, `pathname`, `query`, `totalPages`, and `currentPage` are required
72
+ - `CollectionFooterControls.astro`: `pathname`, `query`, `totalPages`, `currentPage`, `sizeOptions`, `maxPageButtons`, `class` — slot `summary` for “Showing X–Y of Z” text
71
73
 
72
74
  ### Server Islands Pattern
73
75
 
@@ -122,7 +124,8 @@ Use named slots to replace the default filter/pager rendering:
122
124
 
123
125
  - `getTagPalette(tag, options?)` — Deterministic, readable tag color assignment with optional explicit overrides
124
126
  - `formatDisplayDate(date, config?)` — Consistent card/detail date formatting with locale override support
125
- - `parseCollectionQuery(searchParams, options?)` + `paginateCollection(list, query)` + `buildCollectionHref(path, query, overrides?)` + `buildPageSequence(total, current, max?)` + `matchesCollectionFilters(values, filters)` — URL-driven filtering and pagination helpers (`filters` query param supports stringified JSON for multi-filter payloads)
127
+ - `parseCollectionQuery` + `paginateCollection` + `buildCollectionHref` + `buildPageSequence` + `matchesCollectionFilters` + `formatCollectionRangeLabel` + `resolveIslandSearchString` — URL-driven filtering and pagination (`filters` as stringified JSON). `resolveIslandSearchString` is for server islands (pass the page’s search from the page; fall back to `Referer`). `formatCollectionRangeLabel` is for “Showing result N of T” / “Showing results a–b of T” footers.
128
+ - `astroflare-link-local` is **shipped in this package**; run it from any project that depends on `@at-flux/astroflare` (it walks up to the nearest `package.json` that lists the dep). `status` shows whether a local overlay is active. See **Local checkout** below.
126
129
 
127
130
  ## Usage
128
131
 
package/dist/core.cjs CHANGED
@@ -77,21 +77,25 @@ const formatDisplayDate = (date, config = {}) => {
77
77
  };
78
78
  //#endregion
79
79
  //#region src/collection-query.ts
80
+ const normalizeFilterToken = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
81
+ const parseFilterValueList = (value) => (value ?? "").split(",").map(normalizeFilterToken).filter(Boolean).filter((token, index, list) => list.indexOf(token) === index);
82
+ const formatFilterValueListLabel = (value) => parseFilterValueList(value).map((token) => token.replace(/_/g, " ").toUpperCase()).join(" + ");
80
83
  const toInt = (value, fallback) => {
81
84
  const parsed = Number.parseInt(value ?? "", 10);
82
85
  return Number.isFinite(parsed) ? parsed : fallback;
83
86
  };
84
87
  const parseCollectionQuery = (searchParams, options = {}) => {
85
88
  const { defaultPage = 1, defaultSize = 12, maxSize = 100 } = options;
86
- const page = Math.max(1, toInt(searchParams.get("page"), defaultPage));
87
89
  const size = Math.min(maxSize, Math.max(1, toInt(searchParams.get("size"), defaultSize)));
88
- const fallbackOffset = (page - 1) * size;
89
- const offset = Math.max(0, toInt(searchParams.get("offset"), fallbackOffset));
90
+ const pageParam = Math.max(1, toInt(searchParams.get("page"), defaultPage));
91
+ const rawOffset = searchParams.has("offset") ? Math.max(0, toInt(searchParams.get("offset"), 0)) : (pageParam - 1) * size;
92
+ const page = Math.max(1, Math.floor(rawOffset / size) + 1);
93
+ const offset = (page - 1) * size;
90
94
  const rawFilters = searchParams.get("filters");
91
95
  let filters = {};
92
96
  if (rawFilters) try {
93
97
  const parsed = JSON.parse(rawFilters);
94
- filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, String(value)]));
98
+ filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, parseFilterValueList(String(value)).join(",")]));
95
99
  } catch {
96
100
  filters = {};
97
101
  }
@@ -120,17 +124,22 @@ const buildCollectionHref = (pathname, query, overrides = {}) => {
120
124
  ...query,
121
125
  ...overrides
122
126
  };
123
- const normalizedOffset = (next.page - 1) * next.size;
127
+ const offsetFromPage = (next.page - 1) * next.size;
124
128
  const params = new URLSearchParams({
125
129
  page: String(next.page),
126
130
  size: String(next.size),
127
- offset: String(next.offset ?? normalizedOffset)
131
+ offset: String(offsetFromPage)
128
132
  });
129
- if (Object.keys(next.filters ?? {}).length > 0) params.set("filters", JSON.stringify(next.filters));
133
+ if (Object.keys(next.filters ?? {}).length > 0) {
134
+ const normalizedFilters = Object.fromEntries(Object.entries(next.filters).map(([key, value]) => [key, parseFilterValueList(value).join(",")]));
135
+ params.set("filters", JSON.stringify(normalizedFilters));
136
+ }
130
137
  return `${pathname}?${params.toString()}`;
131
138
  };
132
139
  const matchesCollectionFilters = (values, filters) => Object.entries(filters).every(([key, value]) => {
133
- return (values[key] ?? []).includes(value);
140
+ const expected = parseFilterValueList(value);
141
+ const candidates = (values[key] ?? []).map(normalizeFilterToken);
142
+ return expected.every((token) => candidates.includes(token));
134
143
  });
135
144
  const buildPageSequence = (totalPages, currentPage, maxButtons = 7) => {
136
145
  if (totalPages <= maxButtons) return Array.from({ length: totalPages }, (_, index) => index + 1);
@@ -145,11 +154,36 @@ const buildPageSequence = (totalPages, currentPage, maxButtons = 7) => {
145
154
  sequence.push(totalPages);
146
155
  return sequence;
147
156
  };
157
+ /**
158
+ * For Astro server islands: the serialized `search` prop is usually set from the
159
+ * page request, but the island subrequest may omit it. When empty, use the
160
+ * `Referer` request header so `parseCollectionQuery` still matches the main document URL.
161
+ */
162
+ /** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
163
+ const formatCollectionRangeLabel = (slice) => {
164
+ if (slice.totalItems === 0) return "Showing 0 of 0";
165
+ const a = slice.start + 1;
166
+ const b = slice.end;
167
+ if (a === b) return `Showing result ${a} of ${slice.totalItems}`;
168
+ return `Showing results ${a}–${b} of ${slice.totalItems}`;
169
+ };
170
+ const resolveIslandSearchString = (searchFromProps, request) => {
171
+ if (searchFromProps) return searchFromProps;
172
+ const referer = request.headers.get("referer") ?? "";
173
+ if (!referer) return "";
174
+ try {
175
+ return new URL(referer).search;
176
+ } catch {
177
+ return "";
178
+ }
179
+ };
148
180
  //#endregion
149
181
  exports.buildCollectionHref = buildCollectionHref;
150
182
  exports.buildPageSequence = buildPageSequence;
151
183
  exports.composeEmailAddress = require_forms.composeEmailAddress;
184
+ exports.formatCollectionRangeLabel = formatCollectionRangeLabel;
152
185
  exports.formatDisplayDate = formatDisplayDate;
186
+ exports.formatFilterValueListLabel = formatFilterValueListLabel;
153
187
  Object.defineProperty(exports, "forms", {
154
188
  enumerable: true,
155
189
  get: function() {
@@ -160,7 +194,10 @@ exports.generateFormResultHtml = require_forms.generateFormResultHtml;
160
194
  exports.generateFormSectionHtml = require_forms.generateFormSectionHtml;
161
195
  exports.getTagPalette = getTagPalette;
162
196
  exports.matchesCollectionFilters = matchesCollectionFilters;
197
+ exports.normalizeFilterToken = normalizeFilterToken;
163
198
  exports.paginateCollection = paginateCollection;
164
199
  exports.parseCollectionQuery = parseCollectionQuery;
200
+ exports.parseFilterValueList = parseFilterValueList;
165
201
  exports.renderEmailTemplate = require_forms.renderEmailTemplate;
202
+ exports.resolveIslandSearchString = resolveIslandSearchString;
166
203
  exports.sendEmail = require_forms.sendEmail;
package/dist/core.d.cts CHANGED
@@ -50,10 +50,21 @@ interface PaginationSlice<T> {
50
50
  start: number;
51
51
  end: number;
52
52
  }
53
+ declare const normalizeFilterToken: (value: string) => string;
54
+ declare const parseFilterValueList: (value: string | undefined) => string[];
55
+ declare const formatFilterValueListLabel: (value: string | undefined) => string;
53
56
  declare const parseCollectionQuery: (searchParams: URLSearchParams, options?: CollectionQueryOptions) => CollectionQueryState;
54
57
  declare const paginateCollection: <T>(list: T[], query: CollectionQueryState) => PaginationSlice<T>;
55
58
  declare const buildCollectionHref: (pathname: string, query: CollectionQueryState, overrides?: Partial<CollectionQueryState>) => string;
56
59
  declare const matchesCollectionFilters: (values: Record<string, string[]>, filters: Record<string, string>) => boolean;
57
60
  declare const buildPageSequence: (totalPages: number, currentPage: number, maxButtons?: number) => Array<number | "\u2026">;
61
+ /**
62
+ * For Astro server islands: the serialized `search` prop is usually set from the
63
+ * page request, but the island subrequest may omit it. When empty, use the
64
+ * `Referer` request header so `parseCollectionQuery` still matches the main document URL.
65
+ */
66
+ /** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
67
+ declare const formatCollectionRangeLabel: (slice: Pick<PaginationSlice<unknown>, "start" | "end" | "totalItems">) => string;
68
+ declare const resolveIslandSearchString: (searchFromProps: string | undefined, request: Request) => string;
58
69
  //#endregion
59
- export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
70
+ export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
package/dist/core.d.ts CHANGED
@@ -50,10 +50,21 @@ interface PaginationSlice<T> {
50
50
  start: number;
51
51
  end: number;
52
52
  }
53
+ declare const normalizeFilterToken: (value: string) => string;
54
+ declare const parseFilterValueList: (value: string | undefined) => string[];
55
+ declare const formatFilterValueListLabel: (value: string | undefined) => string;
53
56
  declare const parseCollectionQuery: (searchParams: URLSearchParams, options?: CollectionQueryOptions) => CollectionQueryState;
54
57
  declare const paginateCollection: <T>(list: T[], query: CollectionQueryState) => PaginationSlice<T>;
55
58
  declare const buildCollectionHref: (pathname: string, query: CollectionQueryState, overrides?: Partial<CollectionQueryState>) => string;
56
59
  declare const matchesCollectionFilters: (values: Record<string, string[]>, filters: Record<string, string>) => boolean;
57
60
  declare const buildPageSequence: (totalPages: number, currentPage: number, maxButtons?: number) => Array<number | "\u2026">;
61
+ /**
62
+ * For Astro server islands: the serialized `search` prop is usually set from the
63
+ * page request, but the island subrequest may omit it. When empty, use the
64
+ * `Referer` request header so `parseCollectionQuery` still matches the main document URL.
65
+ */
66
+ /** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
67
+ declare const formatCollectionRangeLabel: (slice: Pick<PaginationSlice<unknown>, "start" | "end" | "totalItems">) => string;
68
+ declare const resolveIslandSearchString: (searchFromProps: string | undefined, request: Request) => string;
58
69
  //#endregion
59
- export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
70
+ export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
package/dist/core.js CHANGED
@@ -76,21 +76,25 @@ const formatDisplayDate = (date, config = {}) => {
76
76
  };
77
77
  //#endregion
78
78
  //#region src/collection-query.ts
79
+ const normalizeFilterToken = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
80
+ const parseFilterValueList = (value) => (value ?? "").split(",").map(normalizeFilterToken).filter(Boolean).filter((token, index, list) => list.indexOf(token) === index);
81
+ const formatFilterValueListLabel = (value) => parseFilterValueList(value).map((token) => token.replace(/_/g, " ").toUpperCase()).join(" + ");
79
82
  const toInt = (value, fallback) => {
80
83
  const parsed = Number.parseInt(value ?? "", 10);
81
84
  return Number.isFinite(parsed) ? parsed : fallback;
82
85
  };
83
86
  const parseCollectionQuery = (searchParams, options = {}) => {
84
87
  const { defaultPage = 1, defaultSize = 12, maxSize = 100 } = options;
85
- const page = Math.max(1, toInt(searchParams.get("page"), defaultPage));
86
88
  const size = Math.min(maxSize, Math.max(1, toInt(searchParams.get("size"), defaultSize)));
87
- const fallbackOffset = (page - 1) * size;
88
- const offset = Math.max(0, toInt(searchParams.get("offset"), fallbackOffset));
89
+ const pageParam = Math.max(1, toInt(searchParams.get("page"), defaultPage));
90
+ const rawOffset = searchParams.has("offset") ? Math.max(0, toInt(searchParams.get("offset"), 0)) : (pageParam - 1) * size;
91
+ const page = Math.max(1, Math.floor(rawOffset / size) + 1);
92
+ const offset = (page - 1) * size;
89
93
  const rawFilters = searchParams.get("filters");
90
94
  let filters = {};
91
95
  if (rawFilters) try {
92
96
  const parsed = JSON.parse(rawFilters);
93
- filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, String(value)]));
97
+ filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, parseFilterValueList(String(value)).join(",")]));
94
98
  } catch {
95
99
  filters = {};
96
100
  }
@@ -119,17 +123,22 @@ const buildCollectionHref = (pathname, query, overrides = {}) => {
119
123
  ...query,
120
124
  ...overrides
121
125
  };
122
- const normalizedOffset = (next.page - 1) * next.size;
126
+ const offsetFromPage = (next.page - 1) * next.size;
123
127
  const params = new URLSearchParams({
124
128
  page: String(next.page),
125
129
  size: String(next.size),
126
- offset: String(next.offset ?? normalizedOffset)
130
+ offset: String(offsetFromPage)
127
131
  });
128
- if (Object.keys(next.filters ?? {}).length > 0) params.set("filters", JSON.stringify(next.filters));
132
+ if (Object.keys(next.filters ?? {}).length > 0) {
133
+ const normalizedFilters = Object.fromEntries(Object.entries(next.filters).map(([key, value]) => [key, parseFilterValueList(value).join(",")]));
134
+ params.set("filters", JSON.stringify(normalizedFilters));
135
+ }
129
136
  return `${pathname}?${params.toString()}`;
130
137
  };
131
138
  const matchesCollectionFilters = (values, filters) => Object.entries(filters).every(([key, value]) => {
132
- return (values[key] ?? []).includes(value);
139
+ const expected = parseFilterValueList(value);
140
+ const candidates = (values[key] ?? []).map(normalizeFilterToken);
141
+ return expected.every((token) => candidates.includes(token));
133
142
  });
134
143
  const buildPageSequence = (totalPages, currentPage, maxButtons = 7) => {
135
144
  if (totalPages <= maxButtons) return Array.from({ length: totalPages }, (_, index) => index + 1);
@@ -144,5 +153,28 @@ const buildPageSequence = (totalPages, currentPage, maxButtons = 7) => {
144
153
  sequence.push(totalPages);
145
154
  return sequence;
146
155
  };
156
+ /**
157
+ * For Astro server islands: the serialized `search` prop is usually set from the
158
+ * page request, but the island subrequest may omit it. When empty, use the
159
+ * `Referer` request header so `parseCollectionQuery` still matches the main document URL.
160
+ */
161
+ /** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
162
+ const formatCollectionRangeLabel = (slice) => {
163
+ if (slice.totalItems === 0) return "Showing 0 of 0";
164
+ const a = slice.start + 1;
165
+ const b = slice.end;
166
+ if (a === b) return `Showing result ${a} of ${slice.totalItems}`;
167
+ return `Showing results ${a}–${b} of ${slice.totalItems}`;
168
+ };
169
+ const resolveIslandSearchString = (searchFromProps, request) => {
170
+ if (searchFromProps) return searchFromProps;
171
+ const referer = request.headers.get("referer") ?? "";
172
+ if (!referer) return "";
173
+ try {
174
+ return new URL(referer).search;
175
+ } catch {
176
+ return "";
177
+ }
178
+ };
147
179
  //#endregion
148
- export { buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, forms_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
180
+ export { buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, forms_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
package/dist/index.cjs CHANGED
@@ -4,7 +4,9 @@ const require_core = require("./core.cjs");
4
4
  exports.buildCollectionHref = require_core.buildCollectionHref;
5
5
  exports.buildPageSequence = require_core.buildPageSequence;
6
6
  exports.composeEmailAddress = require_forms.composeEmailAddress;
7
+ exports.formatCollectionRangeLabel = require_core.formatCollectionRangeLabel;
7
8
  exports.formatDisplayDate = require_core.formatDisplayDate;
9
+ exports.formatFilterValueListLabel = require_core.formatFilterValueListLabel;
8
10
  Object.defineProperty(exports, "forms", {
9
11
  enumerable: true,
10
12
  get: function() {
@@ -15,7 +17,10 @@ exports.generateFormResultHtml = require_forms.generateFormResultHtml;
15
17
  exports.generateFormSectionHtml = require_forms.generateFormSectionHtml;
16
18
  exports.getTagPalette = require_core.getTagPalette;
17
19
  exports.matchesCollectionFilters = require_core.matchesCollectionFilters;
20
+ exports.normalizeFilterToken = require_core.normalizeFilterToken;
18
21
  exports.paginateCollection = require_core.paginateCollection;
19
22
  exports.parseCollectionQuery = require_core.parseCollectionQuery;
23
+ exports.parseFilterValueList = require_core.parseFilterValueList;
20
24
  exports.renderEmailTemplate = require_forms.renderEmailTemplate;
25
+ exports.resolveIslandSearchString = require_core.resolveIslandSearchString;
21
26
  exports.sendEmail = require_forms.sendEmail;
package/dist/index.d.cts CHANGED
@@ -1,3 +1,3 @@
1
1
  import { a as generateFormResultHtml, c as renderEmailTemplate, i as composeEmailAddress, l as sendEmail, n as FormSection, o as generateFormSectionHtml, r as ResendConfig, s as index_d_exports, t as EmailPayload } from "./index-CcH_yXr-.cjs";
2
- import { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, PaginationSlice, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, formatDisplayDate, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery } from "./core.cjs";
3
- export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
2
+ import { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, PaginationSlice, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, resolveIslandSearchString } from "./core.cjs";
3
+ export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import { a as generateFormResultHtml, c as renderEmailTemplate, i as composeEmailAddress, l as sendEmail, n as FormSection, o as generateFormSectionHtml, r as ResendConfig, s as index_d_exports, t as EmailPayload } from "./index-Bk-K9aDQ.js";
2
- import { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, PaginationSlice, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, formatDisplayDate, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery } from "./core.js";
3
- export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
2
+ import { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, PaginationSlice, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, resolveIslandSearchString } from "./core.js";
3
+ export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  import { composeEmailAddress, generateFormResultHtml, generateFormSectionHtml, renderEmailTemplate, sendEmail, t as forms_exports } from "./forms/index.js";
2
- import { buildCollectionHref, buildPageSequence, formatDisplayDate, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery } from "./core.js";
3
- export { buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, forms_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
2
+ import { buildCollectionHref, buildPageSequence, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, resolveIslandSearchString } from "./core.js";
3
+ export { buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, forms_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@at-flux/astroflare",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "type": "module",
5
5
  "description": "Reusable headless components, styles, and utilities for Astro + Tailwind v4 + Cloudflare projects",
6
6
  "author": "atflux <dev@atflux.uk>",
@@ -4,7 +4,8 @@
4
4
  * Does not change package.json or the lockfile — run `pnpm install` first so the
5
5
  * registry version is recorded, then this replaces only the on-disk resolution.
6
6
  */
7
- import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
7
+ import { existsSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
8
+ import { lstatSync } from 'node:fs';
8
9
  import { dirname, isAbsolute, join, resolve } from 'node:path';
9
10
  import { spawnSync } from 'node:child_process';
10
11
  import { platform } from 'node:os';
@@ -126,7 +127,9 @@ function cmdStatus(projectRoot) {
126
127
  console.log(` marker: ${marker}`);
127
128
  console.log(` local: ${data.localPath}`);
128
129
  if (existsSync(target)) {
129
- console.log(` node_modules: ${target}`);
130
+ const st = lstatSync(target);
131
+ const link = st.isSymbolicLink() ? ` → ${readlinkSync(target)}` : " (not a symlink — registry or copy)";
132
+ console.log(` node_modules: ${target}${link}`);
130
133
  }
131
134
  } catch {
132
135
  console.log('astroflare-link-local: marker present but invalid');
@@ -154,6 +157,9 @@ if (argv[0] === 'help' || argv[0] === '-h' || argv[0] === '--help') {
154
157
  astroflare-link-local unlink --no-install
155
158
  astroflare-link-local status
156
159
 
160
+ Shipped with @at-flux/astroflare: run from the app that depends on the package (not only from the monorepo
161
+ that builds astroflare). Walks up directories until it finds a package.json that depends on @at-flux/astroflare.
162
+
157
163
  Symlinks node_modules/@at-flux/astroflare → local checkout. Does not edit package.json or lockfile.`);
158
164
  process.exit(0);
159
165
  }
@@ -19,6 +19,26 @@ export interface PaginationSlice<T> {
19
19
  end: number;
20
20
  }
21
21
 
22
+ export const normalizeFilterToken = (value: string): string =>
23
+ value
24
+ .trim()
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, "_")
27
+ .replace(/^_+|_+$/g, "")
28
+ .replace(/_+/g, "_");
29
+
30
+ export const parseFilterValueList = (value: string | undefined): string[] =>
31
+ (value ?? "")
32
+ .split(",")
33
+ .map(normalizeFilterToken)
34
+ .filter(Boolean)
35
+ .filter((token, index, list) => list.indexOf(token) === index);
36
+
37
+ export const formatFilterValueListLabel = (value: string | undefined): string =>
38
+ parseFilterValueList(value)
39
+ .map((token) => token.replace(/_/g, " ").toUpperCase())
40
+ .join(" + ");
41
+
22
42
  const toInt = (value: string | null, fallback: number): number => {
23
43
  const parsed = Number.parseInt(value ?? "", 10);
24
44
  return Number.isFinite(parsed) ? parsed : fallback;
@@ -30,13 +50,19 @@ export const parseCollectionQuery = (
30
50
  ): CollectionQueryState => {
31
51
  const { defaultPage = 1, defaultSize = 12, maxSize = 100 } = options;
32
52
 
33
- const page = Math.max(1, toInt(searchParams.get("page"), defaultPage));
34
53
  const size = Math.min(
35
54
  maxSize,
36
55
  Math.max(1, toInt(searchParams.get("size"), defaultSize)),
37
56
  );
38
- const fallbackOffset = (page - 1) * size;
39
- const offset = Math.max(0, toInt(searchParams.get("offset"), fallbackOffset));
57
+ const pageParam = Math.max(1, toInt(searchParams.get("page"), defaultPage));
58
+ const hasOffset = searchParams.has("offset");
59
+ const rawOffset = hasOffset
60
+ ? Math.max(0, toInt(searchParams.get("offset"), 0))
61
+ : (pageParam - 1) * size;
62
+ // Offset (which window of the list) is authoritative. Fixes ?page=2&size=2&offset=0
63
+ // where 0 is valid but is not a multiple of size for page 2, and 0 is not "missing".
64
+ const page = Math.max(1, Math.floor(rawOffset / size) + 1);
65
+ const offset = (page - 1) * size;
40
66
  const rawFilters = searchParams.get("filters");
41
67
  let filters: Record<string, string> = {};
42
68
  if (rawFilters) {
@@ -45,7 +71,7 @@ export const parseCollectionQuery = (
45
71
  filters = Object.fromEntries(
46
72
  Object.entries(parsed)
47
73
  .filter(([, value]) => typeof value === "string")
48
- .map(([key, value]) => [key, String(value)]),
74
+ .map(([key, value]) => [key, parseFilterValueList(String(value)).join(",")]),
49
75
  );
50
76
  } catch {
51
77
  filters = {};
@@ -86,14 +112,21 @@ export const buildCollectionHref = (
86
112
  ...query,
87
113
  ...overrides,
88
114
  };
89
- const normalizedOffset = (next.page - 1) * next.size;
115
+ const offsetFromPage = (next.page - 1) * next.size;
90
116
  const params = new URLSearchParams({
91
117
  page: String(next.page),
92
118
  size: String(next.size),
93
- offset: String(next.offset ?? normalizedOffset),
119
+ /* Always follow page+size. Stale offset:0 is valid but must not break page 2+ links (0 is not nullish for ??) */
120
+ offset: String(offsetFromPage),
94
121
  });
95
122
  if (Object.keys(next.filters ?? {}).length > 0) {
96
- params.set("filters", JSON.stringify(next.filters));
123
+ const normalizedFilters = Object.fromEntries(
124
+ Object.entries(next.filters).map(([key, value]) => [
125
+ key,
126
+ parseFilterValueList(value).join(","),
127
+ ]),
128
+ );
129
+ params.set("filters", JSON.stringify(normalizedFilters));
97
130
  }
98
131
  return `${pathname}?${params.toString()}`;
99
132
  };
@@ -103,8 +136,9 @@ export const matchesCollectionFilters = (
103
136
  filters: Record<string, string>,
104
137
  ): boolean =>
105
138
  Object.entries(filters).every(([key, value]) => {
106
- const candidates = values[key] ?? [];
107
- return candidates.includes(value);
139
+ const expected = parseFilterValueList(value);
140
+ const candidates = (values[key] ?? []).map(normalizeFilterToken);
141
+ return expected.every((token) => candidates.includes(token));
108
142
  });
109
143
 
110
144
  export const buildPageSequence = (
@@ -128,3 +162,39 @@ export const buildPageSequence = (
128
162
  sequence.push(totalPages);
129
163
  return sequence;
130
164
  };
165
+
166
+ /**
167
+ * For Astro server islands: the serialized `search` prop is usually set from the
168
+ * page request, but the island subrequest may omit it. When empty, use the
169
+ * `Referer` request header so `parseCollectionQuery` still matches the main document URL.
170
+ */
171
+ /** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
172
+ export const formatCollectionRangeLabel = (slice: Pick<PaginationSlice<unknown>, "start" | "end" | "totalItems">): string => {
173
+ if (slice.totalItems === 0) {
174
+ return "Showing 0 of 0";
175
+ }
176
+ const a = slice.start + 1;
177
+ const b = slice.end;
178
+ if (a === b) {
179
+ return `Showing result ${a} of ${slice.totalItems}`;
180
+ }
181
+ return `Showing results ${a}–${b} of ${slice.totalItems}`;
182
+ };
183
+
184
+ export const resolveIslandSearchString = (
185
+ searchFromProps: string | undefined,
186
+ request: Request,
187
+ ): string => {
188
+ if (searchFromProps) {
189
+ return searchFromProps;
190
+ }
191
+ const referer = request.headers.get("referer") ?? "";
192
+ if (!referer) {
193
+ return "";
194
+ }
195
+ try {
196
+ return new URL(referer).search;
197
+ } catch {
198
+ return "";
199
+ }
200
+ };
@@ -0,0 +1,84 @@
1
+ ---
2
+ /**
3
+ * Server-rendered row: result summary, link-driven Pager, and page-size form.
4
+ * Pairs with CollectionQuery filter pills when filters use URL query state.
5
+ */
6
+ import Pager from "./Pager.astro";
7
+ import { buildCollectionHref, buildPageSequence, type CollectionQueryState } from "../collection-query";
8
+
9
+ interface Props {
10
+ pathname: string;
11
+ query: CollectionQueryState;
12
+ totalPages: number;
13
+ currentPage: number;
14
+ sizeOptions?: number[];
15
+ maxPageButtons?: number;
16
+ class?: string;
17
+ }
18
+
19
+ const {
20
+ pathname,
21
+ query,
22
+ totalPages,
23
+ currentPage,
24
+ sizeOptions = [4, 2, 8, 12, 16],
25
+ maxPageButtons = 7,
26
+ class: className = "",
27
+ } = Astro.props;
28
+
29
+ const activeFilters = query.filters ?? {};
30
+ const pagerItems = buildPageSequence(totalPages, currentPage, maxPageButtons).map((item) =>
31
+ item === "…"
32
+ ? { ellipsis: true as const }
33
+ : {
34
+ page: item,
35
+ href: buildCollectionHref(pathname, query, { page: item, offset: (item - 1) * query.size }),
36
+ active: item === currentPage,
37
+ disabled: totalPages <= 1 || item === currentPage,
38
+ },
39
+ );
40
+ const sanitizedSizeOptions = Array.from(
41
+ new Set([query.size, ...sizeOptions].filter((value) => Number.isFinite(value) && value > 0)),
42
+ ).sort((a, b) => a - b);
43
+ ---
44
+
45
+ <div class:list={["af-collection-footer grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-center", className]}>
46
+ <div class="af-collection-summary text-sm text-light-text-subtle dark:text-dark-text-subtle">
47
+ <slot name="summary" />
48
+ </div>
49
+ <Pager items={pagerItems} class="md:col-start-2 md:justify-center" />
50
+ <form method="get" action={pathname} data-astro-reload class="md:col-start-3 md:justify-self-end">
51
+ <input type="hidden" name="page" value="1" />
52
+ <input type="hidden" name="offset" value="0" />
53
+ {Object.keys(activeFilters).length > 0 && (
54
+ <input type="hidden" name="filters" value={JSON.stringify(activeFilters)} />
55
+ )}
56
+ <label class="af-page-size-label inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle">
57
+ <span>Page size</span>
58
+ <select
59
+ name="size"
60
+ class="select-none rounded-full border border-light-border bg-light-surface px-3 py-1.5 text-xs text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
61
+ onchange="this.form?.submit()"
62
+ >
63
+ {sanitizedSizeOptions.map((size) => (
64
+ <option value={String(size)} selected={size === query.size}>
65
+ {size}
66
+ </option>
67
+ ))}
68
+ </select>
69
+ </label>
70
+ </form>
71
+ </div>
72
+
73
+ <style is:global>
74
+ .af-collection-summary,
75
+ .af-page-size-label {
76
+ user-select: none;
77
+ -webkit-user-select: none;
78
+ }
79
+ .af-page-size-label select,
80
+ .af-page-size-label option {
81
+ user-select: none;
82
+ -webkit-user-select: none;
83
+ }
84
+ </style>
@@ -1,7 +1,12 @@
1
1
  ---
2
2
  import FilterPills from "./FilterPills.astro";
3
3
  import Pager from "./Pager.astro";
4
- import { buildCollectionHref, buildPageSequence } from "../collection-query";
4
+ import {
5
+ buildCollectionHref,
6
+ buildPageSequence,
7
+ normalizeFilterToken,
8
+ parseFilterValueList,
9
+ } from "../collection-query";
5
10
  import type { CollectionQueryProps, CollectionQueryServerProps } from "./collection-query-props";
6
11
  type Props = CollectionQueryProps;
7
12
 
@@ -27,14 +32,43 @@ const totalPages = serverProps?.totalPages ?? 1;
27
32
  const currentPage = serverProps?.currentPage ?? 1;
28
33
 
29
34
  const activeFilters = query.filters ?? {};
35
+ const normalize = (value: string): string => value.trim().toLowerCase();
36
+ const maxTagFilters = 5;
37
+ const activeTagTokens = parseFilterValueList(activeFilters.tag);
30
38
  const allHref = buildCollectionHref(pathname, query, { filters: {}, page: 1, offset: 0 });
31
39
  const serverFilterItems = filters.map((option) => {
32
- const nextFilters = { ...activeFilters, [option.key]: option.value };
40
+ const isTagFilter = option.key === "tag";
41
+ const normalizedOptionValue = normalizeFilterToken(option.value);
42
+ const selectedTagTokens = activeTagTokens;
43
+ const isSelectedTag = isTagFilter && selectedTagTokens.includes(normalizedOptionValue);
44
+ const canAddTag = isTagFilter ? selectedTagTokens.length < maxTagFilters : true;
45
+ const nextTagTokens = isTagFilter
46
+ ? (isSelectedTag
47
+ ? selectedTagTokens.filter((token) => token !== normalizedOptionValue)
48
+ : [...selectedTagTokens, normalizedOptionValue]
49
+ )
50
+ : [];
51
+ const nextFilters = { ...activeFilters };
52
+ if (isTagFilter) {
53
+ if (nextTagTokens.length === 0) {
54
+ delete nextFilters.tag;
55
+ } else {
56
+ nextFilters.tag = nextTagTokens.join(",");
57
+ }
58
+ } else {
59
+ nextFilters[option.key] = normalizeFilterToken(option.value);
60
+ }
61
+ const isDisabledTag = isTagFilter && !isSelectedTag && !canAddTag;
33
62
  return {
34
63
  value: `${option.key}:${option.value}`,
35
64
  label: option.label ?? option.value,
36
- href: buildCollectionHref(pathname, query, { filters: nextFilters, page: 1, offset: 0 }),
37
- active: activeFilters[option.key] === option.value,
65
+ href: isDisabledTag
66
+ ? undefined
67
+ : buildCollectionHref(pathname, query, { filters: nextFilters, page: 1, offset: 0 }),
68
+ active: isTagFilter
69
+ ? isSelectedTag
70
+ : normalize(activeFilters[option.key] ?? "") === normalize(option.value),
71
+ disabled: isDisabledTag,
38
72
  colorKey: option.value,
39
73
  };
40
74
  });
@@ -74,20 +108,20 @@ const sanitizedSizeOptions = Array.from(
74
108
  </slot>
75
109
  <slot />
76
110
  <slot name="pager">
77
- <div class:list={["af-query-pager-row flex flex-col gap-3 md:flex-row md:items-center md:justify-between", pagerClass]}>
78
- <Pager items={pagerItems} class="md:order-1 md:flex-1 md:justify-center" />
111
+ <div class:list={["af-query-pager-row grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-center", pagerClass]}>
112
+ <Pager items={pagerItems} class="md:col-start-2 md:justify-center" />
79
113
  {showPageSize && (
80
- <form method="get" action={pathname} class:list={["af-query-size-form md:order-2 md:ml-auto", pageSizeClass]}>
114
+ <form method="get" action={pathname} data-astro-reload class:list={["af-query-size-form md:col-start-3 md:justify-self-end", pageSizeClass]}>
81
115
  <input type="hidden" name="page" value="1" />
82
116
  <input type="hidden" name="offset" value="0" />
83
117
  {Object.keys(activeFilters).length > 0 && (
84
118
  <input type="hidden" name="filters" value={JSON.stringify(activeFilters)} />
85
119
  )}
86
- <label class="inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle">
120
+ <label class="af-page-size-label inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle">
87
121
  <span>Page size</span>
88
122
  <select
89
123
  name="size"
90
- class="rounded-full border border-light-border bg-light-surface px-3 py-1.5 text-xs text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
124
+ class="select-none rounded-full border border-light-border bg-light-surface px-3 py-1.5 text-xs text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
91
125
  onchange="this.form?.submit()"
92
126
  >
93
127
  {sanitizedSizeOptions.map((size) => (
@@ -137,3 +171,15 @@ const sanitizedSizeOptions = Array.from(
137
171
  </script>
138
172
  )
139
173
  }
174
+
175
+ <style is:global>
176
+ .af-page-size-label {
177
+ user-select: none;
178
+ -webkit-user-select: none;
179
+ }
180
+ .af-page-size-label select,
181
+ .af-page-size-label option {
182
+ user-select: none;
183
+ -webkit-user-select: none;
184
+ }
185
+ </style>
@@ -10,6 +10,7 @@ interface Props {
10
10
  label?: string;
11
11
  href?: string;
12
12
  active?: boolean;
13
+ disabled?: boolean;
13
14
  colorKey?: string;
14
15
  }
15
16
  >;
@@ -42,6 +43,14 @@ const {
42
43
 
43
44
  const formatLabel = (value: string): string =>
44
45
  itemCase === "upper" ? value.toUpperCase() : value;
46
+ const normalize = (value: string): string => value.trim().toLowerCase();
47
+ const formPartsFromHref = (href: string): { action: string; fields: Array<{ name: string; value: string }> } => {
48
+ const url = new URL(href, "https://astroflare.local");
49
+ return {
50
+ action: url.pathname,
51
+ fields: Array.from(url.searchParams.entries()).map(([name, value]) => ({ name, value })),
52
+ };
53
+ };
45
54
 
46
55
  const normalizedItems = items.map((item) =>
47
56
  typeof item === "string"
@@ -51,6 +60,7 @@ const normalizedItems = items.map((item) =>
51
60
  label: item.label ?? item.value,
52
61
  href: item.href,
53
62
  active: item.active,
63
+ disabled: item.disabled,
54
64
  colorKey: item.colorKey,
55
65
  },
56
66
  );
@@ -59,12 +69,22 @@ const normalizedItems = items.map((item) =>
59
69
  <div class:list={["af-filter-pills flex flex-wrap items-center gap-2", className]}>
60
70
  {includeAll && (
61
71
  allHref ? (
62
- <a
63
- href={allHref}
64
- class:list={["af-filter-pill", "af-filter-pill-all", active === "all" && "is-active"]}
65
- >
66
- {formatLabel(allLabel)}
67
- </a>
72
+ (() => {
73
+ const form = formPartsFromHref(allHref);
74
+ return (
75
+ <form method="get" action={form.action} data-astro-reload class="af-filter-pill-form">
76
+ {form.fields.map((field) => (
77
+ <input type="hidden" name={field.name} value={field.value} />
78
+ ))}
79
+ <button
80
+ type="submit"
81
+ class:list={["af-filter-pill", "af-filter-pill-all", active === "all" && "is-active"]}
82
+ >
83
+ {formatLabel(allLabel)}
84
+ </button>
85
+ </form>
86
+ );
87
+ })()
68
88
  ) : (
69
89
  <button
70
90
  type="button"
@@ -77,12 +97,36 @@ const normalizedItems = items.map((item) =>
77
97
  )}
78
98
  {normalizedItems.map((item) => {
79
99
  const palette = getTagPalette(item.colorKey ?? item.value, { overrides: colorOverrides });
80
- const isActive = item.active ?? active === item.value;
100
+ const isActive = item.active ?? normalize(active) === normalize(item.value);
81
101
  const label = formatLabel(item.label);
82
102
  return (
83
- item.href ? (
84
- <a
85
- href={item.href}
103
+ item.href && !item.disabled ? (
104
+ (() => {
105
+ const form = formPartsFromHref(item.href);
106
+ return (
107
+ <form method="get" action={form.action} data-astro-reload class="af-filter-pill-form">
108
+ {form.fields.map((field) => (
109
+ <input type="hidden" name={field.name} value={field.value} />
110
+ ))}
111
+ <button
112
+ type="submit"
113
+ class:list={["af-filter-pill", isActive && "is-active"]}
114
+ style={{
115
+ "--af-pill-bg": palette.bg,
116
+ "--af-pill-border": palette.border,
117
+ "--af-pill-text": palette.text,
118
+ }}
119
+ >
120
+ {label}
121
+ </button>
122
+ </form>
123
+ );
124
+ })()
125
+ ) : item.disabled ? (
126
+ <button
127
+ type="button"
128
+ disabled
129
+ aria-disabled="true"
86
130
  class:list={["af-filter-pill", isActive && "is-active"]}
87
131
  style={{
88
132
  "--af-pill-bg": palette.bg,
@@ -91,7 +135,7 @@ const normalizedItems = items.map((item) =>
91
135
  }}
92
136
  >
93
137
  {label}
94
- </a>
138
+ </button>
95
139
  ) : (
96
140
  <button
97
141
  type="button"
@@ -126,15 +170,34 @@ const normalizedItems = items.map((item) =>
126
170
  text-decoration: none;
127
171
  display: inline-flex;
128
172
  align-items: center;
173
+ user-select: none;
174
+ -webkit-user-select: none;
175
+ }
176
+ .af-filter-pill-form {
177
+ display: inline-flex;
178
+ margin: 0;
129
179
  }
130
180
 
131
181
  .af-filter-pill:hover {
132
182
  filter: brightness(1.04);
133
183
  }
184
+ .af-filter-pill:disabled,
185
+ .af-filter-pill[aria-disabled="true"] {
186
+ cursor: not-allowed;
187
+ opacity: 0.48;
188
+ filter: grayscale(0.3);
189
+ }
134
190
 
135
191
  .af-filter-pill.is-active {
136
192
  border-color: color-mix(in oklab, var(--af-pill-text, var(--color-secondary)) 80%, transparent);
137
193
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--af-pill-text, var(--color-secondary)) 36%, transparent);
194
+ outline: 2px solid color-mix(in oklab, var(--af-pill-text, var(--color-secondary)) 48%, transparent);
195
+ outline-offset: 1px;
196
+ font-weight: 600;
197
+ filter: saturate(1.14) brightness(1.06);
198
+ text-decoration: underline;
199
+ text-decoration-thickness: 2px;
200
+ text-underline-offset: 0.15em;
138
201
  }
139
202
  .af-filter-pill.af-filter-pill-all {
140
203
  --af-pill-bg: transparent;
@@ -15,6 +15,13 @@ interface Props {
15
15
 
16
16
  const { pageCount = 0, activePage = 1, class: className = "", items = [] } = Astro.props;
17
17
  const fallbackPages = Array.from({ length: Math.max(0, pageCount) }, (_, index) => index + 1);
18
+ const formPartsFromHref = (href: string): { action: string; fields: Array<{ name: string; value: string }> } => {
19
+ const url = new URL(href, "https://astroflare.local");
20
+ return {
21
+ action: url.pathname,
22
+ fields: Array.from(url.searchParams.entries()).map(([name, value]) => ({ name, value })),
23
+ };
24
+ };
18
25
  ---
19
26
 
20
27
  <div class:list={["af-pager flex items-center justify-center gap-2", className]} data-pagination>
@@ -23,13 +30,23 @@ const fallbackPages = Array.from({ length: Math.max(0, pageCount) }, (_, index)
23
30
  "ellipsis" in item ? (
24
31
  <span class="af-pager-ellipsis" aria-hidden="true">{item.label ?? "…"}</span>
25
32
  ) : item.href && !item.active && !item.disabled ? (
26
- <a
27
- href={item.href}
28
- data-page={String(item.page)}
29
- class:list={["af-pager-pill", item.active && "is-active"]}
30
- >
31
- {item.label ?? item.page}
32
- </a>
33
+ (() => {
34
+ const form = formPartsFromHref(item.href);
35
+ return (
36
+ <form method="get" action={form.action} data-astro-reload class="af-pager-form">
37
+ {form.fields.map((field) => (
38
+ <input type="hidden" name={field.name} value={field.value} />
39
+ ))}
40
+ <button
41
+ type="submit"
42
+ data-page={String(item.page)}
43
+ class:list={["af-pager-pill", item.active && "is-active"]}
44
+ >
45
+ {item.label ?? item.page}
46
+ </button>
47
+ </form>
48
+ );
49
+ })()
33
50
  ) : (
34
51
  <button
35
52
  type="button"
@@ -69,18 +86,33 @@ const fallbackPages = Array.from({ length: Math.max(0, pageCount) }, (_, index)
69
86
  display: inline-flex;
70
87
  align-items: center;
71
88
  justify-content: center;
89
+ user-select: none;
90
+ -webkit-user-select: none;
91
+ }
92
+ .af-pager .af-pager-form {
93
+ display: inline-flex;
94
+ margin: 0;
72
95
  }
73
96
  .af-pager .af-pager-pill:disabled,
74
97
  .af-pager .af-pager-pill[aria-disabled="true"] {
75
98
  cursor: not-allowed;
76
- opacity: 0.72;
99
+ opacity: 0.5;
77
100
  pointer-events: none;
101
+ border-color: color-mix(in oklab, var(--color-light-border) 65%, transparent);
102
+ color: color-mix(in oklab, var(--color-light-text-subtle) 50%, transparent);
103
+ background: color-mix(in oklab, var(--color-light-surface) 35%, transparent);
78
104
  }
79
105
  [data-theme="dark"] .af-pager .af-pager-pill {
80
106
  border-color: var(--color-dark-border);
81
107
  color: color-mix(in oklab, var(--color-dark-text-subtle) 78%, transparent);
82
108
  background: color-mix(in oklab, var(--color-dark-surface) 70%, transparent);
83
109
  }
110
+ [data-theme="dark"] .af-pager .af-pager-pill:disabled,
111
+ [data-theme="dark"] .af-pager .af-pager-pill[aria-disabled="true"] {
112
+ border-color: color-mix(in oklab, var(--color-dark-border) 65%, transparent);
113
+ color: color-mix(in oklab, var(--color-dark-text-subtle) 48%, transparent);
114
+ background: color-mix(in oklab, var(--color-dark-surface) 35%, transparent);
115
+ }
84
116
  .af-pager .af-pager-pill.is-active {
85
117
  border-color: var(--color-secondary);
86
118
  color: var(--color-secondary);