@sjolystinnovation/app-kit 0.10.0 → 0.12.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 CHANGED
@@ -28,7 +28,7 @@ const nextConfig: NextConfig = {
28
28
  };
29
29
  ```
30
30
 
31
- The components in `@sjolystinnovation/app-kit/ui` and `session/react` are styled with Tailwind CSS.
31
+ The components in `@sjolystinnovation/app-kit/ui`, `markdown` and `session/react` are styled with Tailwind CSS.
32
32
  Import the package's stylesheet after Tailwind:
33
33
 
34
34
  ```css
@@ -348,6 +348,36 @@ The pieces are exported too: `securityHeaders(options)`, `contentSecurityPolicy(
348
348
  `apiBaseUrl(apiUrl)`. Scripts allow `'unsafe-inline'`, since a nonce would make every statically
349
349
  rendered page dynamic.
350
350
 
351
+ ### `@sjolystinnovation/app-kit/markdown`
352
+
353
+ Renders Markdown from an editor or the API, with GitHub tables, task lists and footnotes. Raw HTML is
354
+ never rendered and the output is sanitized, so the text cannot inject scripts, styles, event
355
+ handlers or `javascript:` links. It works in a server component. It needs `react-markdown`,
356
+ `remark-gfm` and `rehype-sanitize`, which are optional peer dependencies:
357
+
358
+ ```sh
359
+ npm install react-markdown remark-gfm rehype-sanitize
360
+ ```
361
+
362
+ ```tsx
363
+ import { Markdown } from "@sjolystinnovation/app-kit/markdown";
364
+
365
+ <div className="space-y-4 text-sm text-gray-700">
366
+ <Markdown>{build.description}</Markdown>
367
+ </div>;
368
+ ```
369
+
370
+ | Prop | Type | Default | Description |
371
+ | --- | --- | --- | --- |
372
+ | `children` | `string \| null \| undefined` | | The Markdown. Empty, null or undefined renders nothing. |
373
+ | `components` | `Components` | | Replaces the element for a tag, such as `{ p: MyParagraph }`. Tags not named keep the default. It gets the props without `node`, so it can spread them onto an element. |
374
+ | `externalLinks` | `boolean` | `false` | Opens `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
375
+ | `className` | `string` | | Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. |
376
+
377
+ The text takes its colour and size from the parent. A level one heading becomes a level two, since
378
+ the page has its own `h1`. `markdownComponents` holds the default elements, for an app that builds
379
+ on them.
380
+
351
381
  ### `@sjolystinnovation/app-kit/session`
352
382
 
