@sjolystinnovation/app-kit 0.10.0 → 0.11.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/README.md +69 -0
- package/package.json +1 -1
- package/src/styles.css +7 -0
- package/src/ui/ContentImage.tsx +73 -0
- package/src/ui/ErrorState.tsx +52 -0
- package/src/ui/NotFoundState.tsx +37 -0
- package/src/ui/index.ts +6 -0
package/README.md
CHANGED
|
@@ -660,6 +660,70 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
|
|
|
660
660
|
| `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables. A clashing class wins or loses by the order Tailwind emits them in. |
|
|
661
661
|
| `children` | `ReactNode` | | Content. |
|
|
662
662
|
|
|
663
|
+
### `ContentImage`
|
|
664
|
+
|
|
665
|
+
From `@sjolystinnovation/app-kit/ui`. An uploaded image from the content API, as a plain `<img>`
|
|
666
|
+
with a `srcset` of the widths the API serves. It goes through the content images rewrite from
|
|
667
|
+
`defineAppConfig({ contentImages: true })`, so the browser never contacts the API host. It works in
|
|
668
|
+
a server component.
|
|
669
|
+
|
|
670
|
+
```tsx
|
|
671
|
+
<ContentImage imageId={post.imageId} alt={post.title} sizes="(min-width: 768px) 50vw, 100vw" />
|
|
672
|
+
<ContentImage imageId={hero.imageId} fallbackSrc="/hero.jpg" alt="" sizes="100vw" priority aria-hidden />
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
| Prop | Type | Default | Description |
|
|
676
|
+
| --- | --- | --- | --- |
|
|
677
|
+
| `imageId` | `string \| null` | | Uploaded image id. Null or empty shows `fallbackSrc` as it is, with no `srcset`. |
|
|
678
|
+
| `alt` | `string` | | Empty for a decorative image. |
|
|
679
|
+
| `sizes` | `string` | | Widths the image is shown at. Required, since a browser assumes `100vw` without it. |
|
|
680
|
+
| `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
|
|
681
|
+
| `priority` | `boolean` | `false` | For the one image visible without scrolling. It loads eagerly, at high priority. |
|
|
682
|
+
| `width`, `height` | `number` | | Intrinsic size, to reserve space where the layout fixes no ratio. |
|
|
683
|
+
| `base` | `string` | `"/content-images"` | Path the content images rewrite serves. |
|
|
684
|
+
| `widths` | `number[]` | `CONTENT_IMAGE_WIDTHS` | Widths in the `srcset`. |
|
|
685
|
+
| `className` | `string` | | Classes on the `<img>`. |
|
|
686
|
+
|
|
687
|
+
`contentImagePath(id, base?)` and `contentImageSrcSet(id, widths?, base?)` build the same URLs for
|
|
688
|
+
other uses, such as a CSS background or an Open Graph image.
|
|
689
|
+
|
|
690
|
+
### `ErrorState`
|
|
691
|
+
|
|
692
|
+
From `@sjolystinnovation/app-kit/ui`. What an `error.tsx` shows. It is a client component, as an
|
|
693
|
+
error boundary has to be.
|
|
694
|
+
|
|
695
|
+
```tsx
|
|
696
|
+
"use client";
|
|
697
|
+
|
|
698
|
+
export default function Error({ reset }: { error: Error; reset: () => void }) {
|
|
699
|
+
return <ErrorState reset={reset} homeHref="/" />;
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
| Prop | Type | Description |
|
|
704
|
+
| --- | --- | --- |
|
|
705
|
+
| `reset` | `() => void` | The `reset` Next passes to `error.tsx`. The try again button calls it. |
|
|
706
|
+
| `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
|
|
707
|
+
| `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain` and `home`. |
|
|
708
|
+
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
709
|
+
|
|
710
|
+
The error itself is never shown.
|
|
711
|
+
|
|
712
|
+
### `NotFoundState`
|
|
713
|
+
|
|
714
|
+
From `@sjolystinnovation/app-kit/ui`. What a `not-found.tsx` shows: a large code, the message as
|
|
715
|
+
the page's heading, and a link home. It works in a server component.
|
|
716
|
+
|
|
717
|
+
```tsx
|
|
718
|
+
<NotFoundState homeHref="/no" strings={{ body: dict.notFound, home: dict.toFrontPage }} />
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
| Prop | Type | Description |
|
|
722
|
+
| --- | --- | --- |
|
|
723
|
+
| `homeHref` | `string` | Where the home link goes. |
|
|
724
|
+
| `strings` | `TextOverrides<NotFoundStrings>` | `code`, `body` and `home`. |
|
|
725
|
+
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
726
|
+
|
|
663
727
|
### `PasswordInput`
|
|
664
728
|
|
|
665
729
|
A labelled password field with a button that shows and hides the value. It also takes the other
|
|
@@ -767,6 +831,11 @@ selector such as `.dark` for a scoped theme:
|
|
|
767
831
|
| `--color-dialog-bg` | white |
|
|
768
832
|
| `--color-dialog-title` | gray 900 |
|
|
769
833
|
| `--color-dialog-text` | gray 600 |
|
|
834
|
+
| `--color-state-heading` | gray 900, the heading of an `ErrorState` |
|
|
835
|
+
| `--color-state-text` | gray 600, the text of an `ErrorState` or `NotFoundState` |
|
|
836
|
+
| `--color-state-code` | gray 900, the code of a `NotFoundState` |
|
|
837
|
+
| `--font-state-code` | `inherit`, the font of that code |
|
|
838
|
+
| `--color-state-link`, `-hover` | gray 900, the home link |
|
|
770
839
|
|
|
771
840
|
A default that points at another variable is read once for the whole page. The focus ring and the
|
|
772
841
|
primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
|
package/package.json
CHANGED
package/src/styles.css
CHANGED
|
@@ -45,6 +45,13 @@
|
|
|
45
45
|
--color-toggle-off: var(--color-gray-200);
|
|
46
46
|
--color-toggle-thumb: var(--color-white);
|
|
47
47
|
|
|
48
|
+
--color-state-heading: var(--color-gray-900);
|
|
49
|
+
--color-state-text: var(--color-gray-600);
|
|
50
|
+
--color-state-code: var(--color-gray-900);
|
|
51
|
+
--font-state-code: inherit;
|
|
52
|
+
--color-state-link: var(--color-gray-900);
|
|
53
|
+
--color-state-link-hover: var(--color-gray-900);
|
|
54
|
+
|
|
48
55
|
--radius-button: var(--radius-lg);
|
|
49
56
|
--color-button-primary-bg: var(--color-primary);
|
|
50
57
|
--color-button-primary-text: var(--color-primary-foreground);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** The widths the API serves an uploaded image at, as webp. */
|
|
2
|
+
export const CONTENT_IMAGE_WIDTHS: readonly number[] = [384, 640, 768, 1024, 1366, 1600];
|
|
3
|
+
|
|
4
|
+
/** Where the browser fetches an uploaded image. Site-relative, through the app's content images rewrite. */
|
|
5
|
+
export function contentImagePath(id: string, base = "/content-images"): string {
|
|
6
|
+
return `${base}/${encodeURIComponent(id)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The `srcset` for an uploaded image. The API serves the nearest webp at or above the requested
|
|
11
|
+
* width, or the original when the browser does not accept webp.
|
|
12
|
+
*/
|
|
13
|
+
export function contentImageSrcSet(id: string, widths: readonly number[] = CONTENT_IMAGE_WIDTHS, base?: string): string {
|
|
14
|
+
return widths.map(w => `${contentImagePath(id, base)}?w=${w} ${w}w`).join(", ");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ContentImageProps {
|
|
18
|
+
/** Uploaded image id. Null falls back to `fallbackSrc`, which is served as it is. */
|
|
19
|
+
imageId: string | null;
|
|
20
|
+
/** Empty for an image that carries no meaning of its own, such as a decorative backdrop. */
|
|
21
|
+
alt: string;
|
|
22
|
+
/** Widths the image is shown at. Required, since a browser assumes 100vw without it. */
|
|
23
|
+
sizes: string;
|
|
24
|
+
/** A static path for when there is no uploaded image. */
|
|
25
|
+
fallbackSrc?: string | undefined;
|
|
26
|
+
className?: string | undefined;
|
|
27
|
+
/** Intrinsic size, to reserve space. Only needed where the layout fixes no ratio. */
|
|
28
|
+
width?: number | undefined;
|
|
29
|
+
height?: number | undefined;
|
|
30
|
+
/** Set on the one image visible without scrolling. It loads eagerly, at high priority. */
|
|
31
|
+
priority?: boolean | undefined;
|
|
32
|
+
"aria-hidden"?: boolean | undefined;
|
|
33
|
+
/** Path the content images rewrite serves, `/content-images` by default. */
|
|
34
|
+
base?: string | undefined;
|
|
35
|
+
/** Widths in the `srcset`. Defaults to the widths the API serves. */
|
|
36
|
+
widths?: readonly number[] | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** An image from the content API, served responsively from the `sizes` a call site declares. */
|
|
40
|
+
export function ContentImage({
|
|
41
|
+
imageId,
|
|
42
|
+
alt,
|
|
43
|
+
sizes,
|
|
44
|
+
fallbackSrc,
|
|
45
|
+
className,
|
|
46
|
+
width,
|
|
47
|
+
height,
|
|
48
|
+
priority = false,
|
|
49
|
+
"aria-hidden": ariaHidden,
|
|
50
|
+
base,
|
|
51
|
+
widths,
|
|
52
|
+
}: ContentImageProps) {
|
|
53
|
+
const uploaded = imageId != null && imageId !== "";
|
|
54
|
+
const src = uploaded ? contentImagePath(imageId, base) : fallbackSrc;
|
|
55
|
+
if (!src) return null;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
// A plain img, because the API already serves the sizes. next/image would resize them again.
|
|
59
|
+
<img
|
|
60
|
+
src={src}
|
|
61
|
+
srcSet={uploaded ? contentImageSrcSet(imageId, widths, base) : undefined}
|
|
62
|
+
sizes={uploaded ? sizes : undefined}
|
|
63
|
+
alt={alt}
|
|
64
|
+
className={className}
|
|
65
|
+
width={width}
|
|
66
|
+
height={height}
|
|
67
|
+
loading={priority ? "eager" : "lazy"}
|
|
68
|
+
fetchPriority={priority ? "high" : undefined}
|
|
69
|
+
decoding={priority ? "sync" : "async"}
|
|
70
|
+
aria-hidden={ariaHidden}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { cx } from "./cx";
|
|
5
|
+
import { withDefaults, type TextOverrides } from "./withDefaults";
|
|
6
|
+
|
|
7
|
+
export interface ErrorStateStrings {
|
|
8
|
+
title: string;
|
|
9
|
+
body: string;
|
|
10
|
+
tryAgain: string;
|
|
11
|
+
home: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const defaultErrorStateStrings: ErrorStateStrings = {
|
|
15
|
+
title: "Temporarily unavailable",
|
|
16
|
+
body: "We can't reach the service right now. Please try again shortly.",
|
|
17
|
+
tryAgain: "Try again",
|
|
18
|
+
home: "Go to the front page",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface ErrorStateProps {
|
|
22
|
+
/** The `reset` Next passes to an `error.tsx`. It renders the page again. */
|
|
23
|
+
reset: () => void;
|
|
24
|
+
/** Where the front page link goes, such as `/` or `/en`. */
|
|
25
|
+
homeHref: string;
|
|
26
|
+
strings?: TextOverrides<ErrorStateStrings> | undefined;
|
|
27
|
+
/** Classes for placing it, on the outer element. */
|
|
28
|
+
className?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What an `error.tsx` shows when a page cannot be rendered. The response is still a 500. */
|
|
32
|
+
export function ErrorState({ reset, homeHref, strings, className }: ErrorStateProps) {
|
|
33
|
+
const text = withDefaults(defaultErrorStateStrings, strings);
|
|
34
|
+
return (
|
|
35
|
+
<div className={cx("min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4 text-center", className)}>
|
|
36
|
+
<h1 className="text-3xl font-black text-state-heading">{text.title}</h1>
|
|
37
|
+
<p className="text-lg text-state-text max-w-md">{text.body}</p>
|
|
38
|
+
<div className="mt-2 flex flex-wrap items-center justify-center gap-4">
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={reset}
|
|
42
|
+
className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-6 py-3 hover:brightness-95 transition"
|
|
43
|
+
>
|
|
44
|
+
{text.tryAgain}
|
|
45
|
+
</button>
|
|
46
|
+
<Link href={homeHref} className="font-semibold text-state-link hover:text-state-link-hover underline">
|
|
47
|
+
{text.home}
|
|
48
|
+
</Link>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { cx } from "./cx";
|
|
3
|
+
import { withDefaults, type TextOverrides } from "./withDefaults";
|
|
4
|
+
|
|
5
|
+
export interface NotFoundStrings {
|
|
6
|
+
code: string;
|
|
7
|
+
body: string;
|
|
8
|
+
home: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const defaultNotFoundStrings: NotFoundStrings = {
|
|
12
|
+
code: "404",
|
|
13
|
+
body: "This page does not exist.",
|
|
14
|
+
home: "Go home",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface NotFoundStateProps {
|
|
18
|
+
/** Where the home link goes, such as `/` or `/en`. */
|
|
19
|
+
homeHref: string;
|
|
20
|
+
strings?: TextOverrides<NotFoundStrings> | undefined;
|
|
21
|
+
/** Classes for placing it, on the outer element. */
|
|
22
|
+
className?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a `not-found.tsx` shows. The message is the page's heading. */
|
|
26
|
+
export function NotFoundState({ homeHref, strings, className }: NotFoundStateProps) {
|
|
27
|
+
const text = withDefaults(defaultNotFoundStrings, strings);
|
|
28
|
+
return (
|
|
29
|
+
<div className={cx("min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4 text-center", className)}>
|
|
30
|
+
<span className="text-6xl font-black font-state-code text-state-code">{text.code}</span>
|
|
31
|
+
<h1 className="text-lg text-state-text">{text.body}</h1>
|
|
32
|
+
<Link href={homeHref} className="font-semibold text-state-link hover:text-state-link-hover underline">
|
|
33
|
+
{text.home}
|
|
34
|
+
</Link>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
export { Alert } from "./Alert";
|
|
2
2
|
export type { AlertProps } from "./Alert";
|
|
3
|
+
export { ContentImage, CONTENT_IMAGE_WIDTHS, contentImagePath, contentImageSrcSet } from "./ContentImage";
|
|
4
|
+
export type { ContentImageProps } from "./ContentImage";
|
|
3
5
|
export { CredentialsForm } from "./CredentialsForm";
|
|
4
6
|
export type { CredentialsFormProps } from "./CredentialsForm";
|
|
5
7
|
export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsFormStrings";
|
|
6
8
|
export type { CredentialsFormStrings } from "./credentialsFormStrings";
|
|
9
|
+
export { ErrorState, defaultErrorStateStrings } from "./ErrorState";
|
|
10
|
+
export type { ErrorStateProps, ErrorStateStrings } from "./ErrorState";
|
|
11
|
+
export { NotFoundState, defaultNotFoundStrings } from "./NotFoundState";
|
|
12
|
+
export type { NotFoundStateProps, NotFoundStrings } from "./NotFoundState";
|
|
7
13
|
export { PasswordInput } from "./PasswordInput";
|
|
8
14
|
export type { PasswordInputProps } from "./PasswordInput";
|
|
9
15
|
export { TextArea } from "./TextArea";
|