terrazzo 0.7.6 → 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: f2485c82e85ed664ba929d42f7aba8b3fd05d06c0c98f0e72cdf023108827c9b
4
- data.tar.gz: 936dc1c90978c9e5a034ad1a99994eca021d09494bde07ffa08b452bf99b6641
3
+ metadata.gz: 7116e86ccb7f5bd1ba8309e966913c53746f4a6f7e7ff6461194c084b236697f
4
+ data.tar.gz: b1ff3fb03ce897c082df6977e919bedf667a9d0867e402ea7d720572d4bb5fb9
5
5
  SHA512:
6
- metadata.gz: 2526b14b6bcd42a5d4eaee4ed9d3c6ce95a98f345a6e67ede0935e7fcdf7d455ce5afa1f012b52b26c9930706dcbd32dacb6062860f8a5cffac466045274db5e
7
- data.tar.gz: 69b436459fb552a8569b1b9250f50335b9383c2bc7490d0ec9ce0c8c366c99b57f3c6d6fa9d44de21d5bb306192d6e80ae5ba6ab1cd4d25da4eb0b9e85046171
6
+ metadata.gz: 2dccc34e6589dee6e9a29ac78df787f405c35b841ddf4168fce78fc66afdc2616fe19a657cc751434f15ec703042452f7d1940b58fc6b86e8054a422a130e1b3
7
+ data.tar.gz: ff55728ffbe3d61cbafc4d6b41742363fe4efc3c9b15e17f5b68570fdec9def720018635450d931a7f247eca4ab2125e03248929d8f5078a3d878f3d9486b695
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Terrazzo
2
2
 
3
- Association forms load selected values first. `HasMany` and `BelongsTo` use server search with pages of 25 options. Upgrade the gem and npm package together. Custom association form components must use the new option query contract; see [custom fields](docs/customizing-fields.md).
3
+ Association forms load selected values first. `HasMany` and `BelongsTo` use typeahead search with at most 10 matches. Upgrade the gem and npm package together. Custom association form components must use the new option query contract; see [custom fields](docs/customizing-fields.md).
4
4
 
