@classic-homes/theme-svelte 1.31.0 → 1.32.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.
@@ -1202,6 +1202,17 @@
1202
1202
  };
1203
1203
  });
1204
1204
 
1205
+ /**
1206
+ * Style object -> `style` attribute. Keys may be camelCase, as React's
1207
+ * DataPanel writes them: a raw `maxHeight:` declaration is invalid CSS, so
1208
+ * the desktop card silently lost its 95vh cap and its corner radius.
1209
+ */
1210
+ function toStyleAttribute(styles: Record<string, string>): string {
1211
+ return Object.entries(styles)
1212
+ .map(([key, value]) => `${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}:${value}`)
1213
+ .join(';');
1214
+ }
1215
+
1205
1216
  // Calculate panel styles
1206
1217
  const panelStyles = $derived.by(() => {
1207
1218
  if (variant === 'card') {
@@ -1388,9 +1399,7 @@
1388
1399
  animationClass,
1389
1400
  className
1390
1401
  )}
1391
- style={Object.entries(panelStyles)
1392
- .map(([k, v]) => `${k}:${v}`)
1393
- .join(';')}
1402
+ style={toStyleAttribute(panelStyles)}
1394
1403
  role={mode === 'detached' || variant === 'card' ? 'dialog' : 'complementary'}
1395
1404
  aria-modal={isDialog ? 'true' : undefined}
1396
1405
  aria-labelledby={title && !header ? titleId : undefined}
@@ -9,7 +9,9 @@
9
9
  * Opens and closes with a grid-rows height transition (no measured heights).
10
10
  * While closed the content is `inert`, so the hidden controls are neither
11
11
  * tabbable nor exposed to screen readers. Controls are laid out on a
12
- * responsive grid; "Clear all" follows them when any filter is active.
12
+ * responsive grid, top-aligned so a control with help text under its input
13
+ * doesn't push its label and input out of line with the rest of the row;
14
+ * "Clear all" follows them when any filter is active.
13
15
  *
14
16
  * Usually rendered by FilterToolbar; use it directly only for a custom strip.
15
17
  */
@@ -52,7 +54,7 @@
52
54
  >
53
55
  <div class="overflow-hidden" inert={!open}>
54
56
  <div class="mt-2 rounded-lg border border-border bg-muted/50 p-4">
55
- <div class="grid items-end gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
57
+ <div class="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
56
58
  {@render children()}
57
59
  </div>
58
60
  {#if onClear && activeCount > 0}
@@ -5,7 +5,9 @@ import type { Snippet } from 'svelte';
5
5
  * Opens and closes with a grid-rows height transition (no measured heights).
6
6
  * While closed the content is `inert`, so the hidden controls are neither
7
7
  * tabbable nor exposed to screen readers. Controls are laid out on a
8
- * responsive grid; "Clear all" follows them when any filter is active.
8
+ * responsive grid, top-aligned so a control with help text under its input
9
+ * doesn't push its label and input out of line with the rest of the row;
10
+ * "Clear all" follows them when any filter is active.
9
11
  *
10
12
  * Usually rendered by FilterToolbar; use it directly only for a custom strip.
11
13
  */
@@ -2,6 +2,7 @@
2
2
  import { Combobox as ComboboxPrimitive } from 'bits-ui';
3
3
  import { cn } from '../utils.js';
4
4
  import { computeDescribedBy } from '../utils/form.js';
5
+ import { rangeSelect } from '../utils/range-select.js';
5
6
  import Spinner from './Spinner.svelte';
6
7
  import Label from './Label.svelte';
7
8
  import LiveRegion from './LiveRegion.svelte';
@@ -110,11 +111,26 @@
110
111
  }
111
112
  }
112
113
 
