@indietabletop/appkit 3.2.0-8 → 3.2.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.
@@ -0,0 +1,36 @@
1
+ import {
2
+ DialogProvider,
3
+ useDialogContext,
4
+ useStoreState,
5
+ } from "@ariakit/react";
6
+ import type { ReactElement, ReactNode } from "react";
7
+
8
+ function DialogGuard(props: { children: ReactNode }) {
9
+ const dialog = useDialogContext();
10
+ const isMounted = useStoreState(dialog, (store) => store?.mounted);
11
+ if (!isMounted) {
12
+ return null;
13
+ }
14
+ return props.children;
15
+ }
16
+
17
+ /**
18
+ * Wraps AriaKit's DialogProvider, but take a tuple of Dialog a DialogDisclosure
19
+ * elements as children, and makes sense that the Dialog component is not
20
+ * rendered when it is hidden.
21
+ *
22
+ * This is important in cases where the dialog contains a form that should only
23
+ * be initialized and re-initialized when the dialog opens, not when it is first
24
+ * rendered.
25
+ */
26
+ export function DialogTrigger(props: {
27
+ children: [ReactElement, ReactElement];
28
+ }) {
29
+ const [dialog, button] = props.children;
30
+ return (
31
+ <DialogProvider>
32
+ <DialogGuard>{dialog}</DialogGuard>
33
+ {button}
34
+ </DialogProvider>
35
+ );
36
+ }
@@ -2,7 +2,7 @@ import { createTheme, style } from "@vanilla-extract/css";
2
2
  import { recipe } from "@vanilla-extract/recipes";
3
3
  import { textVariants } from "../atomic.css.ts";
4
4
  import { manofa, minion } from "../common.css.ts";
5
- import { Hover } from "../media.ts";
5
+ import { Hover, MinWidth } from "../media.ts";
6
6
 
7
7
  const align = {
8
8
  start: textVariants({ textAlign: "start" }),
@@ -11,7 +11,7 @@ const align = {
11
11
  };
12
12
 
13
13
  export const [letterheadTheme, { padding, footerMargin }] = createTheme({
14
- padding: "clamp(1rem, 8vw, 4rem)",
14
+ padding: "1.25rem",
15
15
  footerMargin: "3rem",
16
16
  });
17
17
 
@@ -25,6 +25,14 @@ export const letterhead = recipe({
25
25
  borderRadius: "1rem",
26
26
  marginInline: "auto",
27
27
  maxInlineSize: "36rem",
28
+
29
+ "@media": {
30
+ [MinWidth.SMALL]: {
31
+ vars: {
32
+ [padding]: `clamp(1.5rem, 8%, 4rem)`,
33
+ },
34
+ },
35
+ },
28
36
  },
29
37
  ],
30
38
 
@@ -19,6 +19,7 @@ export const fieldLabel = style([
19
19
  fontSize: "0.75rem",
20
20
  fontWeight: 600,
21
21
  marginBottom: "0.5rem",
22
+ textAlign: "start",
22
23
  },
23
24
  ]);
24
25
 
