@vit-foundation/ui 0.15.0 → 0.17.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.
Files changed (45) hide show
  1. package/README.md +10 -10
  2. package/dist/components/jobs/JobList.svelte +11 -19
  3. package/dist/components/jobs/JobList.svelte.d.ts +1 -1
  4. package/dist/components/layout/Footer.svelte +9 -15
  5. package/dist/components/layout/Nav.svelte +10 -15
  6. package/dist/components/projects/ProjectCard.svelte +2 -2
  7. package/dist/components/projects/ProjectCard.svelte.d.ts +1 -1
  8. package/dist/components/team/CollaboratorList.svelte +10 -16
  9. package/dist/components/timeline/Timeline.svelte +10 -23
  10. package/dist/components/timeline/TimelineMilestone.svelte +2 -2
  11. package/dist/components/timeline/TimelineMilestone.svelte.d.ts +1 -1
  12. package/dist/components/weeklies/WeeklieCard.svelte +2 -2
  13. package/dist/components/weeklies/WeeklieCard.svelte.d.ts +1 -1
  14. package/dist/config/index.d.ts +1 -1
  15. package/dist/config/messages.js +5 -1
  16. package/dist/config/types.d.ts +35 -3
  17. package/dist/content-components.d.ts +6 -1
  18. package/dist/content-components.js +4 -1
  19. package/dist/edit/Editable.svelte +22 -38
  20. package/dist/edit/chrome/EditPanel.svelte +2 -2
  21. package/dist/edit/chrome/EditPanel.svelte.d.ts +2 -2
  22. package/dist/edit/chrome/LinkEdit.svelte +36 -4
  23. package/dist/edit/chrome/LinkEdit.svelte.d.ts +5 -1
  24. package/dist/edit/chrome/PropertyRow.svelte +69 -55
  25. package/dist/edit/chrome/PropertyRow.svelte.d.ts +11 -5
  26. package/dist/edit/collection.svelte.d.ts +49 -0
  27. package/dist/edit/collection.svelte.js +39 -0
  28. package/dist/edit/commit.svelte.d.ts +58 -0
  29. package/dist/edit/commit.svelte.js +70 -0
  30. package/dist/edit/helpers.d.ts +5 -5
  31. package/dist/edit/helpers.js +2 -2
  32. package/dist/edit/index.d.ts +4 -1
  33. package/dist/edit/index.js +1 -0
  34. package/dist/edit/types.d.ts +24 -9
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/utils/milestones.d.ts +15 -0
  38. package/dist/utils/milestones.js +17 -0
  39. package/dist/utils/paths.d.ts +6 -0
  40. package/dist/utils/paths.js +14 -0
  41. package/dist/utils/url-filters.svelte.d.ts +58 -0
  42. package/dist/utils/url-filters.svelte.js +47 -0
  43. package/dist/utils/weekly-list.svelte.d.ts +82 -0
  44. package/dist/utils/weekly-list.svelte.js +120 -0
  45. package/package.json +1 -1
