@inshorts/glint-ui 0.3.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 +60 -0
- package/package.json +56 -0
- package/src/alert.tsx +74 -0
- package/src/autocomplete.tsx +666 -0
- package/src/badge.tsx +60 -0
- package/src/breadcrumb.tsx +71 -0
- package/src/button.tsx +113 -0
- package/src/card.tsx +79 -0
- package/src/checkbox.tsx +42 -0
- package/src/clipboard.ts +144 -0
- package/src/collapsible.tsx +63 -0
- package/src/confirm-dialog.tsx +66 -0
- package/src/copy-button.tsx +58 -0
- package/src/data-table.tsx +190 -0
- package/src/date-range-picker/dateRange.ts +142 -0
- package/src/date-range-picker/index.tsx +366 -0
- package/src/dialogLayer.ts +142 -0
- package/src/feature-access-gate.tsx +38 -0
- package/src/field.tsx +39 -0
- package/src/image-uploader.tsx +181 -0
- package/src/index.ts +35 -0
- package/src/input.tsx +16 -0
- package/src/label.tsx +23 -0
- package/src/modal.tsx +110 -0
- package/src/page-header.tsx +75 -0
- package/src/pagination.tsx +116 -0
- package/src/segmented-control.tsx +155 -0
- package/src/select.tsx +105 -0
- package/src/separator.tsx +32 -0
- package/src/side-drawer.tsx +87 -0
- package/src/spinner.tsx +38 -0
- package/src/spreadsheet-grid.tsx +264 -0
- package/src/state-panel.tsx +46 -0
- package/src/switch.tsx +46 -0
- package/src/table.tsx +110 -0
- package/src/tabs.tsx +118 -0
- package/src/textarea.tsx +32 -0
- package/src/utils.ts +6 -0
- package/theme.css +2 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
|
|
8
|
+
export type PaginationProps = {
|
|
9
|
+
currentPage: number;
|
|
10
|
+
totalPages: number;
|
|
11
|
+
onPageChange: (page: number) => void;
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
showPageInfo?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Numbered pager for list panels. Hides itself when there is only one page.
|
|
18
|
+
*/
|
|
19
|
+
export function Pagination({
|
|
20
|
+
currentPage,
|
|
21
|
+
totalPages,
|
|
22
|
+
onPageChange,
|
|
23
|
+
loading = false,
|
|
24
|
+
showPageInfo = true,
|
|
25
|
+
}: PaginationProps) {
|
|
26
|
+
const visiblePages = React.useMemo(() => {
|
|
27
|
+
const delta = 2;
|
|
28
|
+
const range: number[] = [];
|
|
29
|
+
const rangeWithDots: Array<number | "..."> = [];
|
|
30
|
+
|
|
31
|
+
for (
|
|
32
|
+
let i = Math.max(2, currentPage - delta);
|
|
33
|
+
i <= Math.min(totalPages - 1, currentPage + delta);
|
|
34
|
+
i++
|
|
35
|
+
) {
|
|
36
|
+
range.push(i);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (currentPage - delta > 2) {
|
|
40
|
+
rangeWithDots.push(1, "...");
|
|
41
|
+
} else {
|
|
42
|
+
rangeWithDots.push(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
rangeWithDots.push(...range);
|
|
46
|
+
|
|
47
|
+
if (currentPage + delta < totalPages - 1) {
|
|
48
|
+
rangeWithDots.push("...", totalPages);
|
|
49
|
+
} else if (totalPages > 1) {
|
|
50
|
+
rangeWithDots.push(totalPages);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return rangeWithDots;
|
|
54
|
+
}, [currentPage, totalPages]);
|
|
55
|
+
|
|
56
|
+
if (totalPages <= 1) return null;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
|
60
|
+
{showPageInfo ? (
|
|
61
|
+
<div className="text-muted-foreground order-2 text-sm sm:order-1">
|
|
62
|
+
Page {currentPage} of {totalPages}
|
|
63
|
+
</div>
|
|
64
|
+
) : null}
|
|
65
|
+
|
|
66
|
+
<div className="order-1 flex items-center space-x-1 sm:order-2 sm:space-x-2">
|
|
67
|
+
<Button
|
|
68
|
+
variant="outline"
|
|
69
|
+
size="sm"
|
|
70
|
+
onClick={() => onPageChange(currentPage - 1)}
|
|
71
|
+
disabled={currentPage <= 1 || loading}
|
|
72
|
+
className="h-8 min-w-8 px-2"
|
|
73
|
+
aria-label="Previous page"
|
|
74
|
+
>
|
|
75
|
+
<ChevronLeft className="h-4 w-4" />
|
|
76
|
+
</Button>
|
|
77
|
+
|
|
78
|
+
<div className="flex max-w-[200px] items-center space-x-1 overflow-x-auto sm:max-w-none">
|
|
79
|
+
{visiblePages.map((page, index) =>
|
|
80
|
+
page === "..." ? (
|
|
81
|
+
<div
|
|
82
|
+
key={`dots-${index}`}
|
|
83
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center"
|
|
84
|
+
>
|
|
85
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
86
|
+
</div>
|
|
87
|
+
) : (
|
|
88
|
+
<Button
|
|
89
|
+
key={page}
|
|
90
|
+
variant={page === currentPage ? "default" : "outline"}
|
|
91
|
+
size="sm"
|
|
92
|
+
onClick={() => onPageChange(page)}
|
|
93
|
+
disabled={loading}
|
|
94
|
+
className="h-8 min-w-8 shrink-0 px-2"
|
|
95
|
+
aria-current={page === currentPage ? "page" : undefined}
|
|
96
|
+
>
|
|
97
|
+
{page}
|
|
98
|
+
</Button>
|
|
99
|
+
),
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<Button
|
|
104
|
+
variant="outline"
|
|
105
|
+
size="sm"
|
|
106
|
+
onClick={() => onPageChange(currentPage + 1)}
|
|
107
|
+
disabled={currentPage >= totalPages || loading}
|
|
108
|
+
className="h-8 min-w-8 px-2"
|
|
109
|
+
aria-label="Next page"
|
|
110
|
+
>
|
|
111
|
+
<ChevronRight className="h-4 w-4" />
|
|
112
|
+
</Button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "./utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one-of-N picker seven panels were each drawing by hand.
|
|
9
|
+
*
|
|
10
|
+
* Not `Tabs`, which is a bottom-bordered strip sitting above the content it
|
|
11
|
+
* switches. This is the inline version — a bordered track with the selected
|
|
12
|
+
* option filled in — used for a filter or a setting that sits among other
|
|
13
|
+
* controls rather than heading a panel.
|
|
14
|
+
*
|
|
15
|
+
* **Roles are the reason to use this.** The seven copies had three different
|
|
16
|
+
* ideas about what they were: one said `tablist`/`tab`, one said
|
|
17
|
+
* `group`/`aria-pressed`, and the other five said nothing at all — so on five
|
|
18
|
+
* of seven a screen reader heard a row of unlabelled buttons with no
|
|
19
|
+
* indication which one was active. None of the three was right. `tablist`
|
|
20
|
+
* without a `tabpanel` is a promise the markup does not keep, and
|
|
21
|
+
* `aria-pressed` describes independent toggles, not a choice of one. Picking a
|
|
22
|
+
* value out of a fixed set is a radio group, so that is what this is, with the
|
|
23
|
+
* roving tabindex and arrow keys that come with the role.
|
|
24
|
+
*
|
|
25
|
+
* `tone` keeps both active colours the panels had drifted into: `emphasis` is
|
|
26
|
+
* the near-black/near-white fill five of them used, `primary` the violet
|
|
27
|
+
* accent the other two did. They are genuinely different colours rather than
|
|
28
|
+
* one spelled two ways, so collapsing them is a visual change to two panels
|
|
29
|
+
* and belongs in its own reviewable diff — the same call `DataTable` makes
|
|
30
|
+
* about its two header variants.
|
|
31
|
+
*/
|
|
32
|
+
export interface SegmentedOption<Value extends string> {
|
|
33
|
+
value: Value;
|
|
34
|
+
label: React.ReactNode;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Hover text. Worth setting on a disabled option — "Everyone" going grey
|
|
38
|
+
* says nothing about which permission would bring it back.
|
|
39
|
+
*/
|
|
40
|
+
title?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SegmentedControlProps<Value extends string> {
|
|
44
|
+
options: ReadonlyArray<SegmentedOption<Value>>;
|
|
45
|
+
value: Value;
|
|
46
|
+
onChange: (value: Value) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Required: the group never has a visible label — the options name the
|
|
49
|
+
* choice between them, which tells you nothing about what is being chosen.
|
|
50
|
+
*/
|
|
51
|
+
ariaLabel: string;
|
|
52
|
+
/**
|
|
53
|
+
* `contained` — one bordered track around the options (five panels).
|
|
54
|
+
* `loose` — free-standing pills with a gap between them (two panels).
|
|
55
|
+
*/
|
|
56
|
+
variant?: "contained" | "loose";
|
|
57
|
+
/** See the note above: two real colours, not one spelled twice. */
|
|
58
|
+
tone?: "emphasis" | "primary";
|
|
59
|
+
/** Options share the width equally, for a control that fills its column. */
|
|
60
|
+
fill?: boolean;
|
|
61
|
+
className?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const TONES = {
|
|
65
|
+
emphasis: {
|
|
66
|
+
on: "bg-slate-950 text-white dark:bg-white dark:text-slate-950",
|
|
67
|
+
off: "text-muted-foreground hover:bg-muted",
|
|
68
|
+
},
|
|
69
|
+
primary: {
|
|
70
|
+
on: "bg-primary text-primary-foreground",
|
|
71
|
+
off: "text-muted-foreground hover:bg-muted",
|
|
72
|
+
},
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
const TRACKS = {
|
|
76
|
+
contained: "border-border inline-flex rounded-md border p-0.5",
|
|
77
|
+
loose: "flex flex-wrap gap-2",
|
|
78
|
+
} as const;
|
|
79
|
+
|
|
80
|
+
export function SegmentedControl<Value extends string>({
|
|
81
|
+
options,
|
|
82
|
+
value,
|
|
83
|
+
onChange,
|
|
84
|
+
ariaLabel,
|
|
85
|
+
variant = "contained",
|
|
86
|
+
tone = "emphasis",
|
|
87
|
+
fill = false,
|
|
88
|
+
className,
|
|
89
|
+
}: SegmentedControlProps<Value>) {
|
|
90
|
+
const refs = React.useRef(new Map<Value, HTMLButtonElement | null>());
|
|
91
|
+
|
|
92
|
+
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
|
93
|
+
const step =
|
|
94
|
+
event.key === "ArrowRight" || event.key === "ArrowDown"
|
|
95
|
+
? 1
|
|
96
|
+
: event.key === "ArrowLeft" || event.key === "ArrowUp"
|
|
97
|
+
? -1
|
|
98
|
+
: 0;
|
|
99
|
+
if (step === 0) return;
|
|
100
|
+
|
|
101
|
+
const selectable = options.filter((option) => !option.disabled);
|
|
102
|
+
if (selectable.length < 2) return;
|
|
103
|
+
|
|
104
|
+
const current = selectable.findIndex((option) => option.value === value);
|
|
105
|
+
// Wraps, which is what a radio group is expected to do.
|
|
106
|
+
const next =
|
|
107
|
+
selectable[(current + step + selectable.length) % selectable.length];
|
|
108
|
+
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
onChange(next.value);
|
|
111
|
+
// The newly checked option has to take focus, or the next arrow press is
|
|
112
|
+
// still measured from the old one.
|
|
113
|
+
refs.current.get(next.value)?.focus();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
role="radiogroup"
|
|
119
|
+
aria-label={ariaLabel}
|
|
120
|
+
onKeyDown={onKeyDown}
|
|
121
|
+
className={cn(TRACKS[variant], className)}
|
|
122
|
+
>
|
|
123
|
+
{options.map((option) => {
|
|
124
|
+
const checked = option.value === value;
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
key={option.value}
|
|
128
|
+
ref={(node) => {
|
|
129
|
+
refs.current.set(option.value, node);
|
|
130
|
+
}}
|
|
131
|
+
type="button"
|
|
132
|
+
role="radio"
|
|
133
|
+
aria-checked={checked}
|
|
134
|
+
disabled={option.disabled}
|
|
135
|
+
title={option.title}
|
|
136
|
+
// Only the checked option is reachable by Tab; arrows move within
|
|
137
|
+
// the group. Without this a keyboard user pages through every
|
|
138
|
+
// option to get past the control.
|
|
139
|
+
tabIndex={checked ? 0 : -1}
|
|
140
|
+
onClick={() => onChange(option.value)}
|
|
141
|
+
className={cn(
|
|
142
|
+
"cursor-pointer rounded px-3 py-1 text-sm font-medium transition",
|
|
143
|
+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
|
144
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
145
|
+
fill && "flex-1",
|
|
146
|
+
checked ? TONES[tone].on : TONES[tone].off,
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
{option.label}
|
|
150
|
+
</button>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
package/src/select.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { Autocomplete, type AutocompleteOption } from "./autocomplete";
|
|
6
|
+
|
|
7
|
+
type SelectChange = {
|
|
8
|
+
target: { value: string };
|
|
9
|
+
currentTarget: { value: string };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type SelectProps = {
|
|
13
|
+
id?: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/** Accessible name when the control is not wrapped in a label. */
|
|
17
|
+
"aria-label"?: string;
|
|
18
|
+
/** `compact` fits a spreadsheet cell. Typing still filters the list. */
|
|
19
|
+
size?: "default" | "compact";
|
|
20
|
+
value?: string | number | readonly string[];
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
children?: React.ReactNode;
|
|
23
|
+
onChange?: (event: SelectChange) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function optionText(node: React.ReactNode): string {
|
|
27
|
+
if (node == null || typeof node === "boolean") return "";
|
|
28
|
+
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
29
|
+
if (Array.isArray(node)) return node.map(optionText).join("");
|
|
30
|
+
if (React.isValidElement<{ children?: React.ReactNode }>(node)) {
|
|
31
|
+
return optionText(node.props.children);
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function optionsFromChildren(children: React.ReactNode): AutocompleteOption[] {
|
|
37
|
+
const options: AutocompleteOption[] = [];
|
|
38
|
+
React.Children.forEach(children, (child) => {
|
|
39
|
+
if (!React.isValidElement(child)) return;
|
|
40
|
+
if (child.type === React.Fragment) {
|
|
41
|
+
options.push(
|
|
42
|
+
...optionsFromChildren(
|
|
43
|
+
(child.props as { children?: React.ReactNode }).children,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const props = child.props as {
|
|
49
|
+
value?: string | number;
|
|
50
|
+
disabled?: boolean;
|
|
51
|
+
children?: React.ReactNode;
|
|
52
|
+
};
|
|
53
|
+
const value = props.value == null ? "" : String(props.value);
|
|
54
|
+
options.push({
|
|
55
|
+
value,
|
|
56
|
+
label: optionText(props.children).trim() || value,
|
|
57
|
+
disabled: Boolean(props.disabled),
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
return options;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The dropdown. It is the searchable autocomplete, not a native `<select>`,
|
|
65
|
+
* so a long list can be typed into and an empty list still opens.
|
|
66
|
+
*/
|
|
67
|
+
export function Select({
|
|
68
|
+
id,
|
|
69
|
+
className,
|
|
70
|
+
disabled,
|
|
71
|
+
value,
|
|
72
|
+
placeholder,
|
|
73
|
+
children,
|
|
74
|
+
onChange,
|
|
75
|
+
size = "default",
|
|
76
|
+
"aria-label": ariaLabel,
|
|
77
|
+
}: SelectProps) {
|
|
78
|
+
const options = optionsFromChildren(children);
|
|
79
|
+
const current = Array.isArray(value)
|
|
80
|
+
? String(value[0] ?? "")
|
|
81
|
+
: value == null
|
|
82
|
+
? ""
|
|
83
|
+
: String(value);
|
|
84
|
+
const blank = options.find((option) => option.value === "");
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Autocomplete
|
|
88
|
+
id={id}
|
|
89
|
+
className={className}
|
|
90
|
+
disabled={Boolean(disabled)}
|
|
91
|
+
ariaLabel={ariaLabel}
|
|
92
|
+
size={size}
|
|
93
|
+
options={options}
|
|
94
|
+
value={current}
|
|
95
|
+
clearable={false}
|
|
96
|
+
placeholder={placeholder ?? blank?.label ?? "Search…"}
|
|
97
|
+
onChange={(next) =>
|
|
98
|
+
onChange?.({
|
|
99
|
+
target: { value: next },
|
|
100
|
+
currentTarget: { value: next },
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "./utils";
|
|
6
|
+
|
|
7
|
+
export type SeparatorProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
8
|
+
orientation?: "horizontal" | "vertical";
|
|
9
|
+
decorative?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Thin rule between sections. */
|
|
13
|
+
export const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
|
|
14
|
+
(
|
|
15
|
+
{ className, orientation = "horizontal", decorative = true, ...props },
|
|
16
|
+
ref,
|
|
17
|
+
) => (
|
|
18
|
+
<div
|
|
19
|
+
ref={ref}
|
|
20
|
+
role={decorative ? "none" : "separator"}
|
|
21
|
+
aria-orientation={orientation}
|
|
22
|
+
className={cn(
|
|
23
|
+
"bg-border shrink-0",
|
|
24
|
+
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
|
25
|
+
className,
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
Separator.displayName = "Separator";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
|
|
7
|
+
import { Button } from "./button";
|
|
8
|
+
import { useDialogLayer } from "./dialogLayer";
|
|
9
|
+
import { useHasMounted } from "./modal";
|
|
10
|
+
import { cn } from "./utils";
|
|
11
|
+
|
|
12
|
+
interface SideDrawerProps {
|
|
13
|
+
isOpen: boolean;
|
|
14
|
+
title: string;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
/** Tailwind width class for the panel. Defaults to a reading-width column. */
|
|
18
|
+
panelClassName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A panel that slides in from the right.
|
|
23
|
+
*
|
|
24
|
+
* For content that belongs beside the page rather than on top of it — a queue
|
|
25
|
+
* you glance at and dismiss, not a decision you have to answer. Unlike
|
|
26
|
+
* `Modal`, it does not centre or dim the whole page to a stop: the table
|
|
27
|
+
* underneath stays readable, which is the point of looking at both.
|
|
28
|
+
*/
|
|
29
|
+
export function SideDrawer({
|
|
30
|
+
isOpen,
|
|
31
|
+
title,
|
|
32
|
+
onClose,
|
|
33
|
+
children,
|
|
34
|
+
panelClassName,
|
|
35
|
+
}: SideDrawerProps) {
|
|
36
|
+
const mounted = useHasMounted();
|
|
37
|
+
const headingId = React.useId();
|
|
38
|
+
const panelRef = React.useRef<HTMLElement>(null);
|
|
39
|
+
|
|
40
|
+
useDialogLayer({
|
|
41
|
+
// Gated on `mounted` too: before that the panel is not rendered, so there
|
|
42
|
+
// would be nothing to move focus into.
|
|
43
|
+
active: isOpen && mounted,
|
|
44
|
+
containerRef: panelRef,
|
|
45
|
+
onEscape: onClose,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!mounted || !isOpen) return null;
|
|
49
|
+
|
|
50
|
+
return createPortal(
|
|
51
|
+
<>
|
|
52
|
+
<div
|
|
53
|
+
aria-hidden
|
|
54
|
+
onClick={onClose}
|
|
55
|
+
className="fixed inset-0 z-[90] bg-slate-950/30"
|
|
56
|
+
/>
|
|
57
|
+
<aside
|
|
58
|
+
ref={panelRef}
|
|
59
|
+
role="dialog"
|
|
60
|
+
aria-modal="true"
|
|
61
|
+
aria-labelledby={headingId}
|
|
62
|
+
tabIndex={-1}
|
|
63
|
+
className={cn(
|
|
64
|
+
"border-border bg-background fixed inset-y-0 right-0 z-[95] flex flex-col border-l shadow-2xl outline-none",
|
|
65
|
+
panelClassName ?? "w-full max-w-md",
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
<div className="border-border flex items-center justify-between gap-3 border-b px-4 py-3">
|
|
69
|
+
<h2 id={headingId} className="truncate text-base font-semibold">
|
|
70
|
+
{title}
|
|
71
|
+
</h2>
|
|
72
|
+
<Button
|
|
73
|
+
variant="ghost"
|
|
74
|
+
size="icon"
|
|
75
|
+
aria-label="Close panel"
|
|
76
|
+
onClick={onClose}
|
|
77
|
+
>
|
|
78
|
+
<X className="h-4 w-4" aria-hidden />
|
|
79
|
+
</Button>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-4">{children}</div>
|
|
83
|
+
</aside>
|
|
84
|
+
</>,
|
|
85
|
+
document.body,
|
|
86
|
+
);
|
|
87
|
+
}
|
package/src/spinner.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Loader2 } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "./utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one spinner.
|
|
8
|
+
*
|
|
9
|
+
* There were five spellings of `animate-spin` across twelve files — `h-4 w-4`,
|
|
10
|
+
* `h-5 w-5`, `size-4`, bare, and a hand-drawn ring in `ProtectedRoute` — so the
|
|
11
|
+
* same "working" signal came out a different size depending on which panel you
|
|
12
|
+
* were on.
|
|
13
|
+
*
|
|
14
|
+
* It is `aria-hidden` on purpose. A spinner is decoration; what a screen reader
|
|
15
|
+
* needs is the surrounding control's `aria-busy` or the panel's status text,
|
|
16
|
+
* and announcing "loading" twice is worse than once. `Button` and `StatePanel`
|
|
17
|
+
* both supply that.
|
|
18
|
+
*/
|
|
19
|
+
const SIZES = {
|
|
20
|
+
sm: "size-3.5",
|
|
21
|
+
md: "size-4",
|
|
22
|
+
lg: "size-5",
|
|
23
|
+
xl: "size-10 border-2",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export interface SpinnerProps extends React.SVGProps<SVGSVGElement> {
|
|
27
|
+
size?: keyof typeof SIZES;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Spinner({ size = "md", className, ...props }: SpinnerProps) {
|
|
31
|
+
return (
|
|
32
|
+
<Loader2
|
|
33
|
+
aria-hidden
|
|
34
|
+
className={cn("animate-spin", SIZES[size], className)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|