@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.
- package/dist/lib/components/DataPanel.svelte +12 -3
- package/dist/lib/components/FilterPanel.svelte +4 -2
- package/dist/lib/components/FilterPanel.svelte.d.ts +3 -1
- package/dist/lib/components/MultiSelect.svelte +23 -3
- package/dist/lib/utils/range-select.d.ts +22 -0
- package/dist/lib/utils/range-select.js +40 -0
- package/package.json +1 -1
|
@@ -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={
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
}
|