@@ -0,0 +1,70 @@
1
+ export function commitState(initial, messages) {
2
+ let status = $state('idle');
3
+ let announcement = $state('');
4
+ let saved = $state(initial);
5
+ let lastProp = $state(initial);
6
+ return {
7
+ get status() {
8
+ return status;
9
+ },
10
+ get announcement() {
11
+ return announcement;
12
+ },
13
+ get saved() {
14
+ return saved;
15
+ },
16
+ markDirty() {
17
+ if (status !== 'saving')
18
+ status = 'dirty';
19
+ },
20
+ settle() {
21
+ status = 'idle';
22
+ },
23
+ revert() {
24
+ status = 'idle';
25
+ announcement = '';
26
+ },
27
+ refuse(next) {
28
+ status = 'error';
29
+ announcement = next;
30
+ },
31
+ async commit(next, write) {
32
+ status = 'saving';
33
+ announcement = messages.edit_saving();
34
+ try {
35
+ await write();
36
+ saved = next;
37
+ // `lastProp` is NOT advanced here: it tracks the last PROP value
38
+ // seen, and a save does not change the prop. Advancing it made an
39
+ // unchanged prop look like a reload on the next effect run, which
40
+ // then restored the pre-save value over the committed one.
41
+ status = 'idle';
42
+ announcement = messages.edit_saved();
43
+ return true;
44
+ }
45
+ catch {
46
+ status = 'error';
47
+ announcement = messages.edit_saveError();
48
+ return false;
49
+ }
50
+ },
51
+ follow(next) {
52
+ if (next === lastProp)
53
+ return null;
54
+ lastProp = next;
55
+ if (status !== 'idle')
56
+ return null;
57
+ saved = next;
58
+ return next;
59
+ }
60
+ };
61
+ }
62
+ /**
63
+ * A control's string draft as the value the ADAPTER takes. Every control's
64
+ * state is text — a flag's boolean rides as `'true'`/`'false'` in its
65
+ * `<select>` — and this is the one place it becomes a boolean again, so the
66
+ * panel row and the link modal cannot disagree about the boundary.
67
+ */
68
+ export function propertyValue(descriptor, draft) {
69
+ return descriptor.type === 'flag' ? draft === 'true' : draft;
70
+ }
@@ -1,13 +1,12 @@
1
- import type { Locale } from '../config/types.js';
1
+ import type { Locale, NotParameterized, ParameterlessKey } from '../config/types.js';
2
2
  import type { CollectionRef, EditableEntity, EditDescriptor, PropertyDescriptor } from './types.js';
3
3
  /** Everything a property descriptor carries beyond its ref. */
4
4
  type PropertySpec = Omit<PropertyDescriptor, 'ref'>;
5
5
  /**
6
6
  * Descriptor for one interface-wording message (a Paraglide catalog key).
7
- * Only parameterless messages are sensibly editable in place — editing the
8
- * RENDERED text of a parameterized one would overwrite its template.
7
+ * The key type carries the rule — see `ParameterlessKey` in ../config/types.js.
9
8
  */
