@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,89 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { FieldConstraint } from '../../content/types.js';
|
|
4
|
+
|
|
5
|
+
/** Attributes the control must spread (`{...attrs}`) to stay wired. */
|
|
6
|
+
interface ControlAttrs {
|
|
7
|
+
id: string;
|
|
8
|
+
'aria-describedby': string | undefined;
|
|
9
|
+
maxlength: number | undefined;
|
|
10
|
+
minlength: number | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The message comes from the validation that failed, not from the caller.
|
|
15
|
+
* Every call site used to hand Field the sentence as a prop while issues()
|
|
16
|
+
* was read as a boolean, so the constraint and the sentence describing it
|
|
17
|
+
* lived in different files and could never disagree loudly. The host app's schemas own
|
|
18
|
+
* both now (the foundation site: `boundedText` in its schema layer).
|
|
19
|
+
*
|
|
20
|
+
* The numbers travel the same way for the same reason. They used to be
|
|
21
|
+
* imported by each form and spelled out as a `maxlength` beside the input,
|
|
22
|
+
* which is how the password and email bounds came to be enforced on the
|
|
23
|
+
* server and stated nowhere in the markup: forgetting an attribute breaks
|
|
24
|
+
* nothing visible and no test. A field that declares its constraint here
|
|
25
|
+
* cannot render a control without it.
|
|
26
|
+
*
|
|
27
|
+
* `constraint` is required and explicitly nullable rather than optional,
|
|
28
|
+
* because optional is what let the hole reopen. The round that moved the
|
|
29
|
+
* numbers here counted eleven bounded fields and shipped the twelfth — the
|
|
30
|
+
* magic-link email — with no bound stated in the browser, and nothing could
|
|
31
|
+
* fail: an omitted prop and a control that genuinely has no length were the
|
|
32
|
+
* same value. They are different values now. A control with no length says
|
|
33
|
+
* `null` and says so on purpose; a bounded field that says nothing does not
|
|
34
|
+
* compile.
|
|
35
|
+
*/
|
|
36
|
+
interface Props {
|
|
37
|
+
/** Control id; the error paragraph gets `${id}-error`. */
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
/** The remote form field; its first issue is what gets rendered. */
|
|
41
|
+
field: { issues(): Array<{ message: string }> | undefined };
|
|
42
|
+
/**
|
|
43
|
+
* The schema's bounds for this field, become the control's length
|
|
44
|
+
* attributes. `null` for controls with no length: checkboxes, selects.
|
|
45
|
+
*/
|
|
46
|
+
constraint: FieldConstraint | null;
|
|
47
|
+
/** Render the label visually hidden (surrounding context already labels it). */
|
|
48
|
+
hideLabel?: boolean;
|
|
49
|
+
/** The input/textarea/select, spreading the given attrs. */
|
|
50
|
+
children: Snippet<[ControlAttrs]>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let { id, label, field, constraint, hideLabel = false, children }: Props = $props();
|
|
54
|
+
|
|
55
|
+
const errorMessage = $derived(field.issues()?.[0]?.message);
|
|
56
|
+
const showError = $derived(errorMessage !== undefined);
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<div class="field">
|
|
60
|
+
<label class:visually-hidden={hideLabel} for={id}>{label}</label>
|
|
61
|
+
{@render children({
|
|
62
|
+
id,
|
|
63
|
+
'aria-describedby': showError ? `${id}-error` : undefined,
|
|
64
|
+
maxlength: constraint?.max,
|
|
65
|
+
minlength: constraint?.min
|
|
66
|
+
})}
|
|
67
|
+
{#if showError}
|
|
68
|
+
<p class="field-error" id="{id}-error">{errorMessage}</p>
|
|
69
|
+
{/if}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<style>
|
|
73
|
+
.field {
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: var(--space-1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
label {
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
font-size: var(--text-sm);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.field-error {
|
|
85
|
+
color: var(--series-8);
|
|
86
|
+
font-size: var(--text-sm);
|
|
87
|
+
margin: 0;
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { FieldConstraint } from '../../content/types.js';
|
|
3
|
+
/** Attributes the control must spread (`{...attrs}`) to stay wired. */
|
|
4
|
+
interface ControlAttrs {
|
|
5
|
+
id: string;
|
|
6
|
+
'aria-describedby': string | undefined;
|
|
7
|
+
maxlength: number | undefined;
|
|
8
|
+
minlength: number | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The message comes from the validation that failed, not from the caller.
|
|
12
|
+
* Every call site used to hand Field the sentence as a prop while issues()
|
|
13
|
+
* was read as a boolean, so the constraint and the sentence describing it
|
|
14
|
+
* lived in different files and could never disagree loudly. The host app's schemas own
|
|
15
|
+
* both now (the foundation site: `boundedText` in its schema layer).
|
|
16
|
+
*
|
|
17
|
+
* The numbers travel the same way for the same reason. They used to be
|
|
18
|
+
* imported by each form and spelled out as a `maxlength` beside the input,
|
|
19
|
+
* which is how the password and email bounds came to be enforced on the
|
|
20
|
+
* server and stated nowhere in the markup: forgetting an attribute breaks
|
|
21
|
+
* nothing visible and no test. A field that declares its constraint here
|
|
22
|
+
* cannot render a control without it.
|
|
23
|
+
*
|
|
24
|
+
* `constraint` is required and explicitly nullable rather than optional,
|
|
25
|
+
* because optional is what let the hole reopen. The round that moved the
|
|
26
|
+
* numbers here counted eleven bounded fields and shipped the twelfth — the
|
|
27
|
+
* magic-link email — with no bound stated in the browser, and nothing could
|
|
28
|
+
* fail: an omitted prop and a control that genuinely has no length were the
|
|
29
|
+
* same value. They are different values now. A control with no length says
|
|
30
|
+
* `null` and says so on purpose; a bounded field that says nothing does not
|
|
31
|
+
* compile.
|
|
32
|
+
*/
|
|
33
|
+
interface Props {
|
|
34
|
+
/** Control id; the error paragraph gets `${id}-error`. */
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
/** The remote form field; its first issue is what gets rendered. */
|
|
38
|
+
field: {
|
|
39
|
+
issues(): Array<{
|
|
40
|
+
message: string;
|
|
41
|
+
}> | undefined;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The schema's bounds for this field, become the control's length
|
|
45
|
+
* attributes. `null` for controls with no length: checkboxes, selects.
|
|
46
|
+
*/
|
|
47
|
+
constraint: FieldConstraint | null;
|
|
48
|
+
/** Render the label visually hidden (surrounding context already labels it). */
|
|
49
|
+
hideLabel?: boolean;
|
|
50
|
+
/** The input/textarea/select, spreading the given attrs. */
|
|
51
|
+
children: Snippet<[ControlAttrs]>;
|
|
52
|
+
}
|
|
53
|
+
declare const Field: import("svelte").Component<Props, {}, "">;
|
|
54
|
+
type Field = ReturnType<typeof Field>;
|
|
55
|
+
export default Field;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import GhostButton from './GhostButton.svelte';
|
|
3
|
+
|
|
4
|
+
interface Chip {
|
|
5
|
+
value: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
chips: Chip[];
|
|
11
|
+
/** Currently selected chip value; null = none selected. */
|
|
12
|
+
selected: string | null;
|
|
13
|
+
label: string;
|
|
14
|
+
/** Called with the toggled value, or null when the active chip is unselected. */
|
|
15
|
+
onchange: (value: string | null) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { chips, selected, label, onchange }: Props = $props();
|
|
19
|
+
|
|
20
|
+
function toggle(value: string): void {
|
|
21
|
+
onchange(selected === value ? null : value);
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div class="chips" role="group" aria-label={label}>
|
|
26
|
+
{#each chips as chip (chip.value)}
|
|
27
|
+
<GhostButton
|
|
28
|
+
variant="chip"
|
|
29
|
+
aria-pressed={selected === chip.value}
|
|
30
|
+
onclick={() => toggle(chip.value)}
|
|
31
|
+
>
|
|
32
|
+
{chip.label}
|
|
33
|
+
</GhostButton>
|
|
34
|
+
{/each}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<style>
|
|
38
|
+
.chips {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-wrap: wrap;
|
|
41
|
+
gap: var(--space-2);
|
|
42
|
+
}
|
|
43
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
interface Chip {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
}
|
|
5
|
+
interface Props {
|
|
6
|
+
chips: Chip[];
|
|
7
|
+
/** Currently selected chip value; null = none selected. */
|
|
8
|
+
selected: string | null;
|
|
9
|
+
label: string;
|
|
10
|
+
/** Called with the toggled value, or null when the active chip is unselected. */
|
|
11
|
+
onchange: (value: string | null) => void;
|
|
12
|
+
}
|
|
13
|
+
declare const FilterChips: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type FilterChips = ReturnType<typeof FilterChips>;
|
|
15
|
+
export default FilterChips;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script lang="ts" generics="ExtraReason extends string = never">
|
|
2
|
+
import { getUiConfig } from '../../config/context.js';
|
|
3
|
+
import type { FormFailReason } from '../../content/types.js';
|
|
4
|
+
import FormFeedback from './FormFeedback.svelte';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** The remote form's result; renders nothing unless it's a failure. */
|
|
8
|
+
result: { ok: boolean; reason?: FormFailReason | ExtraReason } | undefined | null;
|
|
9
|
+
/** Per-reason overrides; unlisted reasons use the shared form_error_* copy. */
|
|
10
|
+
messages?: Partial<Record<FormFailReason | ExtraReason, string>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { result, messages = {} }: Props = $props();
|
|
14
|
+
|
|
15
|
+
const config = getUiConfig();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Exhaustive over the shared reasons, minus 'error' which is the fallback
|
|
19
|
+
* itself. Adding a member to FormFailReason fails the build here instead of
|
|
20
|
+
* silently rendering the generic message.
|
|
21
|
+
*/
|
|
22
|
+
const GENERIC = $derived<Record<Exclude<FormFailReason, 'error'>, () => string>>({
|
|
23
|
+
rateLimited: config.messages.form_error_rateLimited,
|
|
24
|
+
unauthenticated: config.messages.form_error_unauthenticated,
|
|
25
|
+
forbidden: config.messages.form_error_forbidden,
|
|
26
|
+
unavailable: config.messages.form_error_unavailable
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Own-property lookups on both records, which is what makes the rule this
|
|
31
|
+
* module documents — an unlisted reason gets the shared copy — actually
|
|
32
|
+
* hold. `messages[reason]` returns a truthy *inherited* member for
|
|
33
|
+
* 'constructor', 'toString' and '__proto__', and `reason in GENERIC` is true
|
|
34
|
+
* for all three, so each rendered a prototype member where a sentence
|
|
35
|
+
* belongs: "function Object() { [native code] }" in place of an error.
|
|
36
|
+
*
|
|
37
|
+
* Not reachable today — every reason the server produces is a constant from
|
|
38
|
+
* a closed set — so this is not a guard against an attacker. It is the
|
|
39
|
+
* difference between the fallback being documented and it being true.
|
|
40
|
+
*/
|
|
41
|
+
function messageFor(reason: FormFailReason | ExtraReason | undefined): string {
|
|
42
|
+
if (reason) {
|
|
43
|
+
const override = Object.hasOwn(messages, reason) ? messages[reason] : undefined;
|
|
44
|
+
if (override) return override;
|
|
45
|
+
if (Object.hasOwn(GENERIC, reason)) {
|
|
46
|
+
return GENERIC[reason as Exclude<FormFailReason, 'error'>]();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return config.messages.form_error_generic();
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
{#if result && !result.ok}
|
|
54
|
+
<FormFeedback kind="error">{messageFor(result.reason)}</FormFeedback>
|
|
55
|
+
{/if}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FormFailReason } from '../../content/types.js';
|
|
2
|
+
declare function $$render<ExtraReason extends string = never>(): {
|
|
3
|
+
props: {
|
|
4
|
+
/** The remote form's result; renders nothing unless it's a failure. */
|
|
5
|
+
result: {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
reason?: FormFailReason | ExtraReason;
|
|
8
|
+
} | undefined | null;
|
|
9
|
+
/** Per-reason overrides; unlisted reasons use the shared form_error_* copy. */
|
|
10
|
+
messages?: Partial<Record<FormFailReason | ExtraReason, string>>;
|
|
11
|
+
};
|
|
12
|
+
exports: {};
|
|
13
|
+
bindings: "";
|
|
14
|
+
slots: {};
|
|
15
|
+
events: {};
|
|
16
|
+
};
|
|
17
|
+
declare class __sveltets_Render<ExtraReason extends string = never> {
|
|
18
|
+
props(): ReturnType<typeof $$render<ExtraReason>>['props'];
|
|
19
|
+
events(): ReturnType<typeof $$render<ExtraReason>>['events'];
|
|
20
|
+
slots(): ReturnType<typeof $$render<ExtraReason>>['slots'];
|
|
21
|
+
bindings(): "";
|
|
22
|
+
exports(): {};
|
|
23
|
+
}
|
|
24
|
+
interface $$IsomorphicComponent {
|
|
25
|
+
new <ExtraReason extends string = never>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<ExtraReason>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<ExtraReason>['props']>, ReturnType<__sveltets_Render<ExtraReason>['events']>, ReturnType<__sveltets_Render<ExtraReason>['slots']>> & {
|
|
26
|
+
$$bindings?: ReturnType<__sveltets_Render<ExtraReason>['bindings']>;
|
|
27
|
+
} & ReturnType<__sveltets_Render<ExtraReason>['exports']>;
|
|
28
|
+
<ExtraReason extends string = never>(internal: unknown, props: ReturnType<__sveltets_Render<ExtraReason>['props']> & {}): ReturnType<__sveltets_Render<ExtraReason>['exports']>;
|
|
29
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
30
|
+
}
|
|
31
|
+
declare const FormErrorFeedback: $$IsomorphicComponent;
|
|
32
|
+
type FormErrorFeedback<ExtraReason extends string = never> = InstanceType<typeof FormErrorFeedback<ExtraReason>>;
|
|
33
|
+
export default FormErrorFeedback;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The outcome of a submission, and the one owner of where focus goes when
|
|
6
|
+
* that outcome replaces the form.
|
|
7
|
+
*
|
|
8
|
+
* Every form that succeeds swaps its whole `<form>` out for this, which
|
|
9
|
+
* destroys the submit button the reader had just activated: focus falls back
|
|
10
|
+
* to `<body>`, so the next Tab restarts at the top of the document with no
|
|
11
|
+
* sign the submission worked. And because this element is *inserted* with
|
|
12
|
+
* its text already in place rather than being present and then updated, a
|
|
13
|
+
* polite `role="status"` is announced unreliably — the two together can
|
|
14
|
+
* leave a screen-reader user with no confirmation at all.
|
|
15
|
+
*
|
|
16
|
+
* Moving focus here settles both: the message is read on focus, and the
|
|
17
|
+
* reader continues from the confirmation instead of from nowhere.
|
|
18
|
+
*
|
|
19
|
+
* Only on success. An error leaves the form standing — and ContactForm
|
|
20
|
+
* renders one straight from live preflight issues — so focusing an error
|
|
21
|
+
* would take the cursor out of the field the reader is still typing in.
|
|
22
|
+
* `role="alert"` is assertive and is announced on insertion anyway; it is
|
|
23
|
+
* the polite `role="status"` that needs the focus move to be heard.
|
|
24
|
+
*/
|
|
25
|
+
interface Props {
|
|
26
|
+
/** success renders role="status"; error renders role="alert". */
|
|
27
|
+
kind: 'success' | 'error';
|
|
28
|
+
children: Snippet;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let { kind, children }: Props = $props();
|
|
32
|
+
|
|
33
|
+
const isSuccess = $derived(kind === 'success');
|
|
34
|
+
|
|
35
|
+
let node = $state<HTMLParagraphElement | null>(null);
|
|
36
|
+
|
|
37
|
+
$effect(() => {
|
|
38
|
+
if (isSuccess) node?.focus();
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<p bind:this={node} class="feedback {kind}" role={isSuccess ? 'status' : 'alert'} tabindex="-1">
|
|
43
|
+
{@render children()}
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
<style>
|
|
47
|
+
.feedback {
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
margin: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Focused programmatically, never by tabbing, so the ring would only ever
|
|
53
|
+
confuse: it is a message, not a control. */
|
|
54
|
+
.feedback:focus {
|
|
55
|
+
outline: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.success {
|
|
59
|
+
color: var(--color-brand);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.error {
|
|
63
|
+
color: var(--series-8);
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* The outcome of a submission, and the one owner of where focus goes when
|
|
4
|
+
* that outcome replaces the form.
|
|
5
|
+
*
|
|
6
|
+
* Every form that succeeds swaps its whole `<form>` out for this, which
|
|
7
|
+
* destroys the submit button the reader had just activated: focus falls back
|
|
8
|
+
* to `<body>`, so the next Tab restarts at the top of the document with no
|
|
9
|
+
* sign the submission worked. And because this element is *inserted* with
|
|
10
|
+
* its text already in place rather than being present and then updated, a
|
|
11
|
+
* polite `role="status"` is announced unreliably — the two together can
|
|
12
|
+
* leave a screen-reader user with no confirmation at all.
|
|
13
|
+
*
|
|
14
|
+
* Moving focus here settles both: the message is read on focus, and the
|
|
15
|
+
* reader continues from the confirmation instead of from nowhere.
|
|
16
|
+
*
|
|
17
|
+
* Only on success. An error leaves the form standing — and ContactForm
|
|
18
|
+
* renders one straight from live preflight issues — so focusing an error
|
|
19
|
+
* would take the cursor out of the field the reader is still typing in.
|
|
20
|
+
* `role="alert"` is assertive and is announced on insertion anyway; it is
|
|
21
|
+
* the polite `role="status"` that needs the focus move to be heard.
|
|
22
|
+
*/
|
|
23
|
+
interface Props {
|
|
24
|
+
/** success renders role="status"; error renders role="alert". */
|
|
25
|
+
kind: 'success' | 'error';
|
|
26
|
+
children: Snippet;
|
|
27
|
+
}
|
|
28
|
+
declare const FormFeedback: import("svelte").Component<Props, {}, "">;
|
|
29
|
+
type FormFeedback = ReturnType<typeof FormFeedback>;
|
|
30
|
+
export default FormFeedback;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import FormErrorFeedback from './FormErrorFeedback.svelte';
|
|
3
|
+
import FormFeedback from './FormFeedback.svelte';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A form's outcome in one slot: the success message announced with
|
|
7
|
+
* role="status", any failure rendered by FormErrorFeedback with role="alert".
|
|
8
|
+
* Use it wherever a form stays on screen after submitting; forms that
|
|
9
|
+
* replace themselves on success (contact, signup, magic link) branch in
|
|
10
|
+
* their own markup, because what changes there is the whole form, not the
|
|
11
|
+
* message.
|
|
12
|
+
*
|
|
13
|
+
* No per-reason copy passes through here. It used to take a `messages`
|
|
14
|
+
* record and hand it on unread; all three render sites omitted it, and every
|
|
15
|
+
* `messages` in the app goes to FormErrorFeedback directly — which is where
|
|
16
|
+
* a form needing per-reason copy still reaches, and what the two forms that
|
|
17
|
+
* need it already do.
|
|
18
|
+
*/
|
|
19
|
+
interface Props {
|
|
20
|
+
/** The remote form's result; nothing renders until there is one. */
|
|
21
|
+
result: { ok: boolean; reason?: string } | undefined | null;
|
|
22
|
+
/** Announced on success. */
|
|
23
|
+
successMessage: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let { result, successMessage }: Props = $props();
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
{#if result?.ok}
|
|
30
|
+
<FormFeedback kind="success">{successMessage}</FormFeedback>
|
|
31
|
+
{:else}
|
|
32
|
+
<FormErrorFeedback {result} />
|
|
33
|
+
{/if}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A form's outcome in one slot: the success message announced with
|
|
3
|
+
* role="status", any failure rendered by FormErrorFeedback with role="alert".
|
|
4
|
+
* Use it wherever a form stays on screen after submitting; forms that
|
|
5
|
+
* replace themselves on success (contact, signup, magic link) branch in
|
|
6
|
+
* their own markup, because what changes there is the whole form, not the
|
|
7
|
+
* message.
|
|
8
|
+
*
|
|
9
|
+
* No per-reason copy passes through here. It used to take a `messages`
|
|
10
|
+
* record and hand it on unread; all three render sites omitted it, and every
|
|
11
|
+
* `messages` in the app goes to FormErrorFeedback directly — which is where
|
|
12
|
+
* a form needing per-reason copy still reaches, and what the two forms that
|
|
13
|
+
* need it already do.
|
|
14
|
+
*/
|
|
15
|
+
interface Props {
|
|
16
|
+
/** The remote form's result; nothing renders until there is one. */
|
|
17
|
+
result: {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
reason?: string;
|
|
20
|
+
} | undefined | null;
|
|
21
|
+
/** Announced on success. */
|
|
22
|
+
successMessage: string;
|
|
23
|
+
}
|
|
24
|
+
declare const FormResultSlot: import("svelte").Component<Props, {}, "">;
|
|
25
|
+
type FormResultSlot = ReturnType<typeof FormResultSlot>;
|
|
26
|
+
export default FormResultSlot;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
4
|
+
|
|
5
|
+
interface Props extends HTMLButtonAttributes {
|
|
6
|
+
/** 'ghost' = transparent with the app radius; 'chip' = grey pill with an aria-pressed state. */
|
|
7
|
+
variant?: 'ghost' | 'chip';
|
|
8
|
+
children: Snippet;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { variant = 'ghost', children, type = 'button', class: className, ...rest }: Props = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<button {type} class="{variant} {className ?? ''}" {...rest}>{@render children()}</button>
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
button {
|
|
18
|
+
border: 1px solid var(--color-hairline);
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
transition:
|
|
21
|
+
background var(--transition-fast),
|
|
22
|
+
color var(--transition-fast);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
button:hover {
|
|
26
|
+
border-color: var(--color-brand);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.ghost {
|
|
30
|
+
background: none;
|
|
31
|
+
border-radius: var(--radius);
|
|
32
|
+
padding: var(--space-1) var(--space-2);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.chip {
|
|
36
|
+
background: var(--color-band-grey);
|
|
37
|
+
border-radius: 999px;
|
|
38
|
+
padding: var(--space-1) var(--space-3);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.chip[aria-pressed='true'] {
|
|
42
|
+
background: var(--color-brand);
|
|
43
|
+
border-color: var(--color-brand);
|
|
44
|
+
color: var(--color-surface);
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
3
|
+
interface Props extends HTMLButtonAttributes {
|
|
4
|
+
/** 'ghost' = transparent with the app radius; 'chip' = grey pill with an aria-pressed state. */
|
|
5
|
+
variant?: 'ghost' | 'chip';
|
|
6
|
+
children: Snippet;
|
|
7
|
+
}
|
|
8
|
+
declare const GhostButton: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type GhostButton = ReturnType<typeof GhostButton>;
|
|
10
|
+
export default GhostButton;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
|
4
|
+
import type { Locale } from '../../config/types.js';
|
|
5
|
+
import { getUiConfig } from '../../config/context.js';
|
|
6
|
+
|
|
7
|
+
interface Props extends HTMLAnchorAttributes {
|
|
8
|
+
/**
|
|
9
|
+
* Canonical (unlocalized) app path, e.g. "/weeklies" — internal only.
|
|
10
|
+
*
|
|
11
|
+
* An absolute or protocol-relative URL does not pass through: the
|
|
12
|
+
* app's href resolver may splice a locale segment into it, so a foreign
|
|
13
|
+
* path can come back mangled, and no `rel` is emitted. A content column that may hold
|
|
14
|
+
* either (a milestone's `link_url`) branches at the call site and
|
|
15
|
+
* renders a bare anchor for the external case.
|
|
16
|
+
*/
|
|
17
|
+
href: string;
|
|
18
|
+
/** Target locale; defaults to the current request/page locale. */
|
|
19
|
+
locale?: Locale;
|
|
20
|
+
children: Snippet;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let { href, locale, children, ...rest }: Props = $props();
|
|
24
|
+
|
|
25
|
+
const config = getUiConfig();
|
|
26
|
+
|
|
27
|
+
const resolvedHref = $derived(config.href(href, { locale }));
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<a href={resolvedHref} {...rest}>{@render children()}</a>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
|
3
|
+
import type { Locale } from '../../config/types.js';
|
|
4
|
+
interface Props extends HTMLAnchorAttributes {
|
|
5
|
+
/**
|
|
6
|
+
* Canonical (unlocalized) app path, e.g. "/weeklies" — internal only.
|
|
7
|
+
*
|
|
8
|
+
* An absolute or protocol-relative URL does not pass through: the
|
|
9
|
+
* app's href resolver may splice a locale segment into it, so a foreign
|
|
10
|
+
* path can come back mangled, and no `rel` is emitted. A content column that may hold
|
|
11
|
+
* either (a milestone's `link_url`) branches at the call site and
|
|
12
|
+
* renders a bare anchor for the external case.
|
|
13
|
+
*/
|
|
14
|
+
href: string;
|
|
15
|
+
/** Target locale; defaults to the current request/page locale. */
|
|
16
|
+
locale?: Locale;
|
|
17
|
+
children: Snippet;
|
|
18
|
+
}
|
|
19
|
+
declare const Link: import("svelte").Component<Props, {}, "">;
|
|
20
|
+
type Link = ReturnType<typeof Link>;
|
|
21
|
+
export default Link;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getUiConfig } from '../../config/context.js';
|
|
3
|
+
import Link from './Link.svelte';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Previous/next paging over a result set. Real links, not buttons: paging
|
|
7
|
+
* is a navigation, so it works without JavaScript, is crawlable, and each
|
|
8
|
+
* page keeps its own URL. The caller builds the hrefs because it owns the
|
|
9
|
+
* rest of the query string (the active filters).
|
|
10
|
+
*/
|
|
11
|
+
interface Props {
|
|
12
|
+
/** 1-based current page. */
|
|
13
|
+
page: number;
|
|
14
|
+
/** Total items across all pages, not just the ones on screen. */
|
|
15
|
+
total: number;
|
|
16
|
+
pageSize: number;
|
|
17
|
+
/** Href for a given 1-based page number. */
|
|
18
|
+
href: (page: number) => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let { page, total, pageSize, href }: Props = $props();
|
|
22
|
+
|
|
23
|
+
const config = getUiConfig();
|
|
24
|
+
const msg = $derived(config.messages);
|
|
25
|
+
|
|
26
|
+
const pages = $derived(Math.max(1, Math.ceil(total / pageSize)));
|
|
27
|
+
const hasPrevious = $derived(page > 1);
|
|
28
|
+
const hasNext = $derived(page < pages);
|
|
29
|
+
|
|
30
|
+
// A page past the end is reachable through a stale or shared link. It shows
|
|
31
|
+
// the empty state, so it must still show the way back — otherwise the only
|
|
32
|
+
// recovery is editing the URL by hand. "Previous" goes to the last page that
|
|
33
|
+
// has rows, not to page - 1, which would be just as empty.
|
|
34
|
+
const isOutOfRange = $derived(page > pages);
|
|
35
|
+
const previousPage = $derived(Math.min(page - 1, pages));
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
{#if pages > 1 || isOutOfRange}
|
|
39
|
+
<nav class="pagination" aria-label={msg.pagination_label()}>
|
|
40
|
+
{#if hasPrevious}
|
|
41
|
+
<Link href={href(previousPage)} rel="prev">{msg.pagination_previous()}</Link>
|
|
42
|
+
{:else}
|
|
43
|
+
<span class="disabled">{msg.pagination_previous()}</span>
|
|
44
|
+
{/if}
|
|
45
|
+
|
|
46
|
+
<p class="status" aria-live="polite">
|
|
47
|
+
{msg.pagination_status({ page, pages })}
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
{#if hasNext}
|
|
51
|
+
<Link href={href(page + 1)} rel="next">{msg.pagination_next()}</Link>
|
|
52
|
+
{:else}
|
|
53
|
+
<span class="disabled">{msg.pagination_next()}</span>
|
|
54
|
+
{/if}
|
|
55
|
+
</nav>
|
|
56
|
+
{/if}
|
|
57
|
+
|
|
58
|
+
<style>
|
|
59
|
+
.pagination {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
justify-content: center;
|
|
63
|
+
gap: var(--space-4);
|
|
64
|
+
margin-top: var(--space-5);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.status {
|
|
68
|
+
margin: 0;
|
|
69
|
+
color: var(--color-ink-secondary);
|
|
70
|
+
font-size: var(--text-sm);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.disabled {
|
|
74
|
+
color: var(--color-ink-muted);
|
|
75
|
+
font-size: var(--text-sm);
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Previous/next paging over a result set. Real links, not buttons: paging
|
|
3
|
+
* is a navigation, so it works without JavaScript, is crawlable, and each
|
|
4
|
+
* page keeps its own URL. The caller builds the hrefs because it owns the
|
|
5
|
+
* rest of the query string (the active filters).
|
|
6
|
+
*/
|
|
7
|
+
interface Props {
|
|
8
|
+
/** 1-based current page. */
|
|
9
|
+
page: number;
|
|
10
|
+
/** Total items across all pages, not just the ones on screen. */
|
|
11
|
+
total: number;
|
|
12
|
+
pageSize: number;
|
|
13
|
+
/** Href for a given 1-based page number. */
|
|
14
|
+
href: (page: number) => string;
|
|
15
|
+
}
|
|
16
|
+
declare const Pagination: import("svelte").Component<Props, {}, "">;
|
|
17
|
+
type Pagination = ReturnType<typeof Pagination>;
|
|
18
|
+
export default Pagination;
|