@inshorts/glint-ui 0.3.2 → 0.5.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshorts/glint-ui",
3
- "version": "0.3.2",
3
+ "version": "0.5.1",
4
4
  "description": "Glint Tailwind 4 UI kit for Inshorts Next panels.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -35,24 +35,42 @@
35
35
  },
36
36
  "scripts": {
37
37
  "typecheck": "tsc --noEmit",
38
- "prepublishOnly": "npm run typecheck"
38
+ "test": "vitest run",
39
+ "prepublishOnly": "npm run typecheck && npm test"
39
40
  },
40
41
  "peerDependencies": {
42
+ "@radix-ui/react-slot": "^1.2.0",
43
+ "lucide-react": "^0.400.0 || ^1.0.0",
41
44
  "react": "^19.3.0",
42
45
  "react-dom": "^19.3.0"
43
46
  },
47
+ "peerDependenciesMeta": {
48
+ "lucide-react": {
49
+ "optional": true
50
+ }
51
+ },
44
52
  "dependencies": {
45
- "@radix-ui/react-slot": "^1.3.3",
53
+ "@inshorts/glint-theme": "^0.5.1",
46
54
  "class-variance-authority": "^0.7.1",
47
55
  "clsx": "^2.1.1",
48
- "lucide-react": "^1.49.0",
49
- "tailwind-merge": "^3.7.0",
50
- "@inshorts/glint-theme": "0.3.1"
56
+ "tailwind-merge": "^3.7.0"
51
57
  },
52
58
  "devDependencies": {
59
+ "@radix-ui/react-slot": "^1.3.3",
60
+ "@testing-library/jest-dom": "^7.0.1",
61
+ "@testing-library/react": "^16.3.3",
62
+ "@testing-library/user-event": "^14.6.8",
53
63
  "@types/react": "^19.3.0",
54
64
  "@types/react-dom": "^19.3.0",
55
- "typescript": "^5.9.3"
65
+ "@vitejs/plugin-react": "^4.7.0",
66
+ "jsdom": "^24.1.3",
67
+ "lucide-react": "^1.49.0",
68
+ "react": "^19.3.0",
69
+ "react-dom": "^19.3.0",
70
+ "typescript": "^5.9.3",
71
+ "vite": "5.4.21",
72
+ "vitest": "^2.1.9",
73
+ "vitest-axe": "^0.1.0"
56
74
  },
57
75
  "publishConfig": {
58
76
  "access": "public"
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-rose-200 bg-rose-50 text-rose-700",
22
- warning: "border-amber-200 bg-amber-50 text-amber-800",
23
- success: "border-emerald-200 bg-emerald-50 text-emerald-800",
24
- info: "border-sky-200 bg-sky-50 text-sky-800",
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-emerald-100 text-emerald-800",
25
- warning: "bg-amber-100 text-amber-800",
26
- danger: "bg-rose-100 text-rose-800",
27
- info: "bg-sky-100 text-sky-900",
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. */
@@ -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="confirm-dialog-title"
46
+ labelledBy={titleId}
47
+ describedBy={describedBy || undefined}
36
48
  closeLabel="Close dialog"
37
49
  onClose={onClose}
38
50
  >
39
- <h3 id="confirm-dialog-title" className="text-lg font-semibold">
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">{description}</div>
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 className="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
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
- <span className="text-muted-foreground">
29
- {label}
30
- {required ? (
31
- <span className="text-destructive ml-0.5" aria-hidden="true">
32
- *
33
- </span>
34
- ) : null}
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
- return (
126
+ const tablist = (
72
127
  <div
73
128
  role="tablist"
74
129
  aria-label={ariaLabel}
75
130
  onKeyDown={onKeyDown}
76
- className={cn("border-border flex flex-wrap gap-1 border-b", className)}
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-slate-950 text-white dark:bg-white dark:text-slate-950"
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
  }