10
- export declare function chromeEdit(key: string, locale: Locale, options?: {
9
+ export declare function chromeEdit(key: ParameterlessKey, locale: Locale, options?: {
11
10
  format?: EditDescriptor['format'];
12
11
  label?: string;
13
12
  }): EditDescriptor;
@@ -38,8 +37,9 @@ export declare function entityProperty(entity: EditableEntity, id: string | numb
38
37
  /**
39
38
  * A panel property over one interface-wording message — how strings that can
40
39
  * never hold a caret (an `<option>` label, an input placeholder) still edit.
40
+ * Accepts the site's own keys too; see `NotParameterized` in ../config/types.js.
41
41
  */
42
- export declare function chromeProperty(key: string, spec: PropertySpec): PropertyDescriptor;
42
+ export declare function chromeProperty<K extends string>(key: NotParameterized<K>, spec: PropertySpec): PropertyDescriptor;
43
43
  /** Names one entity collection at one render site. */
44
44
  export declare function collectionOf(entity: EditableEntity, scope?: string): CollectionRef;
45
45
  export {};
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Descriptor for one interface-wording message (a Paraglide catalog key).
3
- * Only parameterless messages are sensibly editable in place — editing the
4
- * RENDERED text of a parameterized one would overwrite its template.
3
+ * The key type carries the rule — see `ParameterlessKey` in ../config/types.js.
5
4
  */
6
5
  export function chromeEdit(key, locale, options) {
7
6
  return { ref: { kind: 'chrome', key }, locale, ...options };
@@ -33,6 +32,7 @@ export function entityProperty(entity, id) {
33
32
  /**
34
33
  * A panel property over one interface-wording message — how strings that can
35
34
  * never hold a caret (an `<option>` label, an input placeholder) still edit.
35
+ * Accepts the site's own keys too; see `NotParameterized` in ../config/types.js.
36
36
  */
37
37
  export function chromeProperty(key, spec) {
38
38
  return { ref: { kind: 'chrome', key }, ...spec };
@@ -1,4 +1,6 @@
1
1
  export { getEditAdapter, setEditAdapter } from './context.js';
2
+ export { collectionEditing } from './collection.svelte.js';
3
+ export type { CollectionEditing, RemovableMap } from './collection.svelte.js';
2
4
  export { default as ActionLabel } from './ActionLabel.svelte';
3
5
  export { default as Editable } from './Editable.svelte';
4
6
  export { default as AddSlot } from './chrome/AddSlot.svelte';
@@ -10,4 +12,5 @@ export { default as EditPopover } from './chrome/EditPopover.svelte';
10
12
  export { default as LinkEdit } from './chrome/LinkEdit.svelte';
11
13
  export { chromeEdit, chromeProperty, collectionOf, entityEdit, entityProperty, pageCopyEdit } from './helpers.js';
12
14
  export { localize } from './types.js';
13
- export type { CollectionRef, ContentRef, EditableEntity, EditAdapter, EditDescriptor, EntityOp, LocalizedText, PropertyDescriptor, PropertyOption, PropertyType } from './types.js';
15
+ export type { CollectionRef, ContentRef, EditableEntity, EditAdapter, EditDescriptor, EntityOp, LocalizedText, PropertyDescriptor, PropertyOption, PropertyType, PropertyValue } from './types.js';
16
+ export type { NotParameterized, ParameterlessKey } from '../config/types.js';
@@ -1,4 +1,5 @@
1
1
  export { getEditAdapter, setEditAdapter } from './context.js';
2
+ export { collectionEditing } from './collection.svelte.js';
2
3
  export { default as ActionLabel } from './ActionLabel.svelte';
3
4
  export { default as Editable } from './Editable.svelte';
4
5
  export { default as AddSlot } from './chrome/AddSlot.svelte';
@@ -1,4 +1,4 @@
1
- import type { Locale } from '../config/types.js';
1
+ import type { Locale, ParameterlessKey } from '../config/types.js';
2
2
  /**
3
3
  * The stored shape of one localized text column (`jsonb` keyed by locale).
4
4
  * Catalan is canonical and required — the database enforces `content ? 'ca'`
@@ -52,12 +52,19 @@ export interface EditDescriptor {
52
52
  label?: string;
53
53
  }
54
54
  /**
55
- * The value kinds a property panel can edit. Everything in this content model
56
- * serializes to a string on the wire — an ISO date, a URL, an enum member, a
57
- * storage path — so there is no typed value union: the ADAPTER parses, and
58
- * the host's schemas stay the sole validators.
55
+ * The value kinds a property panel can edit. All but one serialize to a
56
+ * string on the wire — an ISO date, a URL, an enum member, a storage path —
57
+ * so the ADAPTER parses and the host's schemas stay the sole validators. The
58
+ * exception is `flag`: a two-state row whose value IS a boolean (published or
59
+ * not, open or not), so no host has to spell a 'true'/'false' select and no
60
+ * card has to derive one from its data.
59
61
  */
60
- export type PropertyType = 'text' | 'url' | 'date' | 'select' | 'image';
62
+ export type PropertyType = 'text' | 'url' | 'date' | 'select' | 'image' | 'flag';
63
+ /**
64
+ * What a property row hands the adapter: a string for every type but `flag`,
65
+ * a boolean for a flag, `null` when a `nullable` property is cleared.
66
+ */
67
+ export type PropertyValue = string | boolean | null;
61
68
  /** One choice of a `select` property. */
62
69
  export interface PropertyOption {
63
70
  value: string;
@@ -77,6 +84,13 @@ export interface PropertyDescriptor {
77
84
  label: string;
78
85
  /** Required when `type` is 'select'. */
79
86
  options?: readonly PropertyOption[];
87
+ /**
88
+ * 'flag' only: the catalog keys wording each state, so the same row reads
89
+ * «Publicat / Esborrany» on a card and «Oberta / Tancada» on an opening.
90
+ * Default `status_published` / `status_draft`.
91
+ */
92
+ on?: ParameterlessKey;
93
+ off?: ParameterlessKey;
80
94
  placeholder?: string;
81
95
  /** Clearing the field saves null (linkUrl, externalUrl). */
82
96
  nullable?: boolean;
@@ -135,10 +149,11 @@ export interface EditAdapter {
135
149
  readonly isEditing: boolean;
136
150
  save(descriptor: EditDescriptor, value: string): Promise<void>;
137
151
  /**
138
- * Panel property save. The value stays a string on the wire (ISO date,
139
- * url, enum member, image path); `null` clears a `nullable` property.
152
+ * Panel property save. A string on the wire for every type but `flag`
153
+ * (ISO date, url, enum member, image path), a boolean for a flag; `null`
154
+ * clears a `nullable` property.
140
155
  */
141
- saveProperty?(descriptor: PropertyDescriptor, value: string | null): Promise<void>;
156
+ saveProperty?(descriptor: PropertyDescriptor, value: PropertyValue): Promise<void>;
142
157
  /**
143
158
  * Structural collection ops. `create` may resolve the new row's id so the
144
159
  * UI can point at it after the host's refresh.
package/dist/index.d.ts CHANGED
@@ -6,4 +6,4 @@ export * from './admin.js';
6
6
  export * from './forms/index.js';
7
7
  export * from './config/index.js';
8
8
  export * from './edit/index.js';
9
- export { isPathUnder } from './utils/paths.js';
9
+ export { buildQueryString, isPathUnder } from './utils/paths.js';
package/dist/index.js CHANGED
@@ -12,5 +12,5 @@ export * from './forms/index.js';
12
12
  // App wiring: configuration context and the edit-mode contract.
13
13
  export * from './config/index.js';
14
14
  export * from './edit/index.js';
15
- // Path utility shared with host apps.
16
- export { isPathUnder } from './utils/paths.js';
15
+ // Path utilities shared with host apps.
16
+ export { buildQueryString, isPathUnder } from './utils/paths.js';
@@ -7,5 +7,20 @@ import type { MilestoneCategory } from '../content/types.js';
7
7
  * tokens.css.
8
8
  */
9
9
  export declare const MILESTONE_CATEGORY_COLOR: Record<MilestoneCategory, string>;
10
+ /**
11
+ * The transparency page's client-side filter: the timeline is small enough
12
+ * to filter in the browser (a choice its load function records), and this is
13
+ * the predicate — named and tested here rather than closing over a page's
14
+ * `data` inside a `$derived`. Category is exact; the query is a trimmed,
15
+ * case-insensitive substring of title + body.
16
+ */
17
+ export declare function matchesMilestoneFilter(milestone: {
18
+ title: string;
19
+ body: string | null;
20
+ category: MilestoneCategory;
21
+ }, filter: {
22
+ q: string;
23
+ category: MilestoneCategory | null;
24
+ }): boolean;
10
25
  /** The category's label in the host app's copy. */
11
26
  export declare function milestoneCategoryLabel(category: MilestoneCategory, messages: UiMessages): string;
@@ -11,6 +11,23 @@ export const MILESTONE_CATEGORY_COLOR = {
11
11
  collaboration: 'var(--series-4)',
12
12
  press: 'var(--series-5)'
13
13
  };
14
+ /**
15
+ * The transparency page's client-side filter: the timeline is small enough
16
+ * to filter in the browser (a choice its load function records), and this is
17
+ * the predicate — named and tested here rather than closing over a page's
18
+ * `data` inside a `$derived`. Category is exact; the query is a trimmed,
19
+ * case-insensitive substring of title + body.
20
+ */
21
+ export function matchesMilestoneFilter(milestone, filter) {
22
+ if (filter.category && milestone.category !== filter.category)
23
+ return false;
24
+ if (!filter.q)
25
+ return true;
26
+ const needle = filter.q.trim().toLowerCase();
27
+ if (!needle)
28
+ return true;
29
+ return `${milestone.title} ${milestone.body ?? ''}`.toLowerCase().includes(needle);
30
+ }
14
31
  /** The category's label in the host app's copy. */
15
32
  export function milestoneCategoryLabel(category, messages) {
16
33
  switch (category) {
@@ -1,2 +1,8 @@
1
1
  /** Prefix match over a URL pathname: '/what-we-do' covers '/what-we-do/<slug>'. */
2
2
  export declare function isPathUnder(pathname: string, prefix: string): boolean;
3
+ /**
4
+ * Builds "?a=1&b=2" from the truthy entries, or "" when none remain — the one
5
+ * way a filter or a page number joins a canonical path, so the URL a list
6
+ * mirrors and the hrefs it renders are spelled by the same function.
7
+ */
8
+ export declare function buildQueryString(params: Record<string, string | null | undefined>): string;
@@ -2,3 +2,17 @@
2
2
  export function isPathUnder(pathname, prefix) {
3
3
  return pathname === prefix || pathname.startsWith(`${prefix}/`);
4
4
  }
5
+ /**
6
+ * Builds "?a=1&b=2" from the truthy entries, or "" when none remain — the one
7
+ * way a filter or a page number joins a canonical path, so the URL a list
8
+ * mirrors and the hrefs it renders are spelled by the same function.
9
+ */
10
+ export function buildQueryString(params) {
11
+ const search = new URLSearchParams();
12
+ for (const [key, value] of Object.entries(params)) {
13
+ if (value)
14
+ search.set(key, value);
15
+ }
16
+ const encoded = search.toString();
17
+ return encoded ? `?${encoded}` : '';
18
+ }
@@ -0,0 +1,58 @@
1
+ export interface UrlFiltersConfig<T extends Record<string, unknown>> {
2
+ /** Unlocalized path the query string is appended to. */
3
+ path: string;
4
+ /**
5
+ * The server-rendered values. A thunk so callers reference props in a
6
+ * closure; read inside an effect, so it tracks them and re-seeds when a
7
+ * navigation changes what the server sent.
8
+ */
9
+ initial: () => T;
10
+ /** Which values ride the URL. Null/undefined/empty entries are dropped. */
11
+ toQuery: (values: T) => Record<string, string | null | undefined>;
12
+ /** Runs after the URL is mirrored; omit for purely client-side filtering. */
13
+ onChange?: () => void;
14
+ /**
15
+ * How a mirrored URL is written. Required, not defaulted: shallow routing
16
+ * is the host's router's (SvelteKit's `replaceState` through its locale
17
+ * prefixing), and the package has no router — the same reason hrefs
18
+ * resolve through `UiConfig.href`.
19
+ */
20
+ replaceUrl: (path: string) => void;
21
+ }
22
+ export interface UrlFilters<T extends Record<string, unknown>> {
23
+ /** Current filter values, for the controls to render. */
24
+ readonly values: T;
25
+ /**
26
+ * The params riding the URL for the current values. Exposed so a caller
27
+ * can build a URL that carries the filters plus something of its own —
28
+ * the weeklies index adds a page number. What that extra means, and when
29
+ * it survives a filter change, is the caller's rule.
30
+ */
31
+ readonly query: Record<string, string | null | undefined>;
32
+ /** Applies a change, mirrors it into the URL, then notifies. */
33
+ update(patch: Partial<T>): void;
34
+ }
35
+ /**
36
+ * Filter state that lives in the URL, in both directions: seeded from the
37
+ * values the server rendered, mirrored back on every client change so the
38
+ * page stays deep-linkable, and re-seeded whenever the server sends
39
+ * different ones.
40
+ *
41
+ * The site's /weeklies and /transparency each wrote this by hand — the
42
+ * seeding, the mirroring, and a handful of `state_referenced_locally`
43
+ * suppressions apiece. `initial` is a thunk, so a page reads its
44
+ * server-rendered data inside a closure, which is what the compiler asked for.
45
+ *
46
+ * The client→URL direction alone was not enough. Read once, `initial` left a
47
+ * same-route navigation carrying different filters — the header's own
48
+ * "Weeklies" link, followed from /weeklies?theme=salut — with the controls
49
+ * displaying the previous filter while the page below showed the server's
50
+ * unfiltered answer. Which surface went stale differed per caller: one
51
+ * contradicted its own chips, the other kept filtering by a chip the URL no
52
+ * longer carried. One rule, two symptoms, so it lives here and not in either.
53
+ *
54
+ * What a page does *with* a change stays the page's — refetch through a
55
+ * remote function, or filter a list it already has — which is why this owns
56
+ * the URL and not the filtering.
57
+ */
58
+ export declare function createUrlFilters<T extends Record<string, unknown>>(config: UrlFiltersConfig<T>): UrlFilters<T>;
@@ -0,0 +1,47 @@
1
+ import { buildQueryString } from './paths.js';
2
+ /**
3
+ * Filter state that lives in the URL, in both directions: seeded from the
4
+ * values the server rendered, mirrored back on every client change so the
5
+ * page stays deep-linkable, and re-seeded whenever the server sends
6
+ * different ones.
7
+ *
8
+ * The site's /weeklies and /transparency each wrote this by hand — the
9
+ * seeding, the mirroring, and a handful of `state_referenced_locally`
10
+ * suppressions apiece. `initial` is a thunk, so a page reads its
11
+ * server-rendered data inside a closure, which is what the compiler asked for.
12
+ *
13
+ * The client→URL direction alone was not enough. Read once, `initial` left a
14
+ * same-route navigation carrying different filters — the header's own
15
+ * "Weeklies" link, followed from /weeklies?theme=salut — with the controls
16
+ * displaying the previous filter while the page below showed the server's
17
+ * unfiltered answer. Which surface went stale differed per caller: one
18
+ * contradicted its own chips, the other kept filtering by a chip the URL no
19
+ * longer carried. One rule, two symptoms, so it lives here and not in either.
20
+ *
21
+ * What a page does *with* a change stays the page's — refetch through a
22
+ * remote function, or filter a list it already has — which is why this owns
23
+ * the URL and not the filtering.
24
+ */
25
+ export function createUrlFilters(config) {
26
+ const values = $state({ ...config.initial() });
27
+ // A client change mirrors through replaceState, which does not re-run the
28
+ // load — so `initial()` still reports what the server last sent and this
29
+ // does not fight the reader's own filtering. It fires on a real
30
+ // navigation, exactly when the controls would otherwise keep old filters.
31
+ $effect(() => {
32
+ Object.assign(values, config.initial());
33
+ });
34
+ return {
35
+ get values() {
36
+ return values;
37
+ },
38
+ get query() {
39
+ return config.toQuery(values);
40
+ },
41
+ update(patch) {
42
+ Object.assign(values, patch);
43
+ config.replaceUrl(`${config.path}${buildQueryString(config.toQuery(values))}`);
44
+ config.onChange?.();
45
+ }
46
+ };
47
+ }
@@ -0,0 +1,82 @@
1
+ import type { Locale } from '../config/types.js';
2
+ import type { SortDirection, WeeklyCardData } from '../content/types.js';
3
+ /**
4
+ * The weeklies index's URL defaults: the value a param stands for when it is
5
+ * absent. Both halves of the URL contract — this module omitting a default,
6
+ * the host's query schema supplying one — must name the same values, so a
7
+ * host derives its schema defaults from here rather than restating them.
8
+ */
9
+ export declare const WEEKLY_LIST_DEFAULTS: {
10
+ readonly sort: "desc";
11
+ readonly page: 1;
12
+ };
13
+ /** A type alias, not an interface: createUrlFilters needs an index signature. */
14
+ export type WeeklyListFilters = {
15
+ q: string;
16
+ theme: string | null;
17
+ sort: SortDirection;
18
+ };
19
+ /** One page of the list plus the unpaged total (for the pagination). */
20
+ export interface WeeklyListPage {
21
+ items: WeeklyCardData[];
22
+ total: number;
23
+ }
24
+ /** The server-rendered page, re-read on every navigation. */
25
+ export interface WeeklyListServerData {
26
+ weeklies: WeeklyCardData[];
27
+ total: number;
28
+ page: number;
29
+ pageSize: number;
30
+ query: WeeklyListFilters;
31
+ }
32
+ export interface WeeklyListConfig {
33
+ /** Reads the route's `data`; called inside an effect, so it tracks it. */
34
+ server: () => WeeklyListServerData;
35
+ /**
36
+ * Fetches the first page of a filter — the host's remote query. Required,
37
+ * like `locale` and `replaceUrl`: the package never talks to a backend, an
38
+ * i18n runtime or a router, so the site hands in its own three.
39
+ */
40
+ fetchPage: (input: {
41
+ q?: string;
42
+ theme?: string | null;
43
+ sort: SortDirection;
44
+ limit: number;
45
+ locale: Locale;
46
+ }) => Promise<WeeklyListPage>;
47
+ /** The locale being rendered. Reactive read. */
48
+ locale: () => Locale;
49
+ /** Shallow-routing URL write — see UrlFiltersConfig.replaceUrl. */
50
+ replaceUrl: (path: string) => void;
51
+ }
52
+ export interface WeeklyList {
53
+ readonly items: WeeklyCardData[];
54
+ readonly total: number;
55
+ /** A client-side refetch always shows the first page of the new filter. */
56
+ readonly page: number;
57
+ readonly isLoading: boolean;
58
+ readonly loadError: boolean;
59
+ /** Current filter values, for the controls to render. */
60
+ readonly filters: WeeklyListFilters;
61
+ /** Href for a 1-based page, carrying the active filters. */
62
+ hrefFor(page: number): string;
63
+ /** Applies a filter change: mirrors the URL, then refetches page one. */
64
+ update(patch: Partial<WeeklyListFilters>): void;
65
+ }
66
+ /**
67
+ * The weeklies index view: the filters that ride the URL, the page the
68
+ * server rendered, and the page a filter change refetched in place.
69
+ *
70
+ * The two disagree by design — a changed filter returns to the first page
71
+ * while the URL still carries the old one — and reconciling them was four
72
+ * `$state` cells, an `$effect` and three `$derived` sitting in a route file,
73
+ * where nothing could reach them. Two things went wrong there: `loadError`
74
+ * was never cleared on navigation, so a failed refetch kept its alert on
75
+ * screen above fresh results; and a failed refetch left the URL, the grid
76
+ * and the pagination status giving three different answers.
77
+ *
78
+ * `createUrlFilters` owns the URL and deliberately not the fetching, because
79
+ * the transparency page filters client-side over data it already has. This
80
+ * owns the fetching and the reconciliation, for the one page that does both.
81
+ */
82
+ export declare function createWeeklyList(config: WeeklyListConfig): WeeklyList;
@@ -0,0 +1,120 @@
1
+ import { buildQueryString } from './paths.js';
2
+ import { createUrlFilters } from './url-filters.svelte.js';
3
+ /**
4
+ * The weeklies index's URL defaults: the value a param stands for when it is
5
+ * absent. Both halves of the URL contract — this module omitting a default,
6
+ * the host's query schema supplying one — must name the same values, so a
7
+ * host derives its schema defaults from here rather than restating them.
8
+ */
9
+ export const WEEKLY_LIST_DEFAULTS = { sort: 'desc', page: 1 };
10
+ /** The index's path, shared by the mirrored URL and the paging hrefs. */
11
+ const WEEKLIES_PATH = '/weeklies';
12
+ /**
13
+ * The weeklies index view: the filters that ride the URL, the page the
14
+ * server rendered, and the page a filter change refetched in place.
15
+ *
16
+ * The two disagree by design — a changed filter returns to the first page
17
+ * while the URL still carries the old one — and reconciling them was four
18
+ * `$state` cells, an `$effect` and three `$derived` sitting in a route file,
19
+ * where nothing could reach them. Two things went wrong there: `loadError`
20
+ * was never cleared on navigation, so a failed refetch kept its alert on
21
+ * screen above fresh results; and a failed refetch left the URL, the grid
22
+ * and the pagination status giving three different answers.
23
+ *
24
+ * `createUrlFilters` owns the URL and deliberately not the fetching, because
25
+ * the transparency page filters client-side over data it already has. This
26
+ * owns the fetching and the reconciliation, for the one page that does both.
27
+ */
28
+ export function createWeeklyList(config) {
29
+ // The client's answer, or null while the server's still stands.
30
+ let clientItems = $state(null);
31
+ let clientTotal = $state(null);
32
+ let isLoading = $state(false);
33
+ let loadError = $state(false);
34
+ const filters = createUrlFilters({
35
+ path: WEEKLIES_PATH,
36
+ initial: () => ({ ...config.server().query }),
37
+ // A param is left out exactly when it holds the value the parse would
38
+ // have supplied anyway, so both sides read the same declaration —
39
+ // otherwise "oldest first" builds a link that reloads as newest-first.
40
+ toQuery: (values) => ({
41
+ q: values.q,
42
+ theme: values.theme,
43
+ sort: values.sort !== WEEKLY_LIST_DEFAULTS.sort ? values.sort : null
44
+ }),
45
+ onChange: () => void refresh(),
46
+ replaceUrl: config.replaceUrl
47
+ });
48
+ $effect(() => {
49
+ // Paging is a real navigation, so the load re-runs and replaces the
50
+ // server data. Reading it here registers the dependency and drops the
51
+ // client override, which would otherwise keep the previous filter's
52
+ // first page on screen while the URL claimed to be on page 2.
53
+ config.server();
54
+ clientItems = null;
55
+ clientTotal = null;
56
+ // Cleared with the rest: the route file reset only the two lists, so a
57
+ // refetch that failed kept its alert across the next navigation.
58
+ loadError = false;
59
+ });
60
+ async function refresh() {
61
+ isLoading = true;
62
+ const { q, theme, sort } = filters.values;
63
+ try {
64
+ const result = await config.fetchPage({
65
+ q: q || undefined,
66
+ theme,
67
+ sort,
68
+ limit: config.server().pageSize,
69
+ locale: config.locale()
70
+ });
71
+ clientItems = result.items;
72
+ clientTotal = result.total;
73
+ loadError = false;
74
+ }
75
+ catch {
76
+ // Keep the stale list on screen; `loadError` explains the failure.
77
+ loadError = true;
78
+ }
79
+ finally {
80
+ isLoading = false;
81
+ }
82
+ }
83
+ return {
84
+ get items() {
85
+ return clientItems ?? config.server().weeklies;
86
+ },
87
+ get total() {
88
+ return clientTotal ?? config.server().total;
89
+ },
90
+ get page() {
91
+ return clientItems ? 1 : config.server().page;
92
+ },
93
+ get isLoading() {
94
+ return isLoading;
95
+ },
96
+ get loadError() {
97
+ return loadError;
98
+ },
99
+ get filters() {
100
+ return filters.values;
101
+ },
102
+ /**
103
+ * Paging is this module's concern and not the URL module's — the
104
+ * transparency page filters a list it already has and has no page at
105
+ * all — so the page number is added here rather than held in the
106
+ * filter state. That is also what makes a filter change drop it: the
107
+ * mirrored URL carries only filters, and a changed filter invalidates
108
+ * the position within the old result set.
109
+ */
110
+ hrefFor(page) {
111
+ return `${WEEKLIES_PATH}${buildQueryString({
112
+ ...filters.query,
113
+ page: page > WEEKLY_LIST_DEFAULTS.page ? String(page) : null
114
+ })}`;
115
+ },
116
+ update(patch) {
117
+ filters.update(patch);
118
+ }
119
+ };
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vit-foundation/ui",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",