@inshorts/glint-ui 0.3.2 → 0.5.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/package.json +28 -11
- package/src/alert.tsx +4 -4
- package/src/badge.tsx +4 -4
- package/src/confirm-dialog.tsx +22 -4
- package/src/field.tsx +25 -8
- package/src/index.ts +2 -0
- package/src/modal.tsx +7 -0
- package/src/panel-brand-mark.tsx +32 -0
- package/src/panel-sign-in.tsx +134 -0
- package/src/tabs.tsx +86 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inshorts/glint-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Glint Tailwind 4 UI kit for Inshorts Next panels.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -33,28 +33,45 @@
|
|
|
33
33
|
"url": "git+https://github.com/NewsInShorts/inshorts-npm-modules.git",
|
|
34
34
|
"directory": "glint/ui"
|
|
35
35
|
},
|
|
36
|
-
"scripts": {
|
|
37
|
-
"typecheck": "tsc --noEmit",
|
|
38
|
-
"prepublishOnly": "npm run typecheck"
|
|
39
|
-
},
|
|
40
36
|
"peerDependencies": {
|
|
37
|
+
"@radix-ui/react-slot": "^1.2.0",
|
|
38
|
+
"lucide-react": "^0.400.0 || ^1.0.0",
|
|
41
39
|
"react": "^19.3.0",
|
|
42
40
|
"react-dom": "^19.3.0"
|
|
43
41
|
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"lucide-react": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
},
|
|
44
47
|
"dependencies": {
|
|
45
|
-
"@
|
|
48
|
+
"@inshorts/glint-theme": "^0.5.0",
|
|
46
49
|
"class-variance-authority": "^0.7.1",
|
|
47
50
|
"clsx": "^2.1.1",
|
|
48
|
-
"
|
|
49
|
-
"tailwind-merge": "^3.7.0",
|
|
50
|
-
"@inshorts/glint-theme": "0.3.1"
|
|
51
|
+
"tailwind-merge": "^3.7.0"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
54
|
+
"@radix-ui/react-slot": "^1.3.3",
|
|
55
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
56
|
+
"@testing-library/react": "^16.3.3",
|
|
57
|
+
"@testing-library/user-event": "^14.6.8",
|
|
53
58
|
"@types/react": "^19.3.0",
|
|
54
59
|
"@types/react-dom": "^19.3.0",
|
|
55
|
-
"
|
|
60
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
61
|
+
"jsdom": "^24.1.3",
|
|
62
|
+
"lucide-react": "^1.49.0",
|
|
63
|
+
"react": "^19.3.0",
|
|
64
|
+
"react-dom": "^19.3.0",
|
|
65
|
+
"typescript": "^5.9.3",
|
|
66
|
+
"vite": "5.4.21",
|
|
67
|
+
"vitest": "^2.1.9",
|
|
68
|
+
"vitest-axe": "^0.1.0"
|
|
56
69
|
},
|
|
57
70
|
"publishConfig": {
|
|
58
71
|
"access": "public"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"typecheck": "tsc --noEmit",
|
|
75
|
+
"test": "vitest run"
|
|
59
76
|
}
|
|
60
|
-
}
|
|
77
|
+
}
|
package/src/alert.tsx
CHANGED
|
@@ -18,10 +18,10 @@ import { cn } from "./utils";
|
|
|
18
18
|
const alertVariants = cva("rounded-lg border px-3 py-2 text-sm", {
|
|
19
19
|
variants: {
|
|
20
20
|
variant: {
|
|
21
|
-
danger: "border-
|
|
22
|
-
warning: "border-
|
|
23
|
-
success: "border-
|
|
24
|
-
info: "border-
|
|
21
|
+
danger: "border-destructive/30 bg-destructive/5 text-destructive",
|
|
22
|
+
warning: "border-warning/30 bg-warning/5 text-warning",
|
|
23
|
+
success: "border-success/30 bg-success/5 text-success",
|
|
24
|
+
info: "border-info/30 bg-info/5 text-info",
|
|
25
25
|
/**
|
|
26
26
|
* The quiet state of a banner that also has loud ones — "no default
|
|
27
27
|
* campaign set" next to "this is the default". Without it those callers
|
package/src/badge.tsx
CHANGED
|
@@ -21,10 +21,10 @@ const badgeVariants = cva(
|
|
|
21
21
|
variants: {
|
|
22
22
|
variant: {
|
|
23
23
|
neutral: "bg-muted text-muted-foreground",
|
|
24
|
-
success: "bg-
|
|
25
|
-
warning: "bg-
|
|
26
|
-
danger: "bg-
|
|
27
|
-
info: "bg-
|
|
24
|
+
success: "bg-success/15 text-success",
|
|
25
|
+
warning: "bg-warning/15 text-warning",
|
|
26
|
+
danger: "bg-destructive/15 text-destructive",
|
|
27
|
+
info: "bg-info/15 text-info",
|
|
28
28
|
},
|
|
29
29
|
size: {
|
|
30
30
|
/** Inline chips — a category, a tenant, a permission. */
|
package/src/confirm-dialog.tsx
CHANGED
|
@@ -28,23 +28,41 @@ export function ConfirmDialog({
|
|
|
28
28
|
onConfirm,
|
|
29
29
|
onClose,
|
|
30
30
|
}: ConfirmDialogProps) {
|
|
31
|
+
const titleId = React.useId();
|
|
32
|
+
const descriptionId = React.useId();
|
|
33
|
+
const errorId = React.useId();
|
|
34
|
+
|
|
35
|
+
const describedBy = [
|
|
36
|
+
description ? descriptionId : null,
|
|
37
|
+
error ? errorId : null,
|
|
38
|
+
]
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.join(" ");
|
|
41
|
+
|
|
31
42
|
return (
|
|
32
43
|
<Modal
|
|
33
44
|
isOpen={isOpen}
|
|
34
45
|
role="alertdialog"
|
|
35
|
-
labelledBy=
|
|
46
|
+
labelledBy={titleId}
|
|
47
|
+
describedBy={describedBy || undefined}
|
|
36
48
|
closeLabel="Close dialog"
|
|
37
49
|
onClose={onClose}
|
|
38
50
|
>
|
|
39
|
-
<h3 id=
|
|
51
|
+
<h3 id={titleId} className="text-lg font-semibold">
|
|
40
52
|
{title}
|
|
41
53
|
</h3>
|
|
42
54
|
{description ? (
|
|
43
|
-
<div className="text-muted-foreground mt-2 text-sm">
|
|
55
|
+
<div id={descriptionId} className="text-muted-foreground mt-2 text-sm">
|
|
56
|
+
{description}
|
|
57
|
+
</div>
|
|
44
58
|
) : null}
|
|
45
59
|
|
|
46
60
|
{error ? (
|
|
47
|
-
<div
|
|
61
|
+
<div
|
|
62
|
+
id={errorId}
|
|
63
|
+
role="alert"
|
|
64
|
+
className="border-destructive/30 bg-destructive/5 text-destructive mt-4 rounded-lg border px-3 py-2 text-sm"
|
|
65
|
+
>
|
|
48
66
|
{error}
|
|
49
67
|
</div>
|
|
50
68
|
) : null}
|
package/src/field.tsx
CHANGED
|
@@ -5,6 +5,12 @@ import { cn } from "./utils";
|
|
|
5
5
|
interface FieldProps {
|
|
6
6
|
label: React.ReactNode;
|
|
7
7
|
required?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Associates the label with a control via `htmlFor` / `id`. Prefer this for
|
|
10
|
+
* native inputs rendered as siblings (not wrapped). For composite controls
|
|
11
|
+
* (Autocomplete), omit and use `as="label"` wrapping or an `aria-label`.
|
|
12
|
+
*/
|
|
13
|
+
htmlFor?: string;
|
|
8
14
|
/**
|
|
9
15
|
* Render as a `<label>` (wraps a native control so clicking the text focuses
|
|
10
16
|
* it) or a `<div>` (for composite controls like Autocomplete that manage
|
|
@@ -18,21 +24,32 @@ interface FieldProps {
|
|
|
18
24
|
export function Field({
|
|
19
25
|
label,
|
|
20
26
|
required,
|
|
27
|
+
htmlFor,
|
|
21
28
|
as = "div",
|
|
22
29
|
className,
|
|
23
30
|
children,
|
|
24
31
|
}: FieldProps) {
|
|
25
32
|
const Wrapper = as;
|
|
33
|
+
const labelNode = (
|
|
34
|
+
<>
|
|
35
|
+
{label}
|
|
36
|
+
{required ? (
|
|
37
|
+
<span className="text-destructive ml-0.5" aria-hidden="true">
|
|
38
|
+
*
|
|
39
|
+
</span>
|
|
40
|
+
) : null}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
|
|
26
44
|
return (
|
|
27
45
|
<Wrapper className={cn("space-y-1 text-sm", className)}>
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
</span>
|
|
46
|
+
{htmlFor && as === "div" ? (
|
|
47
|
+
<label htmlFor={htmlFor} className="text-muted-foreground">
|
|
48
|
+
{labelNode}
|
|
49
|
+
</label>
|
|
50
|
+
) : (
|
|
51
|
+
<span className="text-muted-foreground">{labelNode}</span>
|
|
52
|
+
)}
|
|
36
53
|
{children}
|
|
37
54
|
</Wrapper>
|
|
38
55
|
);
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ export * from "./input";
|
|
|
20
20
|
export * from "./label";
|
|
21
21
|
export * from "./modal";
|
|
22
22
|
export * from "./page-header";
|
|
23
|
+
export * from "./panel-brand-mark";
|
|
24
|
+
export * from "./panel-sign-in";
|
|
23
25
|
export * from "./pagination";
|
|
24
26
|
export * from "./segmented-control";
|
|
25
27
|
export * from "./select";
|
package/src/modal.tsx
CHANGED
|
@@ -25,6 +25,11 @@ interface ModalProps {
|
|
|
25
25
|
isOpen: boolean;
|
|
26
26
|
/** id of the element labelling the dialog; wired to aria-labelledby. */
|
|
27
27
|
labelledBy: string;
|
|
28
|
+
/**
|
|
29
|
+
* Optional id(s) of elements describing the dialog (APG alertdialog body).
|
|
30
|
+
* Space-separated when multiple.
|
|
31
|
+
*/
|
|
32
|
+
describedBy?: string;
|
|
28
33
|
/** Accessible label for the backdrop dismiss button. */
|
|
29
34
|
closeLabel: string;
|
|
30
35
|
onClose: () => void;
|
|
@@ -48,6 +53,7 @@ interface ModalProps {
|
|
|
48
53
|
export function Modal({
|
|
49
54
|
isOpen,
|
|
50
55
|
labelledBy,
|
|
56
|
+
describedBy,
|
|
51
57
|
closeLabel,
|
|
52
58
|
onClose,
|
|
53
59
|
children,
|
|
@@ -96,6 +102,7 @@ export function Modal({
|
|
|
96
102
|
role={role}
|
|
97
103
|
aria-modal="true"
|
|
98
104
|
aria-labelledby={labelledBy}
|
|
105
|
+
aria-describedby={describedBy}
|
|
99
106
|
tabIndex={-1}
|
|
100
107
|
className={cn(
|
|
101
108
|
"border-border bg-background relative z-10 my-auto w-full rounded-lg border shadow-2xl outline-none",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./utils";
|
|
4
|
+
|
|
5
|
+
export type PanelBrandMarkProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
6
|
+
/** Icon inside the mark (lucide, etc.). */
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
size?: "sm" | "md";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Quiet brand tile for sidebar / sign-in — solid primary, no decorative gradients.
|
|
13
|
+
*/
|
|
14
|
+
export function PanelBrandMark({
|
|
15
|
+
children,
|
|
16
|
+
size = "md",
|
|
17
|
+
className,
|
|
18
|
+
...props
|
|
19
|
+
}: PanelBrandMarkProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
className={cn(
|
|
23
|
+
"bg-primary text-primary-foreground grid shrink-0 place-items-center rounded-lg",
|
|
24
|
+
size === "sm" ? "h-9 w-9" : "h-10 w-10",
|
|
25
|
+
className,
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Loader2 } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { PanelBrandMark } from "./panel-brand-mark";
|
|
8
|
+
import { cn } from "./utils";
|
|
9
|
+
|
|
10
|
+
export type PanelSignInProps = {
|
|
11
|
+
appName: string;
|
|
12
|
+
/** Short line under the brand (e.g. "Secure Google sign-in"). */
|
|
13
|
+
eyebrow?: string;
|
|
14
|
+
/** One supporting sentence under the headline. */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Optional feature bullets — keep to ≤3; DESIGN.md: one job per surface. */
|
|
17
|
+
features?: string[];
|
|
18
|
+
brandIcon?: React.ReactNode;
|
|
19
|
+
error?: string | null;
|
|
20
|
+
/** Google signed in but panel access refused. */
|
|
21
|
+
refused?: boolean;
|
|
22
|
+
refusedTitle?: string;
|
|
23
|
+
refusedMessage?: string;
|
|
24
|
+
isSessionLoading?: boolean;
|
|
25
|
+
isSigningIn?: boolean;
|
|
26
|
+
onSignIn: () => void;
|
|
27
|
+
signInLabel?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* DESIGN.md-compliant panel login: brand-first, ink neutrals, no purple glow.
|
|
33
|
+
*/
|
|
34
|
+
export function PanelSignIn({
|
|
35
|
+
appName,
|
|
36
|
+
eyebrow = "Secure Google sign-in",
|
|
37
|
+
description = "Use your work Google account to continue.",
|
|
38
|
+
features,
|
|
39
|
+
brandIcon,
|
|
40
|
+
error,
|
|
41
|
+
refused = false,
|
|
42
|
+
refusedTitle = "Access denied",
|
|
43
|
+
refusedMessage = "This Google account is signed in, but it is not provisioned for this panel.",
|
|
44
|
+
isSessionLoading = false,
|
|
45
|
+
isSigningIn = false,
|
|
46
|
+
onSignIn,
|
|
47
|
+
signInLabel = "Continue with Google",
|
|
48
|
+
className,
|
|
49
|
+
}: PanelSignInProps) {
|
|
50
|
+
const busy = isSessionLoading || isSigningIn;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<main
|
|
54
|
+
className={cn(
|
|
55
|
+
"bg-background text-foreground grid min-h-screen place-items-center p-4 sm:p-8",
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<section className="border-border bg-card w-full max-w-md space-y-6 rounded-lg border p-8 shadow-sm">
|
|
60
|
+
<div className="flex items-center gap-3">
|
|
61
|
+
<PanelBrandMark>
|
|
62
|
+
{brandIcon ?? (
|
|
63
|
+
<span className="text-sm font-semibold" aria-hidden>
|
|
64
|
+
{appName.slice(0, 1).toUpperCase()}
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</PanelBrandMark>
|
|
68
|
+
<div className="min-w-0">
|
|
69
|
+
<p className="text-title truncate font-semibold">{appName}</p>
|
|
70
|
+
<p className="text-muted-foreground text-meta">{eyebrow}</p>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div className="space-y-2">
|
|
75
|
+
<h1 className="text-display font-semibold tracking-tight">
|
|
76
|
+
{refused ? refusedTitle : "Sign in"}
|
|
77
|
+
</h1>
|
|
78
|
+
<p className="text-muted-foreground text-body">
|
|
79
|
+
{refused ? refusedMessage : description}
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{features && features.length > 0 && !refused ? (
|
|
84
|
+
<ul className="text-muted-foreground text-body space-y-1.5">
|
|
85
|
+
{features.slice(0, 3).map((feature) => (
|
|
86
|
+
<li key={feature} className="flex gap-2">
|
|
87
|
+
<span className="text-foreground mt-1.5 h-1 w-1 shrink-0 rounded-full bg-current" />
|
|
88
|
+
<span>{feature}</span>
|
|
89
|
+
</li>
|
|
90
|
+
))}
|
|
91
|
+
</ul>
|
|
92
|
+
) : null}
|
|
93
|
+
|
|
94
|
+
{error ? (
|
|
95
|
+
<p
|
|
96
|
+
role="alert"
|
|
97
|
+
className="border-destructive/30 bg-destructive/5 text-destructive rounded-md border px-3 py-2 text-sm"
|
|
98
|
+
>
|
|
99
|
+
{error}
|
|
100
|
+
</p>
|
|
101
|
+
) : null}
|
|
102
|
+
|
|
103
|
+
<Button
|
|
104
|
+
className="w-full"
|
|
105
|
+
disabled={busy}
|
|
106
|
+
onClick={onSignIn}
|
|
107
|
+
aria-busy={busy}
|
|
108
|
+
>
|
|
109
|
+
{busy ? (
|
|
110
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
|
111
|
+
) : null}
|
|
112
|
+
{isSigningIn ? "Redirecting…" : signInLabel}
|
|
113
|
+
</Button>
|
|
114
|
+
</section>
|
|
115
|
+
</main>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function PanelSignInSkeleton({ className }: { className?: string }) {
|
|
120
|
+
return (
|
|
121
|
+
<main
|
|
122
|
+
className={cn(
|
|
123
|
+
"bg-background grid min-h-screen place-items-center p-4 sm:p-8",
|
|
124
|
+
className,
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
<div className="border-border bg-card w-full max-w-md space-y-4 rounded-lg border p-8 shadow-sm">
|
|
128
|
+
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
|
|
129
|
+
<div className="bg-muted h-8 w-48 animate-pulse rounded" />
|
|
130
|
+
<div className="bg-muted mt-7 h-10 w-full animate-pulse rounded-md" />
|
|
131
|
+
</div>
|
|
132
|
+
</main>
|
|
133
|
+
);
|
|
134
|
+
}
|
package/src/tabs.tsx
CHANGED
|
@@ -17,6 +17,10 @@ import { cn } from "./utils";
|
|
|
17
17
|
* about how many tabs there are or which is selected; `tablist` / `tab` /
|
|
18
18
|
* `aria-selected` do. Arrow-key navigation comes with that expectation, so it
|
|
19
19
|
* is implemented here too.
|
|
20
|
+
*
|
|
21
|
+
* Optional `panel` on a tab (or a sibling `TabPanel`) wires APG `aria-controls`
|
|
22
|
+
* / `role="tabpanel"`. Callers that already render content from `value` can
|
|
23
|
+
* keep doing so and pass `panelId` for association, or switch to `panel`.
|
|
20
24
|
*/
|
|
21
25
|
export interface TabItem<Key extends string = string> {
|
|
22
26
|
key: Key;
|
|
@@ -24,6 +28,16 @@ export interface TabItem<Key extends string = string> {
|
|
|
24
28
|
/** Rendered after the label — a count, usually. */
|
|
25
29
|
badge?: React.ReactNode;
|
|
26
30
|
disabled?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Optional panel body. When any tab provides `panel`, panels render under
|
|
33
|
+
* the strip with `role="tabpanel"`.
|
|
34
|
+
*/
|
|
35
|
+
panel?: React.ReactNode;
|
|
36
|
+
/**
|
|
37
|
+
* External panel element id for `aria-controls` when panels live outside
|
|
38
|
+
* this component.
|
|
39
|
+
*/
|
|
40
|
+
panelId?: string;
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
export interface TabsProps<Key extends string = string> {
|
|
@@ -35,6 +49,37 @@ export interface TabsProps<Key extends string = string> {
|
|
|
35
49
|
className?: string;
|
|
36
50
|
}
|
|
37
51
|
|
|
52
|
+
export interface TabPanelProps {
|
|
53
|
+
/** Must match the controlling tab's generated or explicit id. */
|
|
54
|
+
id: string;
|
|
55
|
+
/** Id of the tab that labels this panel. */
|
|
56
|
+
labelledBy: string;
|
|
57
|
+
active: boolean;
|
|
58
|
+
children: React.ReactNode;
|
|
59
|
+
className?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Companion panel for callers that keep content outside `Tabs`. */
|
|
63
|
+
export function TabPanel({
|
|
64
|
+
id,
|
|
65
|
+
labelledBy,
|
|
66
|
+
active,
|
|
67
|
+
children,
|
|
68
|
+
className,
|
|
69
|
+
}: TabPanelProps) {
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
role="tabpanel"
|
|
73
|
+
id={id}
|
|
74
|
+
aria-labelledby={labelledBy}
|
|
75
|
+
hidden={!active}
|
|
76
|
+
className={className}
|
|
77
|
+
>
|
|
78
|
+
{active ? children : null}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
38
83
|
export function Tabs<Key extends string = string>({
|
|
39
84
|
tabs,
|
|
40
85
|
value,
|
|
@@ -43,6 +88,16 @@ export function Tabs<Key extends string = string>({
|
|
|
43
88
|
className,
|
|
44
89
|
}: TabsProps<Key>) {
|
|
45
90
|
const refs = React.useRef(new Map<Key, HTMLButtonElement | null>());
|
|
91
|
+
const reactId = React.useId();
|
|
92
|
+
const hasInlinePanels = tabs.some((tab) => tab.panel != null);
|
|
93
|
+
|
|
94
|
+
function tabDomId(key: Key) {
|
|
95
|
+
return `${reactId}-tab-${key}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function panelDomId(tab: TabItem<Key>) {
|
|
99
|
+
return tab.panelId ?? `${reactId}-panel-${tab.key}`;
|
|
100
|
+
}
|
|
46
101
|
|
|
47
102
|
function focusTab(key: Key) {
|
|
48
103
|
onChange(key);
|
|
@@ -68,24 +123,32 @@ export function Tabs<Key extends string = string>({
|
|
|
68
123
|
focusTab(next.key);
|
|
69
124
|
}
|
|
70
125
|
|
|
71
|
-
|
|
126
|
+
const tablist = (
|
|
72
127
|
<div
|
|
73
128
|
role="tablist"
|
|
74
129
|
aria-label={ariaLabel}
|
|
75
130
|
onKeyDown={onKeyDown}
|
|
76
|
-
className={cn(
|
|
131
|
+
className={cn(
|
|
132
|
+
"border-border flex flex-wrap gap-1 border-b",
|
|
133
|
+
!hasInlinePanels ? className : undefined,
|
|
134
|
+
)}
|
|
77
135
|
>
|
|
78
136
|
{tabs.map((tab) => {
|
|
79
137
|
const active = tab.key === value;
|
|
138
|
+
const id = tabDomId(tab.key);
|
|
139
|
+
const controls =
|
|
140
|
+
tab.panel != null || tab.panelId ? panelDomId(tab) : undefined;
|
|
80
141
|
return (
|
|
81
142
|
<button
|
|
82
143
|
key={tab.key}
|
|
144
|
+
id={id}
|
|
83
145
|
ref={(node) => {
|
|
84
146
|
refs.current.set(tab.key, node);
|
|
85
147
|
}}
|
|
86
148
|
type="button"
|
|
87
149
|
role="tab"
|
|
88
150
|
aria-selected={active}
|
|
151
|
+
aria-controls={controls}
|
|
89
152
|
disabled={tab.disabled}
|
|
90
153
|
// Only the active tab is reachable by Tab; arrows move within the
|
|
91
154
|
// strip. Without this a keyboard user pages through every tab to
|
|
@@ -100,7 +163,7 @@ export function Tabs<Key extends string = string>({
|
|
|
100
163
|
// already used. Adopting it keeps this a deduplication rather
|
|
101
164
|
// than a restyle of every panel that has tabs.
|
|
102
165
|
active
|
|
103
|
-
? "bg-
|
|
166
|
+
? "bg-primary text-primary-foreground"
|
|
104
167
|
: "text-muted-foreground hover:bg-muted",
|
|
105
168
|
)}
|
|
106
169
|
>
|
|
@@ -115,4 +178,24 @@ export function Tabs<Key extends string = string>({
|
|
|
115
178
|
})}
|
|
116
179
|
</div>
|
|
117
180
|
);
|
|
181
|
+
|
|
182
|
+
if (!hasInlinePanels) return tablist;
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className={cn("space-y-3", className)}>
|
|
186
|
+
{tablist}
|
|
187
|
+
{tabs.map((tab) =>
|
|
188
|
+
tab.panel != null ? (
|
|
189
|
+
<TabPanel
|
|
190
|
+
key={tab.key}
|
|
191
|
+
id={panelDomId(tab)}
|
|
192
|
+
labelledBy={tabDomId(tab.key)}
|
|
193
|
+
active={tab.key === value}
|
|
194
|
+
>
|
|
195
|
+
{tab.panel}
|
|
196
|
+
</TabPanel>
|
|
197
|
+
) : null,
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
118
201
|
}
|