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: 7598c68c1691318c48e26a9b47176857414e6b1d0b72df96215941bd6f809ea5
4
- data.tar.gz: e6f744d7131f5481d2b4a2c8c25db6a0547e96d9417eddc6edad0192805ad223
3
+ metadata.gz: 7116e86ccb7f5bd1ba8309e966913c53746f4a6f7e7ff6461194c084b236697f
4
+ data.tar.gz: b1ff3fb03ce897c082df6977e919bedf667a9d0867e402ea7d720572d4bb5fb9
5
5
  SHA512:
6
- metadata.gz: 2b50169a5535e19fe88c591f4d6f44881f1e7923fb6559d3f50ef6091e5addf971eb789a30d2c8fb92a09eb2180ee1bec7d89bb824a6783bf2244560e17b7d40
7
- data.tar.gz: 91a647284864eddc2f1d3ba22e658a02b4800415008720256b3e1d868fe62e50af20d05d8ed54cf4064e9267fdaab13480688f4de9236be8f59321bc0fa74362
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({ resourceOptions: [] });
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) setResult(data);
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) setLoading(false);
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
- ...selectedOptions.filter((item) => item[1] !== id),
73
- option,
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
- selectedIds.map((id) => (
99
- <input key={id} type="hidden" name={input.name} value={id} />
100
- ))}
101
- {multiple && (
102
- <div className="flex flex-wrap gap-1">
103
- {selectedOptions
104
- .filter((option) => selectedIds.includes(option[1]))
105
- .map(([display, id]) => (
106
- <button
107
- key={id}
108
- type="button"
109
- className="rounded bg-muted px-2 py-1 text-sm"
110
- aria-label={`Remove ${display}`}
111
- onClick={() => remove(id)}
112
- >
113
- {display} ×
114
- </button>
115
- ))}
116
- </div>
117
- )}
118
- <Popover
119
- open={open}
120
- onOpenChange={(next) => {
121
- setOpen(next);
122
- if (next) setLoading(true);
123
- }}
124
- >
125
- <PopoverTrigger asChild>
126
- <button
127
- type="button"
128
- id={input?.id}
129
- aria-describedby={input?.["aria-describedby"]}
130
- role="combobox"
131
- aria-expanded={open}
132
- aria-controls={popupId}
133
- aria-haspopup="dialog"
134
- className="flex min-h-10 w-full items-center rounded-md border px-3 py-2 text-sm"
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
- {multiple
137
- ? "Select items…"
138
- : (selectedOptions.find(
139
- (option) => option[1] === selectedIds[0],
140
- )?.[0] ?? "Select…")}
141
- </button>
142
- </PopoverTrigger>
143
- <PopoverContent id={popupId} className="w-80 space-y-2">
144
- <input
145
- aria-label={`Search ${label ?? "options"}`}
146
- value={search}
147
- placeholder="Search…"
148
- className="w-full rounded border px-2 py-1"
149
- onChange={(event) => {
150
- setSearch(event.target.value);
151
- setLoading(true);
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
- {loading ? (
155
- <p role="status">Loading…</p>
156
- ) : error ? (
157
- <div role="alert">
158
- {error}{" "}
159
- <button
160
- type="button"
161
- onClick={() => setRetry((count) => count + 1)}
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
- Retry
164
- </button>
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
- <div
168
- className="max-h-60 overflow-y-auto"
169
- role="group"
170
- aria-label="Options"
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
- {result.resourceOptions.length === 0 && <p>No results found.</p>}
173
- {result.resourceOptions.map((option) => (
174
- <button
175
- key={option[1]}
176
- type="button"
177
- aria-pressed={selectedIds.includes(option[1])}
178
- className="block w-full rounded px-2 py-1 text-left hover:bg-muted"
179
- onClick={() => select(option)}
180
- >
181
- {selectedIds.includes(option[1]) ? "✓ " : ""}
182
- {option[0]}
183
- </button>
184
- ))}
185
- </div>
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
  }
@@ -1,3 +1,3 @@
1
1
  module Terrazzo
2
- VERSION = "0.7.7"
2
+ VERSION = "0.7.8"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: terrazzo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.7
4
+ version: 0.7.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Terrazzo Contributors