@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.
@@ -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
+ }
@@ -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
+ }