5
5
  A drop-in admin panel for Rails apps. Uses the [Administrate](https://github.com/thoughtbot/administrate) dashboard DSL with a React SPA frontend powered by [Superglue](https://github.com/thoughtbot/superglue).
6
6
 
@@ -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,
@@ -282,7 +282,7 @@ module Terrazzo
282
282
  return head :not_found unless field.association_options?
283
283
 
284
284
  response.headers["Cache-Control"] = "no-store"
285
- render json: field.association_options(search: params[:search], page: params[:page])
285
+ render json: field.association_options(search: params[:search])
286
286
  end
287
287
 
288
288
  def resolver
@@ -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,59 +1,53 @@
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 [page, setPage] = useState(1);
28
- const [result, setResult] = useState({ resourceOptions: [], hasMore: false });
16
+ const [result, setResult] = useState(null);
17
+ const [activeResultId, setActiveResultId] = useState("");
29
18
  const [loading, setLoading] = useState(false);
30
19
  const [error, setError] = useState("");
31
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")}…`;
32
23
 
33
24
  useEffect(() => {
34
25
  if (!open) return;
35
26
  const controller = new window.AbortController();
36
27
  let active = true;
37
28
  setLoading(true);
38
- setError("");
39
29
  const timer = setTimeout(async () => {
40
30
  try {
41
31
  const url = new URL(options.optionsUrl, window.location.origin);
42
32
  url.searchParams.set("search", search);
43
- url.searchParams.set("page", String(page));
44
33
  const response = await window.fetch(url, {
45
34
  headers: { Accept: "application/json" },
46
35
  signal: controller.signal,
47
- credentials: "same-origin",
36
+ credentials: "same-origin"
48
37
  });
49
38
  if (!response.ok) throw new Error("Could not load options.");
50
39
  const data = await response.json();
51
- if (active) setResult(data);
40
+ if (active) {
41
+ setResult(data);
42
+ setActiveResultId(data.resourceOptions[0]?.[1] ?? "");
43
+ setError("");
44
+ }
52
45
  } catch (failure) {
53
- if (active && failure.name !== "AbortError")
54
- setError("Could not load options.");
46
+ if (active && failure.name !== "AbortError") setError("Could not load options.");
55
47
  } finally {
56
- if (active) setLoading(false);
48
+ if (active) {
49
+ setLoading(false);
50
+ }
57
51
  }
58
52
  }, 200);
59
53
  return () => {
@@ -61,7 +55,7 @@ export function AssociationSelect({
61
55
  clearTimeout(timer);
62
56
  controller.abort();
63
57
  };
64
- }, [open, search, page, options.optionsUrl, retry]);
58
+ }, [open, search, options.optionsUrl, retry]);
65
59
 
66
60
  const select = (option) => {
67
61
  const id = option[1];
@@ -70,10 +64,9 @@ export function AssociationSelect({
70
64
  ? selectedIds.filter((item) => item !== id)
71
65
  : [...selectedIds, id]
72
66
  : [id];
73
- const nextOptions = [
74
- ...selectedOptions.filter((item) => item[1] !== id),
75
- option,
76
- ].filter((item) => nextIds.includes(item[1]));
67
+ const nextOptions = [...selectedOptions.filter((item) => item[1] !== id), option].filter((item) =>
68
+ nextIds.includes(item[1])
69
+ );
77
70
  setSelectedIds(nextIds);
78
71
  setSelectedOptions(nextOptions);
79
72
  onChange?.(multiple ? nextIds : id, option);
@@ -87,140 +80,192 @@ export function AssociationSelect({
87
80
  onChange?.(multiple ? nextIds : "", null);
88
81
  };
89
82
 
83
+ const changeOpen = (next) => {
84
+ setOpen(next);
85
+ if (next) {
86
+ setLoading(true);
87
+ }
88
+ };
89
+
90
90
  return (
91
91
  <div className="space-y-2">
92
92
  {label && (
93
93
  <Label htmlFor={input?.id}>
94
94
  {label}
95
- {required && " *"}
95
+ {required && <span className="text-destructive ml-1">*</span>}
96
96
  </Label>
97
97
  )}
98
98
  {input?.name && <input type="hidden" name={input.name} value="" />}
99
- {input?.name &&
100
- selectedIds.map((id) => (
101
- <input key={id} type="hidden" name={input.name} value={id} />
102
- ))}
103
- {multiple && (
104
- <div className="flex flex-wrap gap-1">
105
- {selectedOptions
106
- .filter((option) => selectedIds.includes(option[1]))
107
- .map(([display, id]) => (
108
- <button
109
- key={id}
110
- type="button"
111
- className="rounded bg-muted px-2 py-1 text-sm"
112
- aria-label={`Remove ${display}`}
113
- onClick={() => remove(id)}
114
- >
115
- {display} ×
116
- </button>
117
- ))}
118
- </div>
119
- )}
120
- <Popover
121
- open={open}
122
- onOpenChange={(next) => {
123
- setOpen(next);
124
- if (next) setLoading(true);
125
- }}
126
- >
127
- <PopoverTrigger asChild>
128
- <button
129
- type="button"
130
- id={input?.id}
131
- aria-describedby={input?.["aria-describedby"]}
132
- role="combobox"
133
- aria-expanded={open}
134
- aria-controls={popupId}
135
- aria-haspopup="dialog"
136
- className="flex min-h-10 w-full items-center rounded-md border px-3 py-2 text-sm"
137
- >
138
- {multiple
139
- ? "Select items…"
140
- : (selectedOptions.find(
141
- (option) => option[1] === selectedIds[0],
142
- )?.[0] ?? "Select…")}
143
- </button>
144
- </PopoverTrigger>
145
- <PopoverContent id={popupId} className="w-80 space-y-2">
146
- <input
147
- aria-label={`Search ${label ?? "options"}`}
148
- value={search}
149
- placeholder="Search…"
150
- className="w-full rounded border px-2 py-1"
151
- onChange={(event) => {
152
- setSearch(event.target.value);
153
- setPage(1);
154
- setLoading(true);
155
- }}
156
- />
157
- {loading ? (
158
- <p role="status">Loading…</p>
159
- ) : error ? (
160
- <div role="alert">
161
- {error}{" "}
162
- <button
163
- type="button"
164
- onClick={() => setRetry((count) => count + 1)}
165
- >
166
- Retry
167
- </button>
168
- </div>
169
- ) : (
170
- <div
171
- className="max-h-60 overflow-y-auto"
172
- role="group"
173
- aria-label="Options"
174
- >
175
- {result.resourceOptions.length === 0 && <p>No results found.</p>}
176
- {result.resourceOptions.map((option) => (
177
- <button
178
- key={option[1]}
179
- type="button"
180
- aria-pressed={selectedIds.includes(option[1])}
181
- className="block w-full rounded px-2 py-1 text-left hover:bg-muted"
182
- onClick={() => select(option)}
183
- >
184
- {selectedIds.includes(option[1]) ? "✓ " : ""}
185
- {option[0]}
186
- </button>
187
- ))}
188
- </div>
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"
189
106
  )}
190
- <div className="flex items-center justify-between">
107
+ >
108
+ <PopoverTrigger asChild>
191
109
  <button
192
110
  type="button"
193
- disabled={loading || page === 1}
194
- onClick={() => {
195
- setPage(page - 1);
196
- setLoading(true);
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
+ }
197
125
  }}
198
126
  >
199
- Previous
127
+ <span className="sr-only">
128
+ {selected.length ? selected.map(([name]) => name).join(", ") : placeholder}
129
+ </span>
200
130
  </button>
201
- <span>Page {page}</span>
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
+ )}
137
+ >
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 && (
202
162
  <button
203
163
  type="button"
204
- disabled={loading || !!error || !result.hasMore}
205
- onClick={() => {
206
- setPage(page + 1);
207
- setLoading(true);
208
- }}
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])}
209
167
  >
210
- Next
168
+ <X className="size-3.5" aria-hidden="true" />
211
169
  </button>
212
- </div>
170
+ )}
171
+ <ChevronsUpDown
172
+ className="text-muted-foreground pointer-events-none absolute right-3 size-4"
173
+ aria-hidden="true"
174
+ />
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"
199
+ >
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
+ />
215
+ </div>
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}
222
+ >
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>
213
267
  </PopoverContent>
