terrazzo 0.7.7 → 0.7.8
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.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7116e86ccb7f5bd1ba8309e966913c53746f4a6f7e7ff6461194c084b236697f
|
|
4
|
+
data.tar.gz: b1ff3fb03ce897c082df6977e919bedf667a9d0867e402ea7d720572d4bb5fb9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2dccc34e6589dee6e9a29ac78df787f405c35b841ddf4168fce78fc66afdc2616fe19a657cc751434f15ec703042452f7d1940b58fc6b86e8054a422a130e1b3
|
|
7
|
+
data.tar.gz: ff55728ffbe3d61cbafc4d6b41742363fe4efc3c9b15e17f5b68570fdec9def720018635450d931a7f247eca4ab2125e03248929d8f5078a3d878f3d9486b695
|
data/README.md
CHANGED
|
@@ -33,7 +33,7 @@ npm install @radix-ui/react-avatar@1.1.11 @radix-ui/react-dialog@1.1.15 \
|
|
|
33
33
|
@radix-ui/react-popover@1.1.15 @radix-ui/react-select@2.2.6 \
|
|
34
34
|
@radix-ui/react-separator@1.1.8 @radix-ui/react-slot@1.2.4 \
|
|
35
35
|
@radix-ui/react-tooltip@1.2.8 class-variance-authority@0.7.1 \
|
|
36
|
-
lucide-react@0.344.0 tailwindcss@4.2.1
|
|
36
|
+
cmdk@1.1.1 lucide-react@0.344.0 tailwindcss@4.2.1
|
|
37
37
|
npm install --save-dev @tailwindcss/cli@4.2.1 # if your app does not already compile Tailwind
|
|
38
38
|
|
|
39
39
|
# Install Terrazzo — generates admin namespace, app barrels,
|
|
@@ -32,6 +32,7 @@ module Terrazzo
|
|
|
32
32
|
@radix-ui/react-slot
|
|
33
33
|
@radix-ui/react-tooltip
|
|
34
34
|
class-variance-authority
|
|
35
|
+
cmdk
|
|
35
36
|
lucide-react
|
|
36
37
|
] + [TAILWIND_PACKAGE_NAME]
|
|
37
38
|
).freeze
|
|
@@ -52,6 +53,7 @@ module Terrazzo
|
|
|
52
53
|
"@radix-ui/react-slot" => "1.0.0",
|
|
53
54
|
"@radix-ui/react-tooltip" => "1.0.0",
|
|
54
55
|
"class-variance-authority" => "0.7.0",
|
|
56
|
+
"cmdk" => "1.1.1",
|
|
55
57
|
"lucide-react" => "0.300.0",
|
|
56
58
|
TAILWIND_PACKAGE_NAME => TAILWIND_MINIMUM_VERSION,
|
|
57
59
|
}.freeze
|
|
@@ -1,40 +1,31 @@
|
|
|
1
|
+
import { Command } from "cmdk";
|
|
2
|
+
import { Check, ChevronsUpDown, Loader2, Search, X } from "lucide-react";
|
|
1
3
|
import React, { useEffect, useId, useState } from "react";
|
|
4
|
+
import { cn } from "terrazzo";
|
|
2
5
|
import { Label, Popover, PopoverContent, PopoverTrigger } from "./ui";
|
|
3
6
|
|
|
4
|
-
export function AssociationSelect({
|
|
5
|
-
value,
|
|
6
|
-
options,
|
|
7
|
-
input,
|
|
8
|
-
label,
|
|
9
|
-
required,
|
|
10
|
-
multiple = false,
|
|
11
|
-
onChange,
|
|
12
|
-
}) {
|
|
7
|
+
export function AssociationSelect({ value, options, input, label, required, multiple = false, onChange }) {
|
|
13
8
|
const popupId = useId();
|
|
9
|
+
const searchRef = React.useRef(null);
|
|
14
10
|
const [selectedIds, setSelectedIds] = useState(() =>
|
|
15
|
-
(multiple
|
|
16
|
-
? (value ?? [])
|
|
17
|
-
: value == null || value === ""
|
|
18
|
-
? []
|
|
19
|
-
: [value]
|
|
20
|
-
).map(String),
|
|
21
|
-
);
|
|
22
|
-
const [selectedOptions, setSelectedOptions] = useState(
|
|
23
|
-
options.resourceOptions,
|
|
11
|
+
(multiple ? (value ?? []) : value == null || value === "" ? [] : [value]).map(String)
|
|
24
12
|
);
|
|
13
|
+
const [selectedOptions, setSelectedOptions] = useState(options.resourceOptions);
|
|
25
14
|
const [open, setOpen] = useState(false);
|
|
26
15
|
const [search, setSearch] = useState("");
|
|
27
|
-
const [result, setResult] = useState(
|
|
16
|
+
const [result, setResult] = useState(null);
|
|
17
|
+
const [activeResultId, setActiveResultId] = useState("");
|
|
28
18
|
const [loading, setLoading] = useState(false);
|
|
29
19
|
const [error, setError] = useState("");
|
|
30
20
|
const [retry, setRetry] = useState(0);
|
|
21
|
+
const selected = selectedOptions.filter((option) => selectedIds.includes(option[1]));
|
|
22
|
+
const placeholder = `Select ${label?.toLowerCase() ?? (multiple ? "items" : "an option")}…`;
|
|
31
23
|
|
|
32
24
|
useEffect(() => {
|
|
33
25
|
if (!open) return;
|
|
34
26
|
const controller = new window.AbortController();
|
|
35
27
|
let active = true;
|
|
36
28
|
setLoading(true);
|
|
37
|
-
setError("");
|
|
38
29
|
const timer = setTimeout(async () => {
|
|
39
30
|
try {
|
|
40
31
|
const url = new URL(options.optionsUrl, window.location.origin);
|
|
@@ -42,16 +33,21 @@ export function AssociationSelect({
|
|
|
42
33
|
const response = await window.fetch(url, {
|
|
43
34
|
headers: { Accept: "application/json" },
|
|
44
35
|
signal: controller.signal,
|
|
45
|
-
credentials: "same-origin"
|
|
36
|
+
credentials: "same-origin"
|
|
46
37
|
});
|
|
47
38
|
if (!response.ok) throw new Error("Could not load options.");
|
|
48
39
|
const data = await response.json();
|
|
49
|
-
if (active)
|
|
40
|
+
if (active) {
|
|
41
|
+
setResult(data);
|
|
42
|
+
setActiveResultId(data.resourceOptions[0]?.[1] ?? "");
|
|
43
|
+
setError("");
|
|
44
|
+
}
|
|
50
45
|
} catch (failure) {
|
|
51
|
-
if (active && failure.name !== "AbortError")
|
|
52
|
-
setError("Could not load options.");
|
|
46
|
+
if (active && failure.name !== "AbortError") setError("Could not load options.");
|
|
53
47
|
} finally {
|
|
54
|
-
if (active)
|
|
48
|
+
if (active) {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
55
51
|
}
|
|
56
52
|
}, 200);
|
|
57
53
|
return () => {
|
|
@@ -68,10 +64,9 @@ export function AssociationSelect({
|
|
|
68
64
|
? selectedIds.filter((item) => item !== id)
|
|
69
65
|
: [...selectedIds, id]
|
|
70
66
|
: [id];
|
|
71
|
-
const nextOptions = [
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
].filter((item) => nextIds.includes(item[1]));
|
|
67
|
+
const nextOptions = [...selectedOptions.filter((item) => item[1] !== id), option].filter((item) =>
|
|
68
|
+
nextIds.includes(item[1])
|
|
69
|
+
);
|
|
75
70
|
setSelectedIds(nextIds);
|
|
76
71
|
setSelectedOptions(nextOptions);
|
|
77
72
|
onChange?.(multiple ? nextIds : id, option);
|
|
@@ -85,116 +80,192 @@ export function AssociationSelect({
|
|
|
85
80
|
onChange?.(multiple ? nextIds : "", null);
|
|
86
81
|
};
|
|
87
82
|
|
|
83
|
+
const changeOpen = (next) => {
|
|
84
|
+
setOpen(next);
|
|
85
|
+
if (next) {
|
|
86
|
+
setLoading(true);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
88
90
|
return (
|
|
89
91
|
<div className="space-y-2">
|
|
90
92
|
{label && (
|
|
91
93
|
<Label htmlFor={input?.id}>
|
|
92
94
|
{label}
|
|
93
|
-
{required && "
|
|
95
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
94
96
|
</Label>
|
|
95
97
|
)}
|
|
96
98
|
{input?.name && <input type="hidden" name={input.name} value="" />}
|
|
97
|
-
{input?.name &&
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
99
|
+
{input?.name && selectedIds.map((id) => <input key={id} type="hidden" name={input.name} value={id} />)}
|
|
100
|
+
<Popover open={open} onOpenChange={changeOpen}>
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
"border-border bg-background text-foreground relative flex min-h-10 w-full items-center rounded-md border px-3 py-2 text-sm shadow-sm transition-colors",
|
|
104
|
+
"hover:border-ring/50 focus-within:border-ring focus-within:ring-ring/20 focus-within:ring-2",
|
|
105
|
+
open && "border-ring ring-ring/20 ring-2"
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
<PopoverTrigger asChild>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
id={input?.id}
|
|
112
|
+
data-slot="association-trigger"
|
|
113
|
+
aria-describedby={input?.["aria-describedby"]}
|
|
114
|
+
aria-label={label ?? "Select options"}
|
|
115
|
+
role="combobox"
|
|
116
|
+
aria-expanded={open}
|
|
117
|
+
aria-controls={popupId}
|
|
118
|
+
aria-haspopup="dialog"
|
|
119
|
+
className="absolute inset-0 w-full cursor-pointer rounded-md outline-none"
|
|
120
|
+
onKeyDown={(event) => {
|
|
121
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
changeOpen(true);
|
|
124
|
+
}
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<span className="sr-only">
|
|
128
|
+
{selected.length ? selected.map(([name]) => name).join(", ") : placeholder}
|
|
129
|
+
</span>
|
|
130
|
+
</button>
|
|
131
|
+
</PopoverTrigger>
|
|
132
|
+
<div
|
|
133
|
+
className={cn(
|
|
134
|
+
"pointer-events-none flex min-w-0 flex-1 flex-wrap items-center gap-1.5 pr-6",
|
|
135
|
+
!multiple && selected.length > 0 && "pr-12"
|
|
136
|
+
)}
|
|
135
137
|
>
|
|
136
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
138
|
+
{selected.length === 0 ? (
|
|
139
|
+
<span className="text-muted-foreground truncate">{placeholder}</span>
|
|
140
|
+
) : multiple ? (
|
|
141
|
+
selected.map(([display, id]) => (
|
|
142
|
+
<span
|
|
143
|
+
key={id}
|
|
144
|
+
className="border-border/60 bg-secondary text-secondary-foreground relative inline-flex max-w-full items-center gap-1 rounded border py-0.5 pl-2 pr-1 text-xs font-medium"
|
|
145
|
+
>
|
|
146
|
+
<span className="truncate">{display}</span>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
aria-label={`Remove ${display}`}
|
|
150
|
+
className="text-muted-foreground hover:bg-foreground/10 hover:text-foreground focus-visible:ring-ring pointer-events-auto relative z-10 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm outline-none focus-visible:ring-2"
|
|
151
|
+
onClick={() => remove(id)}
|
|
152
|
+
>
|
|
153
|
+
<X className="size-3" aria-hidden="true" />
|
|
154
|
+
</button>
|
|
155
|
+
</span>
|
|
156
|
+
))
|
|
157
|
+
) : (
|
|
158
|
+
<span className="truncate">{selected[0][0]}</span>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
{!multiple && selected.length > 0 && (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
aria-label={`Clear ${label ?? "selection"}`}
|
|
165
|
+
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring absolute right-8 z-10 inline-flex size-6 cursor-pointer items-center justify-center rounded-sm outline-none focus-visible:ring-2"
|
|
166
|
+
onClick={() => remove(selectedIds[0])}
|
|
167
|
+
>
|
|
168
|
+
<X className="size-3.5" aria-hidden="true" />
|
|
169
|
+
</button>
|
|
170
|
+
)}
|
|
171
|
+
<ChevronsUpDown
|
|
172
|
+
className="text-muted-foreground pointer-events-none absolute right-3 size-4"
|
|
173
|
+
aria-hidden="true"
|
|
153
174
|
/>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
</div>
|
|
176
|
+
<PopoverContent
|
|
177
|
+
id={popupId}
|
|
178
|
+
align="start"
|
|
179
|
+
sideOffset={6}
|
|
180
|
+
className="border-border bg-popover text-popover-foreground w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg p-0 shadow-lg"
|
|
181
|
+
onOpenAutoFocus={(event) => {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
searchRef.current?.focus();
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<Command
|
|
187
|
+
shouldFilter={false}
|
|
188
|
+
loop
|
|
189
|
+
value={activeResultId}
|
|
190
|
+
onValueChange={setActiveResultId}
|
|
191
|
+
label={`Search ${label ?? "options"}`}
|
|
192
|
+
className="bg-popover text-popover-foreground flex w-full flex-col overflow-hidden"
|
|
193
|
+
>
|
|
194
|
+
<div className="border-border flex items-center gap-2.5 border-b px-3">
|
|
195
|
+
<span
|
|
196
|
+
data-slot="association-search-indicator"
|
|
197
|
+
className="text-muted-foreground relative size-4 shrink-0"
|
|
198
|
+
aria-hidden="true"
|
|
162
199
|
>
|
|
163
|
-
|
|
164
|
-
</
|
|
200
|
+
{loading ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}
|
|
201
|
+
</span>
|
|
202
|
+
<Command.Input
|
|
203
|
+
ref={searchRef}
|
|
204
|
+
data-slot="association-search"
|
|
205
|
+
type="text"
|
|
206
|
+
aria-label={`Search ${label ?? "options"}`}
|
|
207
|
+
value={search}
|
|
208
|
+
placeholder={`Search ${label?.toLowerCase() ?? "options"}…`}
|
|
209
|
+
className="bg-transparent! text-popover-foreground placeholder:text-muted-foreground h-11 min-w-0 flex-1 border-0 px-0 text-sm shadow-none outline-none focus:border-0 focus:outline-none focus:ring-0"
|
|
210
|
+
onValueChange={(text) => {
|
|
211
|
+
setSearch(text);
|
|
212
|
+
setLoading(true);
|
|
213
|
+
}}
|
|
214
|
+
/>
|
|
165
215
|
</div>
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
216
|
+
<span role="status" className="sr-only">
|
|
217
|
+
{loading ? "Searching…" : ""}
|
|
218
|
+
</span>
|
|
219
|
+
<Command.List
|
|
220
|
+
className="max-h-64 min-h-20 scroll-py-1 overflow-y-auto overflow-x-hidden p-1"
|
|
221
|
+
aria-busy={loading}
|
|
171
222
|
>
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
223
|
+
{error ? (
|
|
224
|
+
<div role="alert" className="text-muted-foreground space-y-2 px-3 py-5 text-center text-sm">
|
|
225
|
+
<p>{error}</p>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
className="text-foreground focus-visible:ring-ring rounded px-2 py-1 font-medium underline underline-offset-4 outline-none focus-visible:ring-2"
|
|
229
|
+
disabled={loading}
|
|
230
|
+
onClick={() => {
|
|
231
|
+
setLoading(true);
|
|
232
|
+
setRetry((count) => count + 1);
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
Try again
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
238
|
+
) : result ? (
|
|
239
|
+
<>
|
|
240
|
+
{result.resourceOptions.length === 0 && (
|
|
241
|
+
<p className="text-muted-foreground px-3 py-6 text-center text-sm">No results found.</p>
|
|
242
|
+
)}
|
|
243
|
+
{result.resourceOptions.map((option) => (
|
|
244
|
+
<Command.Item
|
|
245
|
+
key={option[1]}
|
|
246
|
+
value={option[1]}
|
|
247
|
+
aria-label={option[0]}
|
|
248
|
+
aria-checked={selectedIds.includes(option[1])}
|
|
249
|
+
disabled={loading}
|
|
250
|
+
onSelect={() => select(option)}
|
|
251
|
+
className="data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex min-h-9 cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none"
|
|
252
|
+
>
|
|
253
|
+
<span className="min-w-0 flex-1 truncate">{option[0]}</span>
|
|
254
|
+
<Check
|
|
255
|
+
className={cn(
|
|
256
|
+
"size-4 shrink-0",
|
|
257
|
+
selectedIds.includes(option[1]) ? "text-primary" : "opacity-0"
|
|
258
|
+
)}
|
|
259
|
+
aria-hidden="true"
|
|
260
|
+
/>
|
|
261
|
+
</Command.Item>
|
|
262
|
+
))}
|
|
263
|
+
</>
|
|
264
|
+
) : null}
|
|
265
|
+
</Command.List>
|
|
266
|
+
</Command>
|
|
187
267
|
</PopoverContent>
|
|
188
268
|
</Popover>
|
|
189
|
-
{!multiple && selectedIds.length > 0 && (
|
|
190
|
-
<button
|
|
191
|
-
type="button"
|
|
192
|
-
className="text-sm text-muted-foreground"
|
|
193
|
-
onClick={() => remove(selectedIds[0])}
|
|
194
|
-
>
|
|
195
|
-
Clear selection
|
|
196
|
-
</button>
|
|
197
|
-
)}
|
|
198
269
|
</div>
|
|
199
270
|
);
|
|
200
271
|
}
|
data/lib/terrazzo/version.rb
CHANGED