353
383
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
@@ -660,6 +690,70 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
660
690
  | `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
691
  | `children` | `ReactNode` | | Content. |
662
692
 
693
+ ### `ContentImage`
694
+
695
+ From `@sjolystinnovation/app-kit/ui`. An uploaded image from the content API, as a plain `<img>`
696
+ with a `srcset` of the widths the API serves. It goes through the content images rewrite from
697
+ `defineAppConfig({ contentImages: true })`, so the browser never contacts the API host. It works in
698
+ a server component.
699
+
700
+ ```tsx
701
+ <ContentImage imageId={post.imageId} alt={post.title} sizes="(min-width: 768px) 50vw, 100vw" />
702
+ <ContentImage imageId={hero.imageId} fallbackSrc="/hero.jpg" alt="" sizes="100vw" priority aria-hidden />
703
+ ```
704
+
705
+ | Prop | Type | Default | Description |
706
+ | --- | --- | --- | --- |
707
+ | `imageId` | `string \| null` | | Uploaded image id. Null or empty shows `fallbackSrc` as it is, with no `srcset`. |
708
+ | `alt` | `string` | | Empty for a decorative image. |
709
+ | `sizes` | `string` | | Widths the image is shown at. Required, since a browser assumes `100vw` without it. |
710
+ | `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
711
+ | `priority` | `boolean` | `false` | For the one image visible without scrolling. It loads eagerly, at high priority. |
712
+ | `width`, `height` | `number` | | Intrinsic size, to reserve space where the layout fixes no ratio. |
713
+ | `base` | `string` | `"/content-images"` | Path the content images rewrite serves. |
714
+ | `widths` | `number[]` | `CONTENT_IMAGE_WIDTHS` | Widths in the `srcset`. |
715
+ | `className` | `string` | | Classes on the `<img>`. |
716
+
717
+ `contentImagePath(id, base?)` and `contentImageSrcSet(id, widths?, base?)` build the same URLs for
718
+ other uses, such as a CSS background or an Open Graph image.
719
+
720
+ ### `ErrorState`
721
+
722
+ From `@sjolystinnovation/app-kit/ui`. What an `error.tsx` shows. It is a client component, as an
723
+ error boundary has to be.
724
+
725
+ ```tsx
726
+ "use client";
727
+
728
+ export default function Error({ reset }: { error: Error; reset: () => void }) {
729
+ return <ErrorState reset={reset} homeHref="/" />;
730
+ }
731
+ ```
732
+
733
+ | Prop | Type | Description |
734
+ | --- | --- | --- |
735
+ | `reset` | `() => void` | The `reset` Next passes to `error.tsx`. The try again button calls it. |
736
+ | `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
737
+ | `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain` and `home`. |
738
+ | `className` | `string` | Classes for placing it, on the outer element. |
739
+
740
+ The error itself is never shown.
741
+
742
+ ### `NotFoundState`
743
+
744
+ From `@sjolystinnovation/app-kit/ui`. What a `not-found.tsx` shows: a large code, the message as
745
+ the page's heading, and a link home. It works in a server component.
746
+
747
+ ```tsx
748
+ <NotFoundState homeHref="/no" strings={{ body: dict.notFound, home: dict.toFrontPage }} />
749
+ ```
750
+
751
+ | Prop | Type | Description |
752
+ | --- | --- | --- |
753
+ | `homeHref` | `string` | Where the home link goes. |
754
+ | `strings` | `TextOverrides<NotFoundStrings>` | `code`, `body` and `home`. |
755
+ | `className` | `string` | Classes for placing it, on the outer element. |
756
+
663
757
  ### `PasswordInput`
664
758
 
665
759
  A labelled password field with a button that shows and hides the value. It also takes the other
@@ -767,6 +861,16 @@ selector such as `.dark` for a scoped theme:
767
861
  | `--color-dialog-bg` | white |
768
862
  | `--color-dialog-title` | gray 900 |
769
863
  | `--color-dialog-text` | gray 600 |
864
+ | `--color-state-heading` | gray 900, the heading of an `ErrorState` |
865
+ | `--color-state-text` | gray 600, the text of an `ErrorState` or `NotFoundState` |
866
+ | `--color-state-code` | gray 900, the code of a `NotFoundState` |
867
+ | `--font-state-code` | `inherit`, the font of that code |
868
+ | `--color-state-link`, `-hover` | gray 900, the home link |
869
+ | `--color-markdown-heading`, `-strong` | gray 900, headings and bold text in `Markdown` |
870
+ | `--color-markdown-link` | gray 900 |
871
+ | `--color-markdown-link-hover` | gray 600 |
872
+ | `--color-markdown-border` | gray 200, rules, quotes and table lines |
873
+ | `--color-markdown-code-bg`, `-code-text` | gray 100, gray 900 |
770
874
 
771
875
  A default that points at another variable is read once for the whole page. The focus ring and the
772
876
  primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -26,6 +26,7 @@
26
26
  "./toast": "./src/toast/index.ts",
27
27
  "./validation": "./src/validation/index.ts",
28
28
  "./next-config": "./src/next-config/index.ts",
29
+ "./markdown": "./src/markdown/index.ts",
29
30
  "./styles.css": "./src/styles.css"
30
31
  },
31
32
  "files": [
@@ -46,6 +47,9 @@
46
47
  "next-auth": "^4.24",
47
48
  "react": "^19",
48
49
  "react-dom": "^19",
50
+ "react-markdown": "^10",
51
+ "rehype-sanitize": "^6",
52
+ "remark-gfm": "^4",
49
53
  "sonner": "^2",
50
54
  "zod": "^4"
51
55
  },
@@ -55,6 +59,15 @@
55
59
  },
56
60
  "zod": {
57
61
  "optional": true
62
+ },
63
+ "react-markdown": {
64
+ "optional": true
65
+ },
66
+ "rehype-sanitize": {
67
+ "optional": true
68
+ },
69
+ "remark-gfm": {
70
+ "optional": true
58
71
  }
59
72
  },
60
73
  "devDependencies": {
@@ -71,6 +84,9 @@
71
84
  "next-auth": "^4.24.15",
72
85
  "react": "^19.3.0",
73
86
  "react-dom": "^19.3.0",
87
+ "react-markdown": "^10.1.0",
88
+ "rehype-sanitize": "^6.0.0",
89
+ "remark-gfm": "^4.0.1",
74
90
  "sonner": "^2.0.8",
75
91
  "typescript": "^6.0.3",
76
92
  "vitest": "^5.0.3",
@@ -85,7 +101,7 @@
85
101
  "tailwindcss"
86
102
  ],
87
103
  "dependencies": {
88
- "@types/jsonwebtoken": "^9.0.8",
89
- "jsonwebtoken": "^9.0.2"
104
+ "@types/jsonwebtoken": "^9.0.10",
105
+ "jsonwebtoken": "^9.0.3"
90
106
  }
91
107
  }
@@ -0,0 +1,89 @@
1
+ import type { ComponentPropsWithoutRef, ElementType } from "react";
2
+ import ReactMarkdown, { type Components, type ExtraProps } from "react-markdown";
3
+ import rehypeSanitize from "rehype-sanitize";
4
+ import remarkGfm from "remark-gfm";
5
+
6
+ type Tag = keyof React.JSX.IntrinsicElements;
7
+
8
+ /** The props without the `node` react-markdown passes, which is no DOM attribute. */
9
+ function domProps<P extends ExtraProps>(props: P): Omit<P, "node"> {
10
+ const rest = { ...props };
11
+ delete rest.node;
12
+ return rest;
13
+ }
14
+
15
+ /** An app's element, given the props without `node`, so it can spread them onto a DOM element. */
16
+ function withoutNode(Element: ElementType) {
17
+ const Wrapped = (props: ExtraProps) => <Element {...domProps(props)} />;
18
+ return Wrapped;
19
+ }
20
+
21
+ /** An element with fixed classes. */
22
+ function styled<T extends Tag>(Element: T, className: string) {
23
+ const Styled = (props: ComponentPropsWithoutRef<T> & ExtraProps) => {
24
+ const Component = Element as ElementType;
25
+ return <Component className={className} {...domProps(props)} />;
26
+ };
27
+ return Styled;
28
+ }
29
+
30
+ /** The default look. A heading level one becomes a level two, since the page has its own `h1`. */
31
+ export const markdownComponents: Components = {
32
+ h1: styled("h2", "text-lg font-semibold text-markdown-heading"),
33
+ h2: styled("h2", "text-base font-semibold text-markdown-heading pt-2"),
34
+ h3: styled("h3", "text-sm font-semibold text-markdown-heading"),
35
+ p: styled("p", "leading-relaxed"),
36
+ ul: styled("ul", "list-disc list-outside pl-5 space-y-1"),
37
+ ol: styled("ol", "list-decimal list-outside pl-5 space-y-1"),
38
+ li: styled("li", "leading-relaxed"),
39
+ a: styled("a", "font-medium text-markdown-link underline hover:text-markdown-link-hover"),
40
+ strong: styled("strong", "font-semibold text-markdown-strong"),
41
+ hr: styled("hr", "border-markdown-border"),
42
+ blockquote: styled("blockquote", "border-l-2 border-markdown-border pl-4 italic"),
43
+ code: styled("code", "rounded bg-markdown-code-bg px-1 py-0.5 text-xs font-mono text-markdown-code-text"),
44
+ table: props => (
45
+ <div className="overflow-x-auto">
46
+ <table className="w-full text-xs border-collapse" {...domProps(props)} />
47
+ </div>
48
+ ),
49
+ thead: styled("thead", "border-b border-markdown-border"),
50
+ tbody: styled("tbody", "divide-y divide-markdown-border"),
51
+ th: styled("th", "text-left py-2 pr-4 font-semibold text-markdown-heading"),
52
+ td: styled("td", "py-2 pr-4 align-top"),
53
+ };
54
+
55
+ const isExternal = (href: string | undefined) => href != null && /^https?:\/\//i.test(href);
56
+
57
+ export interface MarkdownProps {
58
+ /** Markdown text. Empty, null or undefined renders nothing. */
59
+ children: string | null | undefined;
60
+ /** Replaces the default element for a tag, such as `{ p: MyParagraph }`. It gets the props without `node`. */
61
+ components?: Components | undefined;
62
+ /** Opens links to other sites in a new tab, without passing the opener or referrer. */
63
+ externalLinks?: boolean | undefined;
64
+ /** Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. */
65
+ className?: string | undefined;
66
+ }
67
+
68
+ /**
69
+ * Markdown from an editor or the API, with GitHub tables, lists and links. Raw HTML is never
70
+ * rendered, and the output is sanitized, so the text cannot inject scripts, styles or event handlers.
71
+ */
72
+ export function Markdown({ children, components, externalLinks = false, className }: MarkdownProps) {
73
+ if (!children) return null;
74
+
75
+ const own = Object.entries(components ?? {}).map(([tag, Element]) => [tag, withoutNode(Element as ElementType)]);
76
+ const merged: Components = { ...markdownComponents, ...Object.fromEntries(own) };
77
+ if (externalLinks) {
78
+ const Link = (merged.a ?? "a") as ElementType;
79
+ merged.a = props =>
80
+ isExternal(props.href) ? <Link {...props} target="_blank" rel="noopener noreferrer" /> : <Link {...props} />;
81
+ }
82
+
83
+ const output = (
84
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]} components={merged}>
85
+ {children}
86
+ </ReactMarkdown>
87
+ );
88
+ return className === undefined ? output : <div className={className}>{output}</div>;
89
+ }
@@ -0,0 +1 @@
1
+ export { Markdown, markdownComponents, type MarkdownProps } from "./Markdown";
package/src/styles.css CHANGED
@@ -45,6 +45,21 @@
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
+
55
+ --color-markdown-heading: var(--color-gray-900);
56
+ --color-markdown-strong: var(--color-gray-900);
57
+ --color-markdown-link: var(--color-gray-900);
58
+ --color-markdown-link-hover: var(--color-gray-600);
59
+ --color-markdown-border: var(--color-gray-200);
60
+ --color-markdown-code-bg: var(--color-gray-100);
61
+ --color-markdown-code-text: var(--color-gray-900);
62
+
48
63
  --radius-button: var(--radius-lg);
49
64
  --color-button-primary-bg: var(--color-primary);
50
65
  --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";