214
268
  </Popover>
215
- {!multiple && selectedIds.length > 0 && (
216
- <button
217
- type="button"
218
- className="text-sm text-muted-foreground"
219
- onClick={() => remove(selectedIds[0])}
220
- >
221
- Clear selection
222
- </button>
223
- )}
224
269
  </div>
225
270
  );
226
271
  }
@@ -1,20 +1,17 @@
1
1
  module Terrazzo
2
2
  module Field
3
3
  class Associative < Base
4
- OPTION_PAGE_SIZE = 25
4
+ OPTION_LIMIT = 10
5
5
 
6
- def association_options(search:, page:)
7
- page = [page.to_i, 1].max
6
+ def association_options(search:)
8
7
  scope = candidate_scope
9
8
  columns = options[:searchable_fields] || (%w[name title email slug code original_filename id] &
10
9
  (associated_class.column_names + associated_class.stored_attributes.values.flatten.map(&:to_s)))
11
10
  scope = Terrazzo::Search.new(scope, nil, search).run_for_columns(columns)
12
11
  scope = scope.reorder(options[:order] || association_primary_key).order(association_primary_key)
13
- records = scope.offset((page - 1) * OPTION_PAGE_SIZE).limit(OPTION_PAGE_SIZE + 1).to_a
12
+ records = scope.limit(OPTION_LIMIT).to_a
14
13
  {
15
- resourceOptions: records.first(OPTION_PAGE_SIZE).map { |record| option_for(record) },
16
- page: page,
17
- hasMore: records.length > OPTION_PAGE_SIZE
14
+ resourceOptions: records.map { |record| option_for(record) }
18
15
  }
19
16
  end
20
17
 
@@ -1,3 +1,3 @@
1
1
  module Terrazzo
2
- VERSION = "0.7.6"
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.6
4
+ version: 0.7.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Terrazzo Contributors