@@ -31,6 +32,7 @@ export const fieldInput = style([
31
32
  fontSize: "1rem",
32
33
  lineHeight: "1.25rem",
33
34
  padding: "1rem 0 1rem 1rem",
35
+ textAlign: "start",
34
36
 
35
37
  ":read-only": {
36
38
  backgroundColor: "hsl(0 0% 0% / 0.05)",
@@ -50,6 +52,7 @@ export const fieldIssue = style({
50
52
  color: Color.PURPLE,
51
53
  fontSize: "0.875rem",
52
54
  marginTop: "0.5rem",
55
+ textAlign: "start",
53
56
 
54
57
  ":empty": {
55
58
  display: "none",
@@ -60,6 +63,7 @@ export const fieldHint = style({
60
63
  color: Color.MID_GRAY,
61
64
  fontSize: "0.875rem",
62
65
  marginTop: "0.5rem",
66
+ textAlign: "start",
63
67
 
64
68
  selectors: {
65
69
  [`${fieldIssue}:not(:empty) + &`]: {
@@ -73,6 +77,7 @@ export const submitError = style({
73
77
  color: Color.PURPLE,
74
78
  backgroundColor: Color.PALE_GRAY,
75
79
  borderRadius: "0.75rem",
80
+ textAlign: "start",
76
81
 
77
82
  ":empty": {
78
83
  display: "none",
@@ -0,0 +1,28 @@
1
+ import { Dialog, type DialogProps } from "@ariakit/react";
2
+ import type { RecipeVariants } from "@vanilla-extract/recipes";
3
+ import type { ReactNode } from "react";
4
+ import { cx } from "../class-names.ts";
5
+ import * as css from "./style.css.ts";
6
+
7
+ type Size = NonNullable<NonNullable<RecipeVariants<typeof css.dialog>>["size"]>;
8
+
9
+ export type ModalDialogProps = Omit<DialogProps, "modal" | "backdrop"> & {
10
+ children: ReactNode;
11
+ size: Size;
12
+ backdropClassName?: string;
13
+ };
14
+
15
+ export function ModalDialog(props: ModalDialogProps) {
16
+ const { size, backdropClassName, className, ...dialogProps } = props;
17
+
18
+ return (
19
+ <Dialog
20
+ {...dialogProps}
21
+ {...cx(className, css.dialog({ size }))}
22
+ backdrop={<div {...cx(css.backdrop, backdropClassName)} />}
23
+ modal
24
+ >
25
+ {props.children}
26
+ </Dialog>
27
+ );
28
+ }
@@ -0,0 +1,87 @@
1
+ import { style } from "@vanilla-extract/css";
2
+ import { recipe } from "@vanilla-extract/recipes";
3
+ import { MinWidth } from "../media.ts";
4
+
5
+ const scaleTransition = {
6
+ transition: "transform 200ms, opacity 200ms",
7
+ transform: "scale(1.1)",
8
+
9
+ selectors: {
10
+ "&[data-enter]": {
11
+ opacity: 1,
12
+ transform: "scale(1)",
13
+ },
14
+
15
+ "&[data-leave]": {
16
+ opacity: 0,
17
+ transform: "scale(0.9)",
18
+ },
19
+ },
20
+ };
21
+
22
+ const translateTransition = {
23
+ transition: "transform 200ms, opacity 200ms",
24
+ transform: "translateY(5rem)",
25
+
26
+ selectors: {
27
+ "&[data-enter]": {
28
+ opacity: 1,
29
+ transform: "translateY(0)",
30
+ },
31
+
32
+ "&[data-leave]": {
33
+ opacity: 0,
34
+ transform: "translateY(5rem)",
35
+ },
36
+ },
37
+ };
38
+
39
+ export const dialog = recipe({
40
+ base: {
41
+ position: "fixed",
42
+ inset: 0,
43
+ zIndex: 100,
44
+ margin: "auto",
45
+ overflow: "auto",
46
+ opacity: 0,
47
+ backgroundColor: "white",
48
+ },
49
+
50
+ variants: {
51
+ size: {
52
+ large: {
53
+ ...translateTransition,
54
+ inlineSize: "100%",
55
+ blockSize: "100%",
56
+
57
+ "@media": {
58
+ [MinWidth.MEDIUM]: {
59
+ ...scaleTransition,
60
+ blockSize: "fit-content",
61
+ maxInlineSize: "40rem",
62
+ maxBlockSize: "90%",
63
+ borderRadius: "1rem",
64
+ },
65
+ },
66
+ },
67
+
68
+ small: {
69
+ ...scaleTransition,
70
+ inlineSize: "min(24rem, 90svw)",
71
+ blockSize: "fit-content",
72
+ borderRadius: "1rem",
73
+ },
74
+ },
75
+ },
76
+ });
77
+
78
+ export const backdrop = style({
79
+ backgroundColor: "black",
80
+ opacity: 0,
81
+ transition: "opacity 200ms",
82
+ selectors: {
83
+ "&[data-enter]": {
84
+ opacity: 0.4,
85
+ },
86
+ },
87
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ appendCopyToText,
4
+ maybeAppendCopyToText,
5
+ } from "./append-copy-to-text.ts";
6
+
7
+ describe("appendCopyToText", () => {
8
+ test("Appends ' (Copy)' to provided string", () => {
9
+ const returnValue = appendCopyToText("Zangrad Raiders");
10
+ expect(returnValue).toBe("Zangrad Raiders (Copy)");
11
+ });
12
+
13
+ test("Adds a copy count number if string already ends in ' (Copy)'", () => {
14
+ const returnValue = appendCopyToText("Zangrad Raiders (Copy)");
15
+ expect(returnValue).toBe("Zangrad Raiders (Copy 2)");
16
+ });
17
+
18
+ test("Increments a copy count number if one already exists", () => {
19
+ const returnValue = appendCopyToText("Zangrad Raiders (Copy 2)");
20
+ expect(returnValue).toBe("Zangrad Raiders (Copy 3)");
21
+ });
22
+ });
23
+
24
+ describe("maybeAppendCopyToText", () => {
25
+ test("Ignores empty strings", () => {
26
+ const returnValue = maybeAppendCopyToText("");
27
+ expect(returnValue).toBe("");
28
+ });
29
+ });
@@ -0,0 +1,169 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ getFetchFailureMessages,
4
+ getSubmitFailureMessage,
5
+ } from "./failureMessages.ts";
6
+
7
+ describe("getFetchFailureMessages", () => {
8
+ test("Returns correct message for API_ERROR with code 404", () => {
9
+ const result = getFetchFailureMessages({ type: "API_ERROR", code: 404 });
10
+
11
+ expect(result).toMatchInlineSnapshot(`
12
+ {
13
+ "action": {
14
+ "href": "~/",
15
+ "label": "Go back",
16
+ "type": "LINK",
17
+ },
18
+ "description": "The link you have followed might be broken.",
19
+ "title": "Not found",
20
+ }
21
+ `);
22
+ });
23
+
24
+ test("Returns correct message for API_ERROR with code 500", () => {
25
+ const result = getFetchFailureMessages({ type: "API_ERROR", code: 500 });
26
+
27
+ expect(result).toMatchInlineSnapshot(`
28
+ {
29
+ "action": {
30
+ "label": "Reload app",
31
+ "type": "RELOAD",
32
+ },
33
+ "description": "This is probably an issue with our servers. You can try refreshing.",
34
+ "title": "Ooops, something went wrong",
35
+ }
36
+ `);
37
+ });
38
+
39
+ test("Returns correct message for API_ERROR with partial override", () => {
40
+ const result = getFetchFailureMessages(
41
+ { type: "API_ERROR", code: 404 },
42
+ { 404: { title: `Army not found` } },
43
+ );
44
+
45
+ expect(result).toMatchInlineSnapshot(`
46
+ {
47
+ "action": {
48
+ "href": "~/",
49
+ "label": "Go back",
50
+ "type": "LINK",
51
+ },
52
+ "description": "The link you have followed might be broken.",
53
+ "title": "Army not found",
54
+ }
55
+ `);
56
+ });
57
+
58
+ test("Returns correct message for API_ERROR with override", () => {
59
+ const result = getFetchFailureMessages(
60
+ { type: "API_ERROR", code: 404 },
61
+ {
62
+ 404: {
63
+ title: `Army not found`,
64
+ description: `It might have been deleted.`,
65
+ },
66
+ },
67
+ );
68
+
69
+ expect(result).toMatchInlineSnapshot(`
70
+ {
71
+ "action": {
72
+ "href": "~/",
73
+ "label": "Go back",
74
+ "type": "LINK",
75
+ },
76
+ "description": "It might have been deleted.",
77
+ "title": "Army not found",
78
+ }
79
+ `);
80
+ });
81
+
82
+ test("Returns correct message for NETWORK_ERROR", () => {
83
+ const result = getFetchFailureMessages({ type: "NETWORK_ERROR" });
84
+
85
+ expect(result).toMatchInlineSnapshot(`
86
+ {
87
+ "action": {
88
+ "label": "Retry request",
89
+ "type": "REFETCH",
90
+ },
91
+ "description": "Check your interent connection and try again.",
92
+ "title": "No connection",
93
+ }
94
+ `);
95
+ });
96
+
97
+ test("Returns correct message for UNKNOWN_ERROR", () => {
98
+ const result = getFetchFailureMessages({ type: "UNKNOWN_ERROR" });
99
+
100
+ expect(result).toMatchInlineSnapshot(`
101
+ {
102
+ "action": {
103
+ "label": "Reload app",
104
+ "type": "RELOAD",
105
+ },
106
+ "description": "This is probably an issue on our side. You can try refreshing.",
107
+ "title": "Ooops, something went wrong",
108
+ }
109
+ `);
110
+ });
111
+
112
+ test("Returns correct message for an unrecognised error type", () => {
113
+ const result = getFetchFailureMessages({ type: "FOO" as any });
114
+
115
+ expect(result).toMatchInlineSnapshot(`
116
+ {
117
+ "action": {
118
+ "label": "Reload app",
119
+ "type": "RELOAD",
120
+ },
121
+ "description": "This is probably an issue on our side. You can try refreshing.",
122
+ "title": "Ooops, something went wrong",
123
+ }
124
+ `);
125
+ });
126
+ });
127
+
128
+ describe("getSubmitFailureMessage", () => {
129
+ test("Returns correct message for API_ERROR with code 500", () => {
130
+ const message = getSubmitFailureMessage({ type: "API_ERROR", code: 500 });
131
+ expect(message).toMatchInlineSnapshot(
132
+ `"Could not submit form due to an unexpected server error. Please refresh the page and try again."`,
133
+ );
134
+ });
135
+
136
+ test("Returns correct message for API_ERROR with override", () => {
137
+ const message = getSubmitFailureMessage(
138
+ { type: "API_ERROR", code: 401 },
139
+ { 401: `Username and password do not match. Please try again.` },
140
+ );
141
+ expect(message).toMatchInlineSnapshot(
142
+ `"Username and password do not match. Please try again."`,
143
+ );
144
+ });
145
+
146
+ test("Returns correct message for NETWORK_ERROR", () => {
147
+ const result = getSubmitFailureMessage({ type: "NETWORK_ERROR" });
148
+
149
+ expect(result).toMatchInlineSnapshot(
150
+ `"Could not submit form due to network error. Make sure you are connected to the internet and try again."`,
151
+ );
152
+ });
153
+
154
+ test("Returns correct message for UNKNOWN_ERROR", () => {
155
+ const result = getSubmitFailureMessage({ type: "UNKNOWN_ERROR" });
156
+
157
+ expect(result).toMatchInlineSnapshot(
158
+ `"Could not submit form due to an unexpected error. Please refresh the page and try again."`,
159
+ );
160
+ });
161
+
162
+ test("Returns correct message for an unrecognised error type", () => {
163
+ const result = getSubmitFailureMessage({ type: "FOO" as any });
164
+
165
+ expect(result).toMatchInlineSnapshot(
166
+ `"Could not submit form due to an unexpected error. Please refresh the page and try again."`,
167
+ );
168
+ });
169
+ });
@@ -0,0 +1,108 @@
1
+ import type { FailurePayload } from "./types.ts";
2
+
3
+ type OnOverride<T> = (fallback: T, override?: Partial<T>) => T;
4
+
5
+ function createFailureMessageGetter<T>(
6
+ defaults: Record<number | "fallback" | "connection", T>,
7
+ options: { onOverride: OnOverride<T> },
8
+ ) {
9
+ return function getMessage(
10
+ failure: FailurePayload,
11
+ overrides: Record<number, Partial<T>> = {},
12
+ ) {
13
+ switch (failure.type) {
14
+ case "API_ERROR": {
15
+ return options.onOverride(
16
+ defaults[failure.code] ?? defaults.fallback,
17
+ overrides[failure.code],
18
+ );
19
+ }
20
+
21
+ case "NETWORK_ERROR": {
22
+ return defaults.connection;
23
+ }
24
+
25
+ default: {
26
+ return defaults.fallback;
27
+ }
28
+ }
29
+ };
30
+ }
31
+
32
+ export type FetchFailureAction =
33
+ | {
34
+ type: "LINK";
35
+ href: string;
36
+ label: string;
37
+ }
38
+ | {
39
+ type: "RELOAD" | "REFETCH";
40
+ label: string;
41
+ };
42
+
43
+ export type FetchFailureMessages = {
44
+ title: string;
45
+ description: string;
46
+ action: FetchFailureAction;
47
+ };
48
+
49
+ export const getFetchFailureMessages =
50
+ createFailureMessageGetter<FetchFailureMessages>(
51
+ {
52
+ 401: {
53
+ title: "Not logged in",
54
+ description: "You must be logged in to view this page.",
55
+ action: { type: "LINK", href: "~/login", label: "Go to login" },
56
+ },
57
+ 403: {
58
+ title: "Not authorized",
59
+ description: "You might be logged into the wrong account.",
60
+ action: { type: "LINK", href: "~/login", label: "Go to login" },
61
+ },
62
+ 404: {
63
+ title: `Not found`,
64
+ description: `The link you have followed might be broken.`,
65
+ action: { type: "LINK", href: "~/", label: "Go back" },
66
+ },
67
+ 500: {
68
+ title: `Ooops, something went wrong`,
69
+ description: `This is probably an issue with our servers. You can try refreshing.`,
70
+ action: { type: "RELOAD", label: "Reload app" },
71
+ },
72
+ connection: {
73
+ title: `No connection`,
74
+ description: `Check your interent connection and try again.`,
75
+ action: { type: "REFETCH", label: "Retry request" },
76
+ },
77
+ fallback: {
78
+ title: `Ooops, something went wrong`,
79
+ description: `This is probably an issue on our side. You can try refreshing.`,
80
+ action: { type: "RELOAD", label: "Reload app" },
81
+ },
82
+ },
83
+ {
84
+ onOverride(fallback, override) {
85
+ return { ...fallback, ...override };
86
+ },
87
+ },
88
+ );
89
+
90
+ export const getSubmitFailureMessage = createFailureMessageGetter(
91
+ {
92
+ 500: `Could not submit form due to an unexpected server error. Please refresh the page and try again.`,
93
+ connection: `Could not submit form due to network error. Make sure you are connected to the internet and try again.`,
94
+ fallback: `Could not submit form due to an unexpected error. Please refresh the page and try again.`,
95
+ },
96
+ {
97
+ onOverride(fallback, override) {
98
+ return override ?? fallback;
99
+ },
100
+ },
101
+ );
102
+
103
+ /**
104
+ * @deprecated Use {@link getSubmitFailureMessage} instead.
105
+ */
106
+ export function toKnownFailureMessage(failure: FailurePayload) {
107
+ return getSubmitFailureMessage(failure);
108
+ }
@@ -8,6 +8,7 @@ globalStyle(":root", {
8
8
  textRendering: "optimizeLegibility",
9
9
  WebkitFontSmoothing: "antialiased",
10
10
  MozOsxFontSmoothing: "grayscale",
11
+ WebkitTapHighlightColor: "transparent",
11
12
  });
12
13
 
13
14
  globalStyle("*", {
package/lib/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // Components
2
+ export * from "./DialogTrigger/index.tsx";
2
3
  export * from "./ExternalLink.tsx";
3
4
  export * from "./FormSubmitButton.tsx";
4
5
  export * from "./FullscreenDismissBlocker.tsx";
@@ -6,6 +7,7 @@ export * from "./IndieTabletopClubSymbol.tsx";
6
7
  export * from "./Letterhead/index.tsx";
7
8
  export * from "./LetterheadForm/index.tsx";
8
9
  export * from "./LoadingIndicator.tsx";
10
+ export * from "./ModalDialog/index.tsx";
9
11
  export * from "./ServiceWorkerHandler.tsx";
10
12
 
11
13
  // Hooks
@@ -23,7 +25,9 @@ export * from "./async-op.ts";
23
25
  export * from "./caught-value.ts";
24
26
  export * from "./class-names.ts";
25
27
  export * from "./client.ts";
26
- export * from "./knownFailure.ts";
28
+ export * from "./failureMessages.ts";
27
29
  export * from "./media.ts";
30
+ export * from "./random.ts";
28
31
  export * from "./structs.ts";
29
32
  export * from "./types.ts";
33
+ export * from "./validations.ts";
package/lib/random.ts ADDED
@@ -0,0 +1,7 @@
1
+ export function random(max: number) {
2
+ return Math.floor(Math.random() * max);
3
+ }
4
+
5
+ export function randomItem<T>(array: T[]) {
6
+ return array[random(array.length)];
7
+ }
@@ -0,0 +1,25 @@
1
+ export const EMAIL_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
2
+
3
+ export const validEmail = (value: string) => {
4
+ if (value && !EMAIL_REGEX.test(value)) {
5
+ return "This doesn't look like a valid email.";
6
+ }
7
+
8
+ return null;
9
+ };
10
+
11
+ export const validPassword = (value: string) => {
12
+ if (value) {
13
+ if (value.length < 8) {
14
+ return "A password has to be at least 8 characters long.";
15
+ }
16
+
17
+ if (value.length > 256) {
18
+ return "A password cannot be longer than 256 characters.";
19
+ }
20
+
21
+ return null;
22
+ }
23
+
24
+ return null;
25
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indietabletop/appkit",
3
- "version": "3.2.0-8",
3
+ "version": "3.2.0",
4
4
  "description": "A collection of modules used in apps built by Indie Tabletop Club",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "devDependencies": {
28
28
  "@storybook/addon-docs": "^9.0.4",
29
29
  "@storybook/react-vite": "^9.0.4",
30
- "@types/react": "^19.0.8",
30
+ "@types/react": "^19.1.8",
31
31
  "np": "^10.1.0",
32
32
  "storybook": "^9.0.4",
33
33
  "typescript": "^5.8.2",
@@ -1,9 +0,0 @@
1
- import type { FailurePayload } from "./types.ts";
2
-
3
- export function toKnownFailureMessage(failure: FailurePayload) {
4
- if (failure.type === "NETWORK_ERROR") {
5
- return "Could not submit form due to network error. Make sure you are connected to the internet and try again.";
6
- }
7
-
8
- return "Could not submit form due to an unexpected error. Please refresh the page and try again.";
9
- }