114
+ // Shift-click (or Shift+Enter) selects or clears the whole range from the
115
+ // option toggled before. Shift is read when the pick starts, since the
116
+ // combobox reports only the new values.
117
+ let anchor: string | null = null;
118
+ let shiftPick = false;
119
+
113
120
  function handleValueChange(newValues: string[] | undefined) {
114
- if (newValues !== undefined) {
115
- value = newValues;
116
- onValueChange?.(newValues);
121
+ if (newValues === undefined) return;
122
+ const added = newValues.filter((v) => !value.includes(v));
123
+ const removed = value.filter((v) => !newValues.includes(v));
124
+ const toggled = added.length + removed.length === 1 ? (added[0] ?? removed[0]) : null;
125
+ let next = newValues;
126
+ if (shiftPick && toggled !== null && anchor !== null && anchor !== toggled) {
127
+ next =
128
+ rangeSelect(value, filteredOptions, anchor, toggled, added.length === 1, max) ?? newValues;
117
129
  }
130
+ shiftPick = false;
131
+ if (toggled !== null) anchor = toggled;
132
+ value = next;
133
+ onValueChange?.(next);
118
134
  }
119
135
 
120
136
  function removeValue(valueToRemove: string) {
@@ -224,6 +240,9 @@
224
240
  oninput={handleSearchInput}
225
241
  onfocus={() => (open = true)}
226
242
  onclick={() => (open = true)}
243
+ onkeydown={(e: KeyboardEvent) => {
244
+ if (e.key === 'Enter') shiftPick = e.shiftKey;
245
+ }}
227
246
  class={cn(
228
247
  'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
229
248
  error && 'border-destructive focus:ring-destructive'
@@ -260,6 +279,7 @@
260
279
  value={option.value}
261
280
  label={option.label}
262
281
  disabled={option.disabled || isDisabledByMax}
282
+ onpointerdown={(e: PointerEvent) => (shiftPick = e.shiftKey)}
263
283
  class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
264
284
  >
265
285
  <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shift-click range selection for multi-select lists.
3
+ */
4
+ export interface RangeSelectOption {
5
+ value: string;
6
+ disabled?: boolean;
7
+ }
8
+ /**
9
+ * Selects (or clears) every option from `anchor` to `target`, inclusive, in
10
+ * the order `options` lists them (what the user sees). Disabled options are
11
+ * skipped. When selecting, `max` caps the result, filling from the anchor
12
+ * towards the target. Returns null when either end isn't in `options`, so
13
+ * the caller falls back to a plain toggle.
14
+ *
15
+ * @param current - The selection before the click
16
+ * @param options - The options as listed (already filtered by any search)
17
+ * @param anchor - The option toggled before this one
18
+ * @param target - The option just clicked
19
+ * @param select - Whether the click selected the target (else it cleared it)
20
+ * @param max - Maximum number of selections, if any
21
+ */
22
+ export declare function rangeSelect(current: readonly string[], options: readonly RangeSelectOption[], anchor: string, target: string, select: boolean, max?: number): string[] | null;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shift-click range selection for multi-select lists.
3
+ */
4
+ /**
5
+ * Selects (or clears) every option from `anchor` to `target`, inclusive, in
6
+ * the order `options` lists them (what the user sees). Disabled options are
7
+ * skipped. When selecting, `max` caps the result, filling from the anchor
8
+ * towards the target. Returns null when either end isn't in `options`, so
9
+ * the caller falls back to a plain toggle.
10
+ *
11
+ * @param current - The selection before the click
12
+ * @param options - The options as listed (already filtered by any search)
13
+ * @param anchor - The option toggled before this one
14
+ * @param target - The option just clicked
15
+ * @param select - Whether the click selected the target (else it cleared it)
16
+ * @param max - Maximum number of selections, if any
17
+ */
18
+ export function rangeSelect(current, options, anchor, target, select, max) {
19
+ const from = options.findIndex((o) => o.value === anchor);
20
+ const to = options.findIndex((o) => o.value === target);
21
+ if (from < 0 || to < 0)
22
+ return null;
23
+ const step = from <= to ? 1 : -1;
24
+ const range = [];
25
+ for (let i = from; i !== to + step; i += step) {
26
+ if (!options[i].disabled)
27
+ range.push(options[i].value);
28
+ }
29
+ if (!select)
30
+ return current.filter((v) => !range.includes(v));
31
+ const next = [...current];
32
+ for (const v of range) {
33
+ if (next.includes(v))
34
+ continue;
35
+ if (max !== undefined && next.length >= max)
36
+ break;
37
+ next.push(v);
38
+ }
39
+ return next;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",