@vit-foundation/ui 0.1.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/LICENSE +201 -0
- package/README.md +129 -0
- package/dist/components/jobs/JobList.svelte +88 -0
- package/dist/components/jobs/JobList.svelte.d.ts +18 -0
- package/dist/components/jobs/index.d.ts +2 -0
- package/dist/components/jobs/index.js +1 -0
- package/dist/components/layout/Footer.svelte +79 -0
- package/dist/components/layout/Footer.svelte.d.ts +12 -0
- package/dist/components/layout/Nav.svelte +229 -0
- package/dist/components/layout/Nav.svelte.d.ts +18 -0
- package/dist/components/layout/PageShell.svelte +107 -0
- package/dist/components/layout/PageShell.svelte.d.ts +42 -0
- package/dist/components/layout/index.d.ts +3 -0
- package/dist/components/layout/index.js +3 -0
- package/dist/components/projects/ProjectCard.svelte +107 -0
- package/dist/components/projects/ProjectCard.svelte.d.ts +17 -0
- package/dist/components/projects/index.d.ts +2 -0
- package/dist/components/projects/index.js +1 -0
- package/dist/components/team/CollaboratorList.svelte +37 -0
- package/dist/components/team/CollaboratorList.svelte.d.ts +7 -0
- package/dist/components/team/TeamMemberCard.svelte +78 -0
- package/dist/components/team/TeamMemberCard.svelte.d.ts +21 -0
- package/dist/components/team/index.d.ts +3 -0
- package/dist/components/team/index.js +2 -0
- package/dist/components/timeline/Timeline.svelte +116 -0
- package/dist/components/timeline/Timeline.svelte.d.ts +12 -0
- package/dist/components/timeline/TimelineMilestone.svelte +127 -0
- package/dist/components/timeline/TimelineMilestone.svelte.d.ts +15 -0
- package/dist/components/timeline/index.d.ts +3 -0
- package/dist/components/timeline/index.js +2 -0
- package/dist/components/ui/Button.svelte +53 -0
- package/dist/components/ui/Button.svelte.d.ts +21 -0
- package/dist/components/ui/CardMedia.svelte +23 -0
- package/dist/components/ui/CardMedia.svelte.d.ts +11 -0
- package/dist/components/ui/CardTitle.svelte +28 -0
- package/dist/components/ui/CardTitle.svelte.d.ts +8 -0
- package/dist/components/ui/CopyIntro.svelte +40 -0
- package/dist/components/ui/CopyIntro.svelte.d.ts +24 -0
- package/dist/components/ui/DateText.svelte +43 -0
- package/dist/components/ui/DateText.svelte.d.ts +28 -0
- package/dist/components/ui/DecorShapes.svelte +36 -0
- package/dist/components/ui/DecorShapes.svelte.d.ts +7 -0
- package/dist/components/ui/Field.svelte +89 -0
- package/dist/components/ui/Field.svelte.d.ts +55 -0
- package/dist/components/ui/FilterChips.svelte +43 -0
- package/dist/components/ui/FilterChips.svelte.d.ts +15 -0
- package/dist/components/ui/FormErrorFeedback.svelte +55 -0
- package/dist/components/ui/FormErrorFeedback.svelte.d.ts +33 -0
- package/dist/components/ui/FormFeedback.svelte +65 -0
- package/dist/components/ui/FormFeedback.svelte.d.ts +30 -0
- package/dist/components/ui/FormResultSlot.svelte +33 -0
- package/dist/components/ui/FormResultSlot.svelte.d.ts +26 -0
- package/dist/components/ui/GhostButton.svelte +46 -0
- package/dist/components/ui/GhostButton.svelte.d.ts +10 -0
- package/dist/components/ui/Link.svelte +30 -0
- package/dist/components/ui/Link.svelte.d.ts +21 -0
- package/dist/components/ui/Pagination.svelte +77 -0
- package/dist/components/ui/Pagination.svelte.d.ts +18 -0
- package/dist/components/ui/RichText.svelte +160 -0
- package/dist/components/ui/RichText.svelte.d.ts +18 -0
- package/dist/components/ui/SearchInput.svelte +90 -0
- package/dist/components/ui/SearchInput.svelte.d.ts +13 -0
- package/dist/components/ui/ShareRow.svelte +95 -0
- package/dist/components/ui/ShareRow.svelte.d.ts +19 -0
- package/dist/components/ui/index.d.ts +17 -0
- package/dist/components/ui/index.js +17 -0
- package/dist/components/weeklies/SortSelect.svelte +42 -0
- package/dist/components/weeklies/SortSelect.svelte.d.ts +8 -0
- package/dist/components/weeklies/WeeklieCard.svelte +82 -0
- package/dist/components/weeklies/WeeklieCard.svelte.d.ts +15 -0
- package/dist/components/weeklies/index.d.ts +3 -0
- package/dist/components/weeklies/index.js +2 -0
- package/dist/config/UiProvider.svelte +21 -0
- package/dist/config/UiProvider.svelte.d.ts +14 -0
- package/dist/config/context.d.ts +17 -0
- package/dist/config/context.js +47 -0
- package/dist/config/index.d.ts +5 -0
- package/dist/config/index.js +4 -0
- package/dist/config/messages.d.ts +7 -0
- package/dist/config/messages.js +38 -0
- package/dist/config/types.d.ts +99 -0
- package/dist/config/types.js +9 -0
- package/dist/content/index.d.ts +3 -0
- package/dist/content/index.js +1 -0
- package/dist/content/richtext.d.ts +10 -0
- package/dist/content/richtext.js +14 -0
- package/dist/content/types.d.ts +71 -0
- package/dist/content/types.js +6 -0
- package/dist/edit/Editable.svelte +197 -0
- package/dist/edit/Editable.svelte.d.ts +52 -0
- package/dist/edit/context.d.ts +10 -0
- package/dist/edit/context.js +15 -0
- package/dist/edit/helpers.d.ts +18 -0
- package/dist/edit/helpers.js +14 -0
- package/dist/edit/index.d.ts +5 -0
- package/dist/edit/index.js +4 -0
- package/dist/edit/types.d.ts +64 -0
- package/dist/edit/types.js +4 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +17 -0
- package/dist/styles/base.css +67 -0
- package/dist/styles/tokens.css +57 -0
- package/dist/utils/dates.d.ts +14 -0
- package/dist/utils/dates.js +34 -0
- package/dist/utils/milestones.d.ts +11 -0
- package/dist/utils/milestones.js +28 -0
- package/dist/utils/paths.d.ts +2 -0
- package/dist/utils/paths.js +4 -0
- package/package.json +139 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import { defaultMessages } from './messages.js';
|
|
3
|
+
import { BASE_LOCALE, LOCALES } from './types.js';
|
|
4
|
+
const KEY = Symbol.for('@vit-foundation/ui:config');
|
|
5
|
+
/**
|
|
6
|
+
* The provider-less config: identity hrefs, Catalan, no router. Complete on
|
|
7
|
+
* purpose — every component must render without a provider, which is what
|
|
8
|
+
* keeps stories and component tests free of app wiring.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_UI_CONFIG = {
|
|
11
|
+
href: (path) => path,
|
|
12
|
+
locale: () => BASE_LOCALE,
|
|
13
|
+
locales: LOCALES,
|
|
14
|
+
url: () => undefined,
|
|
15
|
+
canonicalPathname: (url) => url.pathname,
|
|
16
|
+
siteName: 'ViT',
|
|
17
|
+
messages: defaultMessages
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Installs the app's config for the subtree. Takes a *thunk* so a provider
|
|
21
|
+
* whose input is a reactive prop stays live: the resolved object delegates
|
|
22
|
+
* every read back through `input()`, and Svelte's reactivity flows through
|
|
23
|
+
* the getters. Called by UiProvider; call it directly only from a layout-like
|
|
24
|
+
* component's init.
|
|
25
|
+
*/
|
|
26
|
+
export function setUiConfig(input) {
|
|
27
|
+
const resolved = {
|
|
28
|
+
href: (path, options) => (input().href ?? DEFAULT_UI_CONFIG.href)(path, options),
|
|
29
|
+
locale: () => (input().locale ?? DEFAULT_UI_CONFIG.locale)(),
|
|
30
|
+
get locales() {
|
|
31
|
+
return input().locales ?? LOCALES;
|
|
32
|
+
},
|
|
33
|
+
url: () => input().url?.(),
|
|
34
|
+
canonicalPathname: (url) => (input().canonicalPathname ?? DEFAULT_UI_CONFIG.canonicalPathname)(url),
|
|
35
|
+
get siteName() {
|
|
36
|
+
return input().siteName ?? DEFAULT_UI_CONFIG.siteName;
|
|
37
|
+
},
|
|
38
|
+
get messages() {
|
|
39
|
+
return input().messages ?? defaultMessages;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return setContext(KEY, resolved);
|
|
43
|
+
}
|
|
44
|
+
/** The nearest provider's config, or the standalone defaults. */
|
|
45
|
+
export function getUiConfig() {
|
|
46
|
+
return getContext(KEY) ?? DEFAULT_UI_CONFIG;
|
|
47
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { DEFAULT_UI_CONFIG, getUiConfig, setUiConfig } from './context.js';
|
|
2
|
+
export { defaultMessages } from './messages.js';
|
|
3
|
+
export { default as UiProvider } from './UiProvider.svelte';
|
|
4
|
+
export { BASE_LOCALE, LOCALES } from './types.js';
|
|
5
|
+
export type { Locale, SiteLink, UiConfig, UiConfigInput, UiMessages } from './types.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { UiMessages } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Catalan fallback copy — the foundation's default UI language. Used whole
|
|
4
|
+
* when no provider is mounted, and never merged key-by-key: `messages` is
|
|
5
|
+
* all-or-nothing so an app cannot ship a half-translated surface silently.
|
|
6
|
+
*/
|
|
7
|
+
export declare const defaultMessages: UiMessages;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalan fallback copy — the foundation's default UI language. Used whole
|
|
3
|
+
* when no provider is mounted, and never merged key-by-key: `messages` is
|
|
4
|
+
* all-or-nothing so an app cannot ship a half-translated surface silently.
|
|
5
|
+
*/
|
|
6
|
+
export const defaultMessages = {
|
|
7
|
+
common_readMore: () => 'Llegeix més',
|
|
8
|
+
pagination_label: () => 'Paginació de weeklies',
|
|
9
|
+
pagination_previous: () => 'Anterior',
|
|
10
|
+
pagination_next: () => 'Següent',
|
|
11
|
+
pagination_status: ({ page, pages }) => `Pàgina ${page} de ${pages}`,
|
|
12
|
+
share_label: () => 'Comparteix',
|
|
13
|
+
share_copyLink: () => "Copia l'enllaç",
|
|
14
|
+
share_copied: () => 'Enllaç copiat!',
|
|
15
|
+
form_error_generic: () => 'Hi ha hagut un error. Torna-ho a provar.',
|
|
16
|
+
form_error_rateLimited: () => 'Has fet massa intents en poc temps. Espera uns minuts i torna-ho a provar.',
|
|
17
|
+
form_error_unauthenticated: () => 'Per continuar cal iniciar la sessió.',
|
|
18
|
+
form_error_forbidden: () => 'No tens permís per fer aquesta acció.',
|
|
19
|
+
form_error_unavailable: () => 'Aquesta acció no està disponible ara mateix. Torna-ho a provar més tard.',
|
|
20
|
+
weeklie_number: ({ number }) => `#${number}`,
|
|
21
|
+
weeklies_sortLabel: () => 'Ordena per',
|
|
22
|
+
weeklies_sortDesc: () => 'Data descendent',
|
|
23
|
+
weeklies_sortAsc: () => 'Data ascendent',
|
|
24
|
+
timeline_label: () => 'Cronologia de fites',
|
|
25
|
+
nav_mainLabel: () => 'Navegació principal',
|
|
26
|
+
nav_home: () => 'Inici',
|
|
27
|
+
nav_menuLabel: () => 'Menú',
|
|
28
|
+
account_navLabel: () => 'El teu compte',
|
|
29
|
+
lang_switcherLabel: () => 'Idioma',
|
|
30
|
+
footer_rights: () => 'Fundació Visualization for Transparency. Tots els drets reservats.',
|
|
31
|
+
jobs_empty: () => 'Ara mateix no tenim cap oferta oberta.',
|
|
32
|
+
jobs_newsletterNudge: () => 'Res que encaixi amb el teu perfil? Subscriu-te al butlletí per estar al dia.',
|
|
33
|
+
category_foundation: () => 'Fundació',
|
|
34
|
+
category_lab: () => 'Laboratori',
|
|
35
|
+
category_education: () => 'Educació',
|
|
36
|
+
category_collaboration: () => 'Col·laboració',
|
|
37
|
+
category_press: () => 'Premsa'
|
|
38
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's locales and their canonical member. Fixed rather than generic:
|
|
3
|
+
* `ca` is load-bearing — it is the required key of every localized column
|
|
4
|
+
* (see LocalizedText in ../edit/types.js) and the fallback `localize()`
|
|
5
|
+
* resolves to — so widening the set is a deliberate package change, not a
|
|
6
|
+
* consumer option.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LOCALES: readonly ["ca", "en", "es"];
|
|
9
|
+
export type Locale = (typeof LOCALES)[number];
|
|
10
|
+
export declare const BASE_LOCALE: Locale;
|
|
11
|
+
/** One primary-navigation entry, rendered by Nav and repeated by Footer. */
|
|
12
|
+
export interface SiteLink {
|
|
13
|
+
href: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The strings the components render, as message *functions* so the app's
|
|
18
|
+
* i18n runtime resolves the active locale at call time. The shape is a
|
|
19
|
+
* structural subset of a Paraglide `messages` module — an app that compiles
|
|
20
|
+
* these keys can pass its `m` object wholesale.
|
|
21
|
+
*
|
|
22
|
+
* Keys consumed only through this package still count as used: an app
|
|
23
|
+
* pruning its message catalogue must keep every key named here.
|
|
24
|
+
*/
|
|
25
|
+
export interface UiMessages {
|
|
26
|
+
common_readMore(): string;
|
|
27
|
+
pagination_label(): string;
|
|
28
|
+
pagination_previous(): string;
|
|
29
|
+
pagination_next(): string;
|
|
30
|
+
pagination_status(params: {
|
|
31
|
+
page: number;
|
|
32
|
+
pages: number;
|
|
33
|
+
}): string;
|
|
34
|
+
share_label(): string;
|
|
35
|
+
share_copyLink(): string;
|
|
36
|
+
share_copied(): string;
|
|
37
|
+
form_error_generic(): string;
|
|
38
|
+
form_error_rateLimited(): string;
|
|
39
|
+
form_error_unauthenticated(): string;
|
|
40
|
+
form_error_forbidden(): string;
|
|
41
|
+
form_error_unavailable(): string;
|
|
42
|
+
weeklie_number(params: {
|
|
43
|
+
number: number;
|
|
44
|
+
}): string;
|
|
45
|
+
weeklies_sortLabel(): string;
|
|
46
|
+
weeklies_sortDesc(): string;
|
|
47
|
+
weeklies_sortAsc(): string;
|
|
48
|
+
timeline_label(): string;
|
|
49
|
+
nav_mainLabel(): string;
|
|
50
|
+
nav_home(): string;
|
|
51
|
+
nav_menuLabel(): string;
|
|
52
|
+
account_navLabel(): string;
|
|
53
|
+
lang_switcherLabel(): string;
|
|
54
|
+
footer_rights(): string;
|
|
55
|
+
jobs_empty(): string;
|
|
56
|
+
jobs_newsletterNudge(): string;
|
|
57
|
+
category_foundation(): string;
|
|
58
|
+
category_lab(): string;
|
|
59
|
+
category_education(): string;
|
|
60
|
+
category_collaboration(): string;
|
|
61
|
+
category_press(): string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Everything the components read from their host app, resolved. Apps provide
|
|
65
|
+
* a partial (`UiConfigInput`) through UiProvider; components read this
|
|
66
|
+
* through `getUiConfig()`, which fills the gaps with identity/Catalan
|
|
67
|
+
* defaults so the package renders standalone (stories, tests, previews).
|
|
68
|
+
*
|
|
69
|
+
* `locale()` and `url()` are function reads, not snapshots, so the provider's
|
|
70
|
+
* reactive sources (an i18n runtime, the router's current URL) flow through:
|
|
71
|
+
* a provider that passes a captured value freezes locale switching and
|
|
72
|
+
* current-page highlighting.
|
|
73
|
+
*/
|
|
74
|
+
export interface UiConfig {
|
|
75
|
+
/** Resolve a canonical internal path to a final href (e.g. locale prefixing). */
|
|
76
|
+
href(path: string, options?: {
|
|
77
|
+
locale?: Locale;
|
|
78
|
+
}): string;
|
|
79
|
+
/** The locale being rendered. Reactive read. */
|
|
80
|
+
locale(): Locale;
|
|
81
|
+
locales: readonly Locale[];
|
|
82
|
+
/** The URL being rendered, when the app has a router to ask. Reactive read. */
|
|
83
|
+
url(): URL | undefined;
|
|
84
|
+
/** Canonical (de-localized) pathname of a URL, for current-page checks. */
|
|
85
|
+
canonicalPathname(url: URL): string;
|
|
86
|
+
/** Appended to document titles and shown as the wordmark. */
|
|
87
|
+
siteName: string;
|
|
88
|
+
messages: UiMessages;
|
|
89
|
+
}
|
|
90
|
+
/** What an app hands UiProvider: any subset; the rest keeps package defaults. */
|
|
91
|
+
export interface UiConfigInput {
|
|
92
|
+
href?: UiConfig['href'];
|
|
93
|
+
locale?: UiConfig['locale'];
|
|
94
|
+
locales?: readonly Locale[];
|
|
95
|
+
url?: UiConfig['url'];
|
|
96
|
+
canonicalPathname?: UiConfig['canonicalPathname'];
|
|
97
|
+
siteName?: string;
|
|
98
|
+
messages?: UiMessages;
|
|
99
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's locales and their canonical member. Fixed rather than generic:
|
|
3
|
+
* `ca` is load-bearing — it is the required key of every localized column
|
|
4
|
+
* (see LocalizedText in ../edit/types.js) and the fallback `localize()`
|
|
5
|
+
* resolves to — so widening the set is a deliberate package change, not a
|
|
6
|
+
* consumer option.
|
|
7
|
+
*/
|
|
8
|
+
export const LOCALES = ['ca', 'en', 'es'];
|
|
9
|
+
export const BASE_LOCALE = 'ca';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { renderBody } from './richtext.js';
|
|
2
|
+
export type { RichTextBlock } from './richtext.js';
|
|
3
|
+
export type { CollaboratorData, FieldConstraint, FormFailReason, JobOpeningData, MilestoneCategory, MilestoneData, ProjectCardData, ProjectKind, SortDirection, TeamMemberData, WeeklyCardData } from './types.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderBody } from './richtext.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface RichTextBlock {
|
|
2
|
+
type: 'h2' | 'p';
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Renders the lightweight markdown used for editorial body fields
|
|
7
|
+
* (paragraphs separated by blank lines, subheadings as `## ` lines)
|
|
8
|
+
* into typed blocks. Rendered with real elements — never `{@html}`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function renderBody(body: string): RichTextBlock[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the lightweight markdown used for editorial body fields
|
|
3
|
+
* (paragraphs separated by blank lines, subheadings as `## ` lines)
|
|
4
|
+
* into typed blocks. Rendered with real elements — never `{@html}`.
|
|
5
|
+
*/
|
|
6
|
+
export function renderBody(body) {
|
|
7
|
+
return body
|
|
8
|
+
.split(/\n{2,}/)
|
|
9
|
+
.map((chunk) => chunk.trim())
|
|
10
|
+
.filter((chunk) => chunk.length > 0)
|
|
11
|
+
.map((chunk) => chunk.startsWith('## ')
|
|
12
|
+
? { type: 'h2', text: chunk.slice(3).trim() }
|
|
13
|
+
: { type: 'p', text: chunk.replace(/\n/g, ' ') });
|
|
14
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural shapes of the content the components render, one locale already
|
|
3
|
+
* applied. The website's zod-inferred types satisfy these structurally; the
|
|
4
|
+
* package states them itself so consumers owe it no schema library.
|
|
5
|
+
*/
|
|
6
|
+
/** The bounds of one form field; Field turns them into length attributes. */
|
|
7
|
+
export interface FieldConstraint {
|
|
8
|
+
min?: number;
|
|
9
|
+
max: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Shared failure vocabulary of the foundation's form envelopes.
|
|
13
|
+
* FormErrorFeedback owns one sentence per member (minus 'error', which is
|
|
14
|
+
* the generic fallback itself).
|
|
15
|
+
*/
|
|
16
|
+
export type FormFailReason = 'rateLimited' | 'unauthenticated' | 'forbidden' | 'unavailable' | 'error';
|
|
17
|
+
export type SortDirection = 'asc' | 'desc';
|
|
18
|
+
/** A weekly as its listing card shows it. */
|
|
19
|
+
export interface WeeklyCardData {
|
|
20
|
+
id: number;
|
|
21
|
+
number: number;
|
|
22
|
+
slug: string;
|
|
23
|
+
/** ISO date (2026-08-30). */
|
|
24
|
+
publishedOn: string;
|
|
25
|
+
title: string;
|
|
26
|
+
excerpt: string;
|
|
27
|
+
imageUrl: string;
|
|
28
|
+
}
|
|
29
|
+
export type ProjectKind = 'collaboration' | 'passion';
|
|
30
|
+
/** A project as its card shows it. */
|
|
31
|
+
export interface ProjectCardData {
|
|
32
|
+
id: number;
|
|
33
|
+
slug: string;
|
|
34
|
+
kind: ProjectKind;
|
|
35
|
+
publishedOn: string;
|
|
36
|
+
title: string;
|
|
37
|
+
excerpt: string;
|
|
38
|
+
imageUrl: string;
|
|
39
|
+
externalUrl: string | null;
|
|
40
|
+
hasStory: boolean;
|
|
41
|
+
}
|
|
42
|
+
export type MilestoneCategory = 'education' | 'lab' | 'foundation' | 'collaboration' | 'press';
|
|
43
|
+
/** One timeline milestone. */
|
|
44
|
+
export interface MilestoneData {
|
|
45
|
+
id: number;
|
|
46
|
+
occurredOn: string;
|
|
47
|
+
category: MilestoneCategory;
|
|
48
|
+
title: string;
|
|
49
|
+
body: string | null;
|
|
50
|
+
imageUrls: string[];
|
|
51
|
+
linkUrl: string | null;
|
|
52
|
+
}
|
|
53
|
+
export interface TeamMemberData {
|
|
54
|
+
slug: string;
|
|
55
|
+
name: string;
|
|
56
|
+
role: string;
|
|
57
|
+
bio: string | null;
|
|
58
|
+
photoUrl: string;
|
|
59
|
+
isBoard: boolean;
|
|
60
|
+
}
|
|
61
|
+
export interface CollaboratorData {
|
|
62
|
+
personName: string;
|
|
63
|
+
affiliation: string;
|
|
64
|
+
url: string | null;
|
|
65
|
+
}
|
|
66
|
+
export interface JobOpeningData {
|
|
67
|
+
slug: string;
|
|
68
|
+
title: string;
|
|
69
|
+
description: string | null;
|
|
70
|
+
postedOn: string;
|
|
71
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { getEditAdapter } from './context.js';
|
|
4
|
+
import type { EditDescriptor } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The inline-edit primitive. It renders no element of its own: the child
|
|
8
|
+
* snippet receives the text and a bag of attributes, and spreads both onto
|
|
9
|
+
* whatever semantic element the call site owns —
|
|
10
|
+
*
|
|
11
|
+
* <Editable edit={descriptor} value={title}>
|
|
12
|
+
* {#snippet children(text, attrs)}<h3 {...attrs}>{text}</h3>{/snippet}
|
|
13
|
+
* </Editable>
|
|
14
|
+
*
|
|
15
|
+
* — the same contract Field uses for its control attrs, and for the same
|
|
16
|
+
* reason: the element (h1, p, span) is part of the document outline and
|
|
17
|
+
* belongs to the caller, not behind a prop.
|
|
18
|
+
*
|
|
19
|
+
* Inactive — no `edit` descriptor, no adapter in context (a read-only
|
|
20
|
+
* app), or the adapter's `isEditing` off — it renders the child with an
|
|
21
|
+
* empty attribute bag: no listeners, no wrapper, no cost.
|
|
22
|
+
*
|
|
23
|
+
* Active, it makes the element a plain-text `contenteditable` textbox.
|
|
24
|
+
* The DOM owns the draft while the reader types; this component reads it
|
|
25
|
+
* back on commit. Enter commits single-line fields (Cmd/Ctrl+Enter for
|
|
26
|
+
* 'multiline'), Escape reverts to the last saved value, blur commits a
|
|
27
|
+
* dirty draft. A failed save keeps the draft on screen in the error state
|
|
28
|
+
* so nothing typed is lost. State is announced through a visually hidden
|
|
29
|
+
* `role="status"` region, the pattern ShareRow set for its copy action.
|
|
30
|
+
*
|
|
31
|
+
* Styling for the affordance lives in base.css (`[data-vit-editing]`):
|
|
32
|
+
* the element is rendered by the *caller's* markup, which scoped styles
|
|
33
|
+
* here could never reach.
|
|
34
|
+
*/
|
|
35
|
+
interface EditableAttrs {
|
|
36
|
+
contenteditable?: 'plaintext-only' | 'true';
|
|
37
|
+
role?: 'textbox';
|
|
38
|
+
'aria-label'?: string;
|
|
39
|
+
'aria-multiline'?: 'true';
|
|
40
|
+
'data-vit-editing'?: 'idle' | 'dirty' | 'saving' | 'error';
|
|
41
|
+
onbeforeinput?: (event: InputEvent) => void;
|
|
42
|
+
oninput?: (event: Event) => void;
|
|
43
|
+
onblur?: (event: FocusEvent) => void;
|
|
44
|
+
onkeydown?: (event: KeyboardEvent) => void;
|
|
45
|
+
onpaste?: (event: ClipboardEvent) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Props {
|
|
49
|
+
/** Identity of the string. Omit it and this is a passthrough. */
|
|
50
|
+
edit?: EditDescriptor;
|
|
51
|
+
value: string;
|
|
52
|
+
children: Snippet<[string, EditableAttrs]>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let { edit, value, children }: Props = $props();
|
|
56
|
+
|
|
57
|
+
const adapter = getEditAdapter();
|
|
58
|
+
|
|
59
|
+
let status = $state<'idle' | 'dirty' | 'saving' | 'error'>('idle');
|
|
60
|
+
/** Last persisted value: what Escape restores and blur diffs against. */
|
|
61
|
+
// svelte-ignore state_referenced_locally
|
|
62
|
+
let savedValue = $state(value);
|
|
63
|
+
/** What the child snippet renders. Only rewritten while the DOM is not
|
|
64
|
+
* being typed in — Svelte and the reader must not fight over the node. */
|
|
65
|
+
// svelte-ignore state_referenced_locally
|
|
66
|
+
let renderText = $state(value);
|
|
67
|
+
let announcement = $state('');
|
|
68
|
+
|
|
69
|
+
// Follow the prop when the app reloads content underneath us — and only
|
|
70
|
+
// then. Diffing against savedValue instead would fire after every save,
|
|
71
|
+
// where savedValue has legitimately advanced past the prop, and repaint
|
|
72
|
+
// the committed draft with stale copy. Never over a draft being held.
|
|
73
|
+
// svelte-ignore state_referenced_locally
|
|
74
|
+
let lastPropValue = $state(value);
|
|
75
|
+
$effect(() => {
|
|
76
|
+
if (value !== lastPropValue) {
|
|
77
|
+
lastPropValue = value;
|
|
78
|
+
if (status === 'idle') {
|
|
79
|
+
savedValue = value;
|
|
80
|
+
renderText = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const active = $derived(edit !== undefined && (adapter?.isEditing ?? false));
|
|
86
|
+
const multiline = $derived(edit?.format === 'multiline' || edit?.format === 'richtext');
|
|
87
|
+
|
|
88
|
+
/** contenteditable text, innerText keeps line breaks; NBSPs become spaces. */
|
|
89
|
+
function textOf(element: HTMLElement): string {
|
|
90
|
+
return (element.innerText ?? element.textContent ?? '').replace(/\u00a0/g, ' ');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function handleBeforeInput(event: InputEvent): void {
|
|
94
|
+
// 'plaintext-only' is not universal (Firefox); strip rich insertions.
|
|
95
|
+
if (event.inputType === 'insertFromPaste' || event.inputType === 'insertFromDrop') return;
|
|
96
|
+
if (!multiline && event.inputType === 'insertParagraph') event.preventDefault();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handlePaste(event: ClipboardEvent): void {
|
|
100
|
+
// Plain text only, whatever the clipboard holds.
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
const text = event.clipboardData?.getData('text/plain') ?? '';
|
|
103
|
+
const selection = window.getSelection();
|
|
104
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
105
|
+
const range = selection.getRangeAt(0);
|
|
106
|
+
range.deleteContents();
|
|
107
|
+
range.insertNode(document.createTextNode(multiline ? text : text.replace(/\s*\n\s*/g, ' ')));
|
|
108
|
+
selection.collapseToEnd();
|
|
109
|
+
status = 'dirty';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function handleInput(): void {
|
|
113
|
+
if (status !== 'saving') status = 'dirty';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function commit(element: HTMLElement): Promise<void> {
|
|
117
|
+
if (!edit || !adapter) return;
|
|
118
|
+
const draft = textOf(element).trim();
|
|
119
|
+
if (draft === savedValue) {
|
|
120
|
+
status = 'idle';
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
status = 'saving';
|
|
124
|
+
announcement = 'Desant…';
|
|
125
|
+
try {
|
|
126
|
+
await adapter.save(edit, draft);
|
|
127
|
+
savedValue = draft;
|
|
128
|
+
status = 'idle';
|
|
129
|
+
announcement = 'Desat';
|
|
130
|
+
} catch {
|
|
131
|
+
// Draft stays in the DOM; the reader decides whether to retry.
|
|
132
|
+
status = 'error';
|
|
133
|
+
announcement = 'Error en desar';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function handleBlur(event: FocusEvent): void {
|
|
138
|
+
if (status === 'dirty' || status === 'error') void commit(event.currentTarget as HTMLElement);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
142
|
+
const element = event.currentTarget as HTMLElement;
|
|
143
|
+
if (event.key === 'Escape') {
|
|
144
|
+
// Svelte's cached text still equals renderText, so a state write
|
|
145
|
+
// alone cannot repaint a node the reader has mutated: restore the
|
|
146
|
+
// DOM directly, then settle.
|
|
147
|
+
element.textContent = savedValue;
|
|
148
|
+
status = 'idle';
|
|
149
|
+
announcement = '';
|
|
150
|
+
element.blur();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const commitKey = multiline
|
|
154
|
+
? event.key === 'Enter' && (event.metaKey || event.ctrlKey)
|
|
155
|
+
: event.key === 'Enter';
|
|
156
|
+
if (commitKey) {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
void commit(element);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const attrs = $derived<EditableAttrs>(
|
|
163
|
+
active
|
|
164
|
+
? {
|
|
165
|
+
contenteditable: 'plaintext-only',
|
|
166
|
+
role: 'textbox',
|
|
167
|
+
'aria-label': edit?.label,
|
|
168
|
+
'aria-multiline': multiline ? 'true' : undefined,
|
|
169
|
+
'data-vit-editing': status,
|
|
170
|
+
onbeforeinput: handleBeforeInput,
|
|
171
|
+
oninput: handleInput,
|
|
172
|
+
onblur: handleBlur,
|
|
173
|
+
onkeydown: handleKeydown,
|
|
174
|
+
onpaste: handlePaste
|
|
175
|
+
}
|
|
176
|
+
: {}
|
|
177
|
+
);
|
|
178
|
+
</script>
|
|
179
|
+
|
|
180
|
+
{@render children(renderText, attrs)}
|
|
181
|
+
{#if active}
|
|
182
|
+
<span class="status" role="status">{announcement}</span>
|
|
183
|
+
{/if}
|
|
184
|
+
|
|
185
|
+
<style>
|
|
186
|
+
.status {
|
|
187
|
+
position: absolute;
|
|
188
|
+
width: 1px;
|
|
189
|
+
height: 1px;
|
|
190
|
+
padding: 0;
|
|
191
|
+
margin: -1px;
|
|
192
|
+
overflow: hidden;
|
|
193
|
+
clip-path: inset(50%);
|
|
194
|
+
white-space: nowrap;
|
|
195
|
+
border: 0;
|
|
196
|
+
}
|
|
197
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { EditDescriptor } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The inline-edit primitive. It renders no element of its own: the child
|
|
5
|
+
* snippet receives the text and a bag of attributes, and spreads both onto
|
|
6
|
+
* whatever semantic element the call site owns —
|
|
7
|
+
*
|
|
8
|
+
* <Editable edit={descriptor} value={title}>
|
|
9
|
+
* {#snippet children(text, attrs)}<h3 {...attrs}>{text}</h3>{/snippet}
|
|
10
|
+
* </Editable>
|
|
11
|
+
*
|
|
12
|
+
* — the same contract Field uses for its control attrs, and for the same
|
|
13
|
+
* reason: the element (h1, p, span) is part of the document outline and
|
|
14
|
+
* belongs to the caller, not behind a prop.
|
|
15
|
+
*
|
|
16
|
+
* Inactive — no `edit` descriptor, no adapter in context (a read-only
|
|
17
|
+
* app), or the adapter's `isEditing` off — it renders the child with an
|
|
18
|
+
* empty attribute bag: no listeners, no wrapper, no cost.
|
|
19
|
+
*
|
|
20
|
+
* Active, it makes the element a plain-text `contenteditable` textbox.
|
|
21
|
+
* The DOM owns the draft while the reader types; this component reads it
|
|
22
|
+
* back on commit. Enter commits single-line fields (Cmd/Ctrl+Enter for
|
|
23
|
+
* 'multiline'), Escape reverts to the last saved value, blur commits a
|
|
24
|
+
* dirty draft. A failed save keeps the draft on screen in the error state
|
|
25
|
+
* so nothing typed is lost. State is announced through a visually hidden
|
|
26
|
+
* `role="status"` region, the pattern ShareRow set for its copy action.
|
|
27
|
+
*
|
|
28
|
+
* Styling for the affordance lives in base.css (`[data-vit-editing]`):
|
|
29
|
+
* the element is rendered by the *caller's* markup, which scoped styles
|
|
30
|
+
* here could never reach.
|
|
31
|
+
*/
|
|
32
|
+
interface EditableAttrs {
|
|
33
|
+
contenteditable?: 'plaintext-only' | 'true';
|
|
34
|
+
role?: 'textbox';
|
|
35
|
+
'aria-label'?: string;
|
|
36
|
+
'aria-multiline'?: 'true';
|
|
37
|
+
'data-vit-editing'?: 'idle' | 'dirty' | 'saving' | 'error';
|
|
38
|
+
onbeforeinput?: (event: InputEvent) => void;
|
|
39
|
+
oninput?: (event: Event) => void;
|
|
40
|
+
onblur?: (event: FocusEvent) => void;
|
|
41
|
+
onkeydown?: (event: KeyboardEvent) => void;
|
|
42
|
+
onpaste?: (event: ClipboardEvent) => void;
|
|
43
|
+
}
|
|
44
|
+
interface Props {
|
|
45
|
+
/** Identity of the string. Omit it and this is a passthrough. */
|
|
46
|
+
edit?: EditDescriptor;
|
|
47
|
+
value: string;
|
|
48
|
+
children: Snippet<[string, EditableAttrs]>;
|
|
49
|
+
}
|
|
50
|
+
declare const Editable: import("svelte").Component<Props, {}, "">;
|
|
51
|
+
type Editable = ReturnType<typeof Editable>;
|
|
52
|
+
export default Editable;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EditAdapter } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Installs the app's persistence adapter for the subtree. An app that never
|
|
4
|
+
* calls this is read-only by construction: without an adapter every edit
|
|
5
|
+
* affordance renders as plain content, byte-identical to a build of the
|
|
6
|
+
* components with no edit mode at all.
|
|
7
|
+
*/
|
|
8
|
+
export declare function setEditAdapter(adapter: EditAdapter): EditAdapter;
|
|
9
|
+
/** The nearest adapter, or undefined in a read-only app. */
|
|
10
|
+
export declare function getEditAdapter(): EditAdapter | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
const KEY = Symbol.for('@vit-foundation/ui:edit-adapter');
|
|
3
|
+
/**
|
|
4
|
+
* Installs the app's persistence adapter for the subtree. An app that never
|
|
5
|
+
* calls this is read-only by construction: without an adapter every edit
|
|
6
|
+
* affordance renders as plain content, byte-identical to a build of the
|
|
7
|
+
* components with no edit mode at all.
|
|
8
|
+
*/
|
|
9
|
+
export function setEditAdapter(adapter) {
|
|
10
|
+
return setContext(KEY, adapter);
|
|
11
|
+
}
|
|
12
|
+
/** The nearest adapter, or undefined in a read-only app. */
|
|
13
|
+
export function getEditAdapter() {
|
|
14
|
+
return getContext(KEY);
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Locale } from '../config/types.js';
|
|
2
|
+
import type { EditableEntity, EditDescriptor } from './types.js';
|
|
3
|
+
/** Descriptor for one page-copy block (`page_content` row). */
|
|
4
|
+
export declare function pageCopyEdit(page: string, sectionKey: string, locale: Locale, options?: {
|
|
5
|
+
format?: EditDescriptor['format'];
|
|
6
|
+
label?: string;
|
|
7
|
+
}): EditDescriptor;
|
|
8
|
+
/**
|
|
9
|
+
* Descriptor factory for one entity's fields, so a call site editing several
|
|
10
|
+
* fields of the same row names the row once:
|
|
11
|
+
*
|
|
12
|
+
* const edit = entityEdit('weeklies', weekly.id, locale);
|
|
13
|
+
* <WeeklieCard {weekly} edit={{ title: edit('title'), excerpt: edit('excerpt') }} />
|
|
14
|
+
*/
|
|
15
|
+
export declare function entityEdit(entity: EditableEntity, id: string | number, locale: Locale): (field: string, options?: {
|
|
16
|
+
format?: EditDescriptor['format'];
|
|
17
|
+
label?: string;
|
|
18
|
+
}) => EditDescriptor;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Descriptor for one page-copy block (`page_content` row). */
|
|
2
|
+
export function pageCopyEdit(page, sectionKey, locale, options) {
|
|
3
|
+
return { ref: { kind: 'page-copy', page, sectionKey }, locale, ...options };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Descriptor factory for one entity's fields, so a call site editing several
|
|
7
|
+
* fields of the same row names the row once:
|
|
8
|
+
*
|
|
9
|
+
* const edit = entityEdit('weeklies', weekly.id, locale);
|
|
10
|
+
* <WeeklieCard {weekly} edit={{ title: edit('title'), excerpt: edit('excerpt') }} />
|
|
11
|
+
*/
|
|
12
|
+
export function entityEdit(entity, id, locale) {
|
|
13
|
+
return (field, options) => ({ ref: { kind: 'entity', entity, id, field }, locale, ...options });
|
|
14
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { getEditAdapter, setEditAdapter } from './context.js';
|
|
2
|
+
export { default as Editable } from './Editable.svelte';
|
|
3
|
+
export { entityEdit, pageCopyEdit } from './helpers.js';
|
|
4
|
+
export { localize } from './types.js';
|
|
5
|
+
export type { ContentRef, EditableEntity, EditAdapter, EditDescriptor, LocalizedText } from './types.js';
|