@classic-homes/theme-svelte 1.38.0 → 1.40.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/Collapsible.svelte +68 -0
- package/dist/lib/components/Collapsible.svelte.d.ts +27 -0
- package/dist/lib/components/CopyButton.svelte +101 -0
- package/dist/lib/components/CopyButton.svelte.d.ts +32 -0
- package/dist/lib/components/EmptyState.svelte +72 -0
- package/dist/lib/components/EmptyState.svelte.d.ts +24 -0
- package/dist/lib/components/FilterToolbar.svelte +7 -1
- package/dist/lib/components/FilterToolbar.svelte.d.ts +4 -1
- package/dist/lib/components/Input.svelte +4 -0
- package/dist/lib/components/Input.svelte.d.ts +4 -0
- package/dist/lib/components/Kbd.svelte +80 -0
- package/dist/lib/components/Kbd.svelte.d.ts +22 -0
- package/dist/lib/components/Label.svelte +1 -1
- package/dist/lib/components/Popover.svelte +90 -0
- package/dist/lib/components/Popover.svelte.d.ts +34 -0
- package/dist/lib/components/ResizablePanes.svelte +185 -0
- package/dist/lib/components/ResizablePanes.svelte.d.ts +33 -0
- package/dist/lib/components/Textarea.svelte +45 -0
- package/dist/lib/components/Textarea.svelte.d.ts +14 -1
- package/dist/lib/components/Toolbar.svelte +8 -3
- package/dist/lib/components/Toolbar.svelte.d.ts +4 -1
- package/dist/lib/components/ToolbarCount.svelte +20 -0
- package/dist/lib/components/ToolbarCount.svelte.d.ts +4 -0
- package/dist/lib/components/ToolbarSearch.svelte +39 -0
- package/dist/lib/components/ToolbarSearch.svelte.d.ts +18 -0
- package/dist/lib/index.d.ts +9 -0
- package/dist/lib/index.js +10 -0
- package/dist/lib/utils/result-count.d.ts +34 -0
- package/dist/lib/utils/result-count.js +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A region that opens and closes from a trigger button (Bits UI Collapsible).
|
|
4
|
+
Pass `label` for a plain trigger with a chevron, or a `trigger` snippet for
|
|
5
|
+
custom trigger content (it receives `{ open }`; the button wraps it).
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
|
|
9
|
+
import type { Snippet } from 'svelte';
|
|
10
|
+
import { cn } from '../utils.js';
|
|
11
|
+
import Icon from './Icon.svelte';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** Whether the content is shown (bindable) */
|
|
15
|
+
open?: boolean;
|
|
16
|
+
onOpenChange?: (open: boolean) => void;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Trigger text; ignored when `trigger` is given */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Custom trigger content, rendered inside the trigger button */
|
|
21
|
+
trigger?: Snippet<[{ open: boolean }]>;
|
|
22
|
+
/** Show the chevron before the trigger content (default true) */
|
|
23
|
+
chevron?: boolean;
|
|
24
|
+
class?: string;
|
|
25
|
+
triggerClass?: string;
|
|
26
|
+
contentClass?: string;
|
|
27
|
+
children: Snippet;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
open = $bindable(false),
|
|
32
|
+
onOpenChange,
|
|
33
|
+
disabled = false,
|
|
34
|
+
label,
|
|
35
|
+
trigger,
|
|
36
|
+
chevron = true,
|
|
37
|
+
class: className,
|
|
38
|
+
triggerClass,
|
|
39
|
+
contentClass,
|
|
40
|
+
children,
|
|
41
|
+
}: Props = $props();
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<CollapsiblePrimitive.Root bind:open {onOpenChange} {disabled} class={className}>
|
|
45
|
+
<CollapsiblePrimitive.Trigger
|
|
46
|
+
class={cn(
|
|
47
|
+
'group inline-flex items-center gap-1.5 rounded-md text-sm font-medium text-foreground hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
48
|
+
triggerClass
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{#if chevron}
|
|
52
|
+
<Icon
|
|
53
|
+
name="chevron-right"
|
|
54
|
+
size={14}
|
|
55
|
+
class="shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90 motion-reduce:transition-none"
|
|
56
|
+
/>
|
|
57
|
+
{/if}
|
|
58
|
+
{#if trigger}{@render trigger({ open })}{:else}{label}{/if}
|
|
59
|
+
</CollapsiblePrimitive.Trigger>
|
|
60
|
+
<CollapsiblePrimitive.Content
|
|
61
|
+
class={cn(
|
|
62
|
+
'overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up motion-reduce:animate-none',
|
|
63
|
+
contentClass
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{@render children()}
|
|
67
|
+
</CollapsiblePrimitive.Content>
|
|
68
|
+
</CollapsiblePrimitive.Root>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Whether the content is shown (bindable) */
|
|
4
|
+
open?: boolean;
|
|
5
|
+
onOpenChange?: (open: boolean) => void;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
/** Trigger text; ignored when `trigger` is given */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** Custom trigger content, rendered inside the trigger button */
|
|
10
|
+
trigger?: Snippet<[{
|
|
11
|
+
open: boolean;
|
|
12
|
+
}]>;
|
|
13
|
+
/** Show the chevron before the trigger content (default true) */
|
|
14
|
+
chevron?: boolean;
|
|
15
|
+
class?: string;
|
|
16
|
+
triggerClass?: string;
|
|
17
|
+
contentClass?: string;
|
|
18
|
+
children: Snippet;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A region that opens and closes from a trigger button (Bits UI Collapsible).
|
|
22
|
+
* Pass `label` for a plain trigger with a chevron, or a `trigger` snippet for
|
|
23
|
+
* custom trigger content (it receives `{ open }`; the button wraps it).
|
|
24
|
+
*/
|
|
25
|
+
declare const Collapsible: import("svelte").Component<Props, {}, "open">;
|
|
26
|
+
type Collapsible = ReturnType<typeof Collapsible>;
|
|
27
|
+
export default Collapsible;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Copies text to the clipboard and confirms it: the icon turns into a check
|
|
4
|
+
and screen readers hear "Copied" for `timeout` ms. Icon-only by default
|
|
5
|
+
(with a tooltip); pass `children` for a labelled button.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import type { Snippet } from 'svelte';
|
|
9
|
+
import Button from './Button.svelte';
|
|
10
|
+
import Icon from './Icon.svelte';
|
|
11
|
+
import Tooltip from './Tooltip.svelte';
|
|
12
|
+
import type { ButtonVariants } from './button-variants.js';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
/** Text to copy, or a function returning it (read when clicked) */
|
|
16
|
+
value: string | (() => string | Promise<string>);
|
|
17
|
+
/** Accessible name and tooltip before copying (default "Copy") */
|
|
18
|
+
label?: string;
|
|
19
|
+
/** Accessible name, tooltip and announcement after copying (default "Copied") */
|
|
20
|
+
copiedLabel?: string;
|
|
21
|
+
/** How long the copied state lasts, in ms (default 2000) */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
variant?: ButtonVariants['variant'];
|
|
24
|
+
size?: ButtonVariants['size'];
|
|
25
|
+
/** Show a tooltip on the icon-only button (default true) */
|
|
26
|
+
tooltip?: boolean;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
class?: string;
|
|
29
|
+
/** Called after the text was written to the clipboard */
|
|
30
|
+
oncopy?: (text: string) => void;
|
|
31
|
+
/** Called when the clipboard is unavailable or the write was refused */
|
|
32
|
+
onerror?: (error: unknown) => void;
|
|
33
|
+
/** Visible label; omit for an icon-only button */
|
|
34
|
+
children?: Snippet;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let {
|
|
38
|
+
value,
|
|
39
|
+
label = 'Copy',
|
|
40
|
+
copiedLabel = 'Copied',
|
|
41
|
+
timeout = 2000,
|
|
42
|
+
variant = 'ghost',
|
|
43
|
+
size,
|
|
44
|
+
tooltip = true,
|
|
45
|
+
disabled = false,
|
|
46
|
+
class: className,
|
|
47
|
+
oncopy,
|
|
48
|
+
onerror,
|
|
49
|
+
children,
|
|
50
|
+
}: Props = $props();
|
|
51
|
+
|
|
52
|
+
let copied = $state(false);
|
|
53
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
$effect(() => () => clearTimeout(timer));
|
|
55
|
+
|
|
56
|
+
async function copy() {
|
|
57
|
+
try {
|
|
58
|
+
const text = typeof value === 'function' ? await value() : value;
|
|
59
|
+
if (!navigator.clipboard?.writeText) throw new Error('Clipboard is not available');
|
|
60
|
+
await navigator.clipboard.writeText(text);
|
|
61
|
+
copied = true;
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
timer = setTimeout(() => (copied = false), timeout);
|
|
64
|
+
oncopy?.(text);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
onerror?.(error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const name = $derived(copied ? copiedLabel : label);
|
|
71
|
+
const iconSize = $derived(size === 'sm' ? 14 : 16);
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
{#snippet button(props: Record<string, unknown> = {})}
|
|
75
|
+
<Button
|
|
76
|
+
{...props}
|
|
77
|
+
type="button"
|
|
78
|
+
{variant}
|
|
79
|
+
size={size ?? (children ? 'sm' : 'icon')}
|
|
80
|
+
{disabled}
|
|
81
|
+
class={className}
|
|
82
|
+
aria-label={children ? undefined : name}
|
|
83
|
+
data-copied={copied || undefined}
|
|
84
|
+
onclick={(e: MouseEvent) => {
|
|
85
|
+
(props.onclick as ((e: MouseEvent) => void) | undefined)?.(e);
|
|
86
|
+
void copy();
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<Icon name={copied ? 'check' : 'copy'} size={iconSize} />
|
|
90
|
+
{#if children}{@render children()}{/if}
|
|
91
|
+
</Button>
|
|
92
|
+
{/snippet}
|
|
93
|
+
|
|
94
|
+
{#if tooltip && !children}
|
|
95
|
+
<Tooltip content={name}>
|
|
96
|
+
{#snippet children({ props })}{@render button(props)}{/snippet}
|
|
97
|
+
</Tooltip>
|
|
98
|
+
{:else}
|
|
99
|
+
{@render button()}
|
|
100
|
+
{/if}
|
|
101
|
+
<span class="sr-only" role="status">{copied ? copiedLabel : ''}</span>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { ButtonVariants } from './button-variants.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Text to copy, or a function returning it (read when clicked) */
|
|
5
|
+
value: string | (() => string | Promise<string>);
|
|
6
|
+
/** Accessible name and tooltip before copying (default "Copy") */
|
|
7
|
+
label?: string;
|
|
8
|
+
/** Accessible name, tooltip and announcement after copying (default "Copied") */
|
|
9
|
+
copiedLabel?: string;
|
|
10
|
+
/** How long the copied state lasts, in ms (default 2000) */
|
|
11
|
+
timeout?: number;
|
|
12
|
+
variant?: ButtonVariants['variant'];
|
|
13
|
+
size?: ButtonVariants['size'];
|
|
14
|
+
/** Show a tooltip on the icon-only button (default true) */
|
|
15
|
+
tooltip?: boolean;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
/** Called after the text was written to the clipboard */
|
|
19
|
+
oncopy?: (text: string) => void;
|
|
20
|
+
/** Called when the clipboard is unavailable or the write was refused */
|
|
21
|
+
onerror?: (error: unknown) => void;
|
|
22
|
+
/** Visible label; omit for an icon-only button */
|
|
23
|
+
children?: Snippet;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Copies text to the clipboard and confirms it: the icon turns into a check
|
|
27
|
+
* and screen readers hear "Copied" for `timeout` ms. Icon-only by default
|
|
28
|
+
* (with a tooltip); pass `children` for a labelled button.
|
|
29
|
+
*/
|
|
30
|
+
declare const CopyButton: import("svelte").Component<Props, {}, "">;
|
|
31
|
+
type CopyButton = ReturnType<typeof CopyButton>;
|
|
32
|
+
export default CopyButton;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A centred placeholder for a view with nothing to show yet: an icon, a
|
|
4
|
+
title, an optional description and actions (e.g. a "Create" button).
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import type { Snippet } from 'svelte';
|
|
8
|
+
import { cn } from '../utils.js';
|
|
9
|
+
import Icon, { type IconName } from './Icon.svelte';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
title: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
/** Icon shown above the title in a muted circle */
|
|
15
|
+
icon?: IconName;
|
|
16
|
+
/** `sm` for panels and lists, `md` for pages (default) */
|
|
17
|
+
size?: 'sm' | 'md';
|
|
18
|
+
/** Heading element for the title (default h3) */
|
|
19
|
+
headingLevel?: 'h2' | 'h3' | 'h4' | 'p';
|
|
20
|
+
class?: string;
|
|
21
|
+
/** Buttons or links under the description */
|
|
22
|
+
actions?: Snippet;
|
|
23
|
+
/** Extra content between the description and the actions */
|
|
24
|
+
children?: Snippet;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
icon,
|
|
31
|
+
size = 'md',
|
|
32
|
+
headingLevel = 'h3',
|
|
33
|
+
class: className,
|
|
34
|
+
actions,
|
|
35
|
+
children,
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<div
|
|
40
|
+
class={cn(
|
|
41
|
+
'mx-auto flex w-full flex-col items-center text-center',
|
|
42
|
+
size === 'sm' ? 'max-w-xs gap-2 px-4 py-6' : 'max-w-md gap-3 px-6 py-12',
|
|
43
|
+
className
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{#if icon}
|
|
47
|
+
<div
|
|
48
|
+
class={cn(
|
|
49
|
+
'flex items-center justify-center rounded-full bg-muted text-muted-foreground',
|
|
50
|
+
size === 'sm' ? 'mb-1 h-10 w-10' : 'mb-2 h-12 w-12'
|
|
51
|
+
)}
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
>
|
|
54
|
+
<Icon name={icon} size={size === 'sm' ? 20 : 24} />
|
|
55
|
+
</div>
|
|
56
|
+
{/if}
|
|
57
|
+
<svelte:element
|
|
58
|
+
this={headingLevel}
|
|
59
|
+
class={cn('font-semibold text-foreground', size === 'sm' ? 'text-sm' : 'text-base')}
|
|
60
|
+
>
|
|
61
|
+
{title}
|
|
62
|
+
</svelte:element>
|
|
63
|
+
{#if description}
|
|
64
|
+
<p class={cn('text-muted-foreground', size === 'sm' ? 'text-xs' : 'text-sm')}>
|
|
65
|
+
{description}
|
|
66
|
+
</p>
|
|
67
|
+
{/if}
|
|
68
|
+
{#if children}{@render children()}{/if}
|
|
69
|
+
{#if actions}
|
|
70
|
+
<div class="mt-2 flex flex-wrap items-center justify-center gap-2">{@render actions()}</div>
|
|
71
|
+
{/if}
|
|
72
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type IconName } from './Icon.svelte';
|
|
3
|
+
interface Props {
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
/** Icon shown above the title in a muted circle */
|
|
7
|
+
icon?: IconName;
|
|
8
|
+
/** `sm` for panels and lists, `md` for pages (default) */
|
|
9
|
+
size?: 'sm' | 'md';
|
|
10
|
+
/** Heading element for the title (default h3) */
|
|
11
|
+
headingLevel?: 'h2' | 'h3' | 'h4' | 'p';
|
|
12
|
+
class?: string;
|
|
13
|
+
/** Buttons or links under the description */
|
|
14
|
+
actions?: Snippet;
|
|
15
|
+
/** Extra content between the description and the actions */
|
|
16
|
+
children?: Snippet;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A centred placeholder for a view with nothing to show yet: an icon, a
|
|
20
|
+
* title, an optional description and actions (e.g. a "Create" button).
|
|
21
|
+
*/
|
|
22
|
+
declare const EmptyState: import("svelte").Component<Props, {}, "">;
|
|
23
|
+
type EmptyState = ReturnType<typeof EmptyState>;
|
|
24
|
+
export default EmptyState;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import FilterPanel from './FilterPanel.svelte';
|
|
7
7
|
import Icon from './Icon.svelte';
|
|
8
8
|
import Toolbar from './Toolbar.svelte';
|
|
9
|
+
import ToolbarCount from './ToolbarCount.svelte';
|
|
10
|
+
import type { ToolbarCountProps } from '../utils/result-count.js';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* FilterToolbar - a Toolbar that folds its filter controls behind a
|
|
@@ -33,7 +35,9 @@
|
|
|
33
35
|
filterCount: number;
|
|
34
36
|
/** Filters currently narrowing results (button badge) */
|
|
35
37
|
activeCount?: number;
|
|
36
|
-
/** Result count
|
|
38
|
+
/** Result count, after the filters or the button (see `ToolbarCount`) */
|
|
39
|
+
count?: ToolbarCountProps;
|
|
40
|
+
/** Other summary text after the count (inherits the strip color; `text-toolbar-muted` to dim) */
|
|
37
41
|
summary?: Snippet;
|
|
38
42
|
/** Right-aligned cluster (view toggles etc.) */
|
|
39
43
|
end?: Snippet;
|
|
@@ -54,6 +58,7 @@
|
|
|
54
58
|
filters,
|
|
55
59
|
filterCount,
|
|
56
60
|
activeCount = 0,
|
|
61
|
+
count,
|
|
57
62
|
summary,
|
|
58
63
|
end: endSnippet,
|
|
59
64
|
onClear,
|
|
@@ -99,6 +104,7 @@
|
|
|
99
104
|
{:else}
|
|
100
105
|
{@render filters({ inline: true })}
|
|
101
106
|
{/if}
|
|
107
|
+
{#if count}<ToolbarCount {...count} />{/if}
|
|
102
108
|
{@render summary?.()}
|
|
103
109
|
</Toolbar>
|
|
104
110
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { ToolbarCountProps } from '../utils/result-count.js';
|
|
2
3
|
/**
|
|
3
4
|
* FilterToolbar - a Toolbar that folds its filter controls behind a
|
|
4
5
|
* "Filters" button once there are too many to sit inline.
|
|
@@ -27,7 +28,9 @@ interface Props {
|
|
|
27
28
|
filterCount: number;
|
|
28
29
|
/** Filters currently narrowing results (button badge) */
|
|
29
30
|
activeCount?: number;
|
|
30
|
-
/** Result count
|
|
31
|
+
/** Result count, after the filters or the button (see `ToolbarCount`) */
|
|
32
|
+
count?: ToolbarCountProps;
|
|
33
|
+
/** Other summary text after the count (inherits the strip color; `text-toolbar-muted` to dim) */
|
|
31
34
|
summary?: Snippet;
|
|
32
35
|
/** Right-aligned cluster (view toggles etc.) */
|
|
33
36
|
end?: Snippet;
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
/** Whether the input has an error */
|
|
25
25
|
'aria-invalid'?: boolean | 'true' | 'false';
|
|
26
26
|
onValueChange?: (value: string) => void;
|
|
27
|
+
/** Native input listener; runs after the value is updated */
|
|
28
|
+
oninput?: (e: Event & { currentTarget: HTMLInputElement }) => void;
|
|
27
29
|
[key: string]: unknown;
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -44,6 +46,7 @@
|
|
|
44
46
|
'aria-describedby': ariaDescribedby,
|
|
45
47
|
'aria-invalid': ariaInvalid,
|
|
46
48
|
onValueChange,
|
|
49
|
+
oninput,
|
|
47
50
|
...restProps
|
|
48
51
|
}: Props = $props();
|
|
49
52
|
|
|
@@ -62,6 +65,7 @@
|
|
|
62
65
|
const target = e.target as HTMLInputElement;
|
|
63
66
|
value = target.value;
|
|
64
67
|
onValueChange?.(target.value);
|
|
68
|
+
oninput?.(e as Event & { currentTarget: HTMLInputElement });
|
|
65
69
|
}
|
|
66
70
|
</script>
|
|
67
71
|
|
|
@@ -20,6 +20,10 @@ interface Props {
|
|
|
20
20
|
/** Whether the input has an error */
|
|
21
21
|
'aria-invalid'?: boolean | 'true' | 'false';
|
|
22
22
|
onValueChange?: (value: string) => void;
|
|
23
|
+
/** Native input listener; runs after the value is updated */
|
|
24
|
+
oninput?: (e: Event & {
|
|
25
|
+
currentTarget: HTMLInputElement;
|
|
26
|
+
}) => void;
|
|
23
27
|
[key: string]: unknown;
|
|
24
28
|
}
|
|
25
29
|
declare const Input: import("svelte").Component<Props, {}, "value">;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A keyboard key or shortcut. Pass `keys` for a combination ("Mod" shows as
|
|
4
|
+
⌘ on Apple devices and Ctrl elsewhere), or `children` for a single key.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts" module>
|
|
7
|
+
const SYMBOLS: Record<string, { apple: string; other: string; name: string }> = {
|
|
8
|
+
mod: { apple: '⌘', other: 'Ctrl', name: 'Command' },
|
|
9
|
+
ctrl: { apple: '⌃', other: 'Ctrl', name: 'Control' },
|
|
10
|
+
alt: { apple: '⌥', other: 'Alt', name: 'Option' },
|
|
11
|
+
shift: { apple: '⇧', other: 'Shift', name: 'Shift' },
|
|
12
|
+
enter: { apple: '↵', other: 'Enter', name: 'Enter' },
|
|
13
|
+
escape: { apple: 'Esc', other: 'Esc', name: 'Escape' },
|
|
14
|
+
esc: { apple: 'Esc', other: 'Esc', name: 'Escape' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Whether shortcuts should use Apple modifier symbols (⌘, ⌥, ⇧). */
|
|
18
|
+
export function isApplePlatform(): boolean {
|
|
19
|
+
if (typeof navigator === 'undefined') return false;
|
|
20
|
+
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
|
|
21
|
+
const platform = nav.userAgentData?.platform ?? navigator.platform ?? '';
|
|
22
|
+
return /mac|iphone|ipad|ipod/i.test(platform);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The text shown for one key, and the name read out for it. */
|
|
26
|
+
export function formatKey(
|
|
27
|
+
key: string,
|
|
28
|
+
apple = isApplePlatform()
|
|
29
|
+
): { label: string; name: string } {
|
|
30
|
+
const known = SYMBOLS[key.toLowerCase()];
|
|
31
|
+
if (!known) return { label: key.length === 1 ? key.toUpperCase() : key, name: key };
|
|
32
|
+
const name = key.toLowerCase() === 'mod' && !apple ? 'Control' : known.name;
|
|
33
|
+
return { label: apple ? known.apple : known.other, name };
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<script lang="ts">
|
|
38
|
+
import type { Snippet } from 'svelte';
|
|
39
|
+
import { cn } from '../utils.js';
|
|
40
|
+
|
|
41
|
+
interface Props {
|
|
42
|
+
/** Keys of a combination, e.g. ['Mod', 'K'] */
|
|
43
|
+
keys?: string[];
|
|
44
|
+
size?: 'sm' | 'md';
|
|
45
|
+
class?: string;
|
|
46
|
+
children?: Snippet;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let { keys, size = 'sm', class: className, children }: Props = $props();
|
|
50
|
+
|
|
51
|
+
// Resolved after mount so server and client render the same markup
|
|
52
|
+
let apple = $state(false);
|
|
53
|
+
$effect(() => {
|
|
54
|
+
apple = isApplePlatform();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const keyClass = $derived(
|
|
58
|
+
cn(
|
|
59
|
+
'inline-flex items-center justify-center rounded border border-border bg-muted font-sans font-medium text-muted-foreground shadow-[inset_0_-1px_0_hsl(var(--border))]',
|
|
60
|
+
size === 'sm' ? 'h-5 min-w-5 px-1 text-[11px]' : 'h-6 min-w-6 px-1.5 text-xs'
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
const formatted = $derived((keys ?? []).map((k) => formatKey(k, apple)));
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
{#if keys?.length}
|
|
67
|
+
<span
|
|
68
|
+
class={cn('inline-flex items-center gap-0.5', className)}
|
|
69
|
+
aria-label={formatted.map((k) => k.name).join(' + ')}
|
|
70
|
+
role="img"
|
|
71
|
+
>
|
|
72
|
+
{#each formatted as key, i (i)}
|
|
73
|
+
<kbd class={keyClass} aria-hidden="true">{key.label}</kbd>
|
|
74
|
+
{/each}
|
|
75
|
+
</span>
|
|
76
|
+
{:else}
|
|
77
|
+
<kbd class={cn(keyClass, className)}
|
|
78
|
+
>{#if children}{@render children()}{/if}</kbd
|
|
79
|
+
>
|
|
80
|
+
{/if}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Whether shortcuts should use Apple modifier symbols (⌘, ⌥, ⇧). */
|
|
2
|
+
export declare function isApplePlatform(): boolean;
|
|
3
|
+
/** The text shown for one key, and the name read out for it. */
|
|
4
|
+
export declare function formatKey(key: string, apple?: boolean): {
|
|
5
|
+
label: string;
|
|
6
|
+
name: string;
|
|
7
|
+
};
|
|
8
|
+
import type { Snippet } from 'svelte';
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Keys of a combination, e.g. ['Mod', 'K'] */
|
|
11
|
+
keys?: string[];
|
|
12
|
+
size?: 'sm' | 'md';
|
|
13
|
+
class?: string;
|
|
14
|
+
children?: Snippet;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A keyboard key or shortcut. Pass `keys` for a combination ("Mod" shows as
|
|
18
|
+
* ⌘ on Apple devices and Ctrl elsewhere), or `children` for a single key.
|
|
19
|
+
*/
|
|
20
|
+
declare const Kbd: import("svelte").Component<Props, {}, "">;
|
|
21
|
+
type Kbd = ReturnType<typeof Kbd>;
|
|
22
|
+
export default Kbd;
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<LabelPrimitive.Root
|
|
24
24
|
for={htmlFor}
|
|
25
25
|
class={cn(
|
|
26
|
-
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground',
|
|
26
|
+
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-[hsl(var(--label-foreground,var(--foreground)))]',
|
|
27
27
|
disabled && 'opacity-50 cursor-not-allowed',
|
|
28
28
|
className
|
|
29
29
|
)}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Floating content anchored to a trigger (Bits UI Popover). The `trigger`
|
|
4
|
+
snippet receives `{ props }`: spread them on your own focusable element
|
|
5
|
+
(`<Button {...props}>`). Without a trigger, anchor it with `anchor` (an
|
|
6
|
+
element or selector) and control it through `open`, e.g. a suggestion list
|
|
7
|
+
under a text field that must keep focus (`autoFocus={false}`).
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import { Popover as PopoverPrimitive } from 'bits-ui';
|
|
11
|
+
import type { Snippet } from 'svelte';
|
|
12
|
+
import { cn } from '../utils.js';
|
|
13
|
+
import TriggerChild, { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Whether the popover is shown (bindable) */
|
|
17
|
+
open?: boolean;
|
|
18
|
+
onOpenChange?: (open: boolean) => void;
|
|
19
|
+
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
20
|
+
align?: 'start' | 'center' | 'end';
|
|
21
|
+
sideOffset?: number;
|
|
22
|
+
/** Element (or selector) to position against instead of the trigger */
|
|
23
|
+
anchor?: HTMLElement | string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Move focus into the content when it opens (default true). Set false to
|
|
26
|
+
* keep focus where it is, e.g. in a text field driving the popover.
|
|
27
|
+
*/
|
|
28
|
+
autoFocus?: boolean;
|
|
29
|
+
/** Keep Tab inside the content while open (default false) */
|
|
30
|
+
trapFocus?: boolean;
|
|
31
|
+
/** Accessible name for the content when it has no visible heading */
|
|
32
|
+
'aria-label'?: string;
|
|
33
|
+
class?: string;
|
|
34
|
+
trigger?: Snippet<[TriggerSnippetProps]>;
|
|
35
|
+
children: Snippet;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let {
|
|
39
|
+
open = $bindable(false),
|
|
40
|
+
onOpenChange,
|
|
41
|
+
side = 'bottom',
|
|
42
|
+
align = 'center',
|
|
43
|
+
sideOffset = 6,
|
|
44
|
+
anchor = null,
|
|
45
|
+
autoFocus = true,
|
|
46
|
+
trapFocus = false,
|
|
47
|
+
'aria-label': ariaLabel,
|
|
48
|
+
class: className,
|
|
49
|
+
trigger,
|
|
50
|
+
children,
|
|
51
|
+
}: Props = $props();
|
|
52
|
+
|
|
53
|
+
const uid = $props.id();
|
|
54
|
+
let triggerId = $state(`${uid}-trigger`);
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<PopoverPrimitive.Root bind:open {onOpenChange}>
|
|
58
|
+
{#if trigger}
|
|
59
|
+
<PopoverPrimitive.Trigger id={triggerId}>
|
|
60
|
+
{#snippet child({ props })}
|
|
61
|
+
<TriggerChild
|
|
62
|
+
{props}
|
|
63
|
+
{trigger}
|
|
64
|
+
fallbackClass="inline-flex"
|
|
65
|
+
onfallback={() => (triggerId = `${uid}-trigger-wrap`)}
|
|
66
|
+
/>
|
|
67
|
+
{/snippet}
|
|
68
|
+
</PopoverPrimitive.Trigger>
|
|
69
|
+
{/if}
|
|
70
|
+
<PopoverPrimitive.Portal>
|
|
71
|
+
<PopoverPrimitive.Content
|
|
72
|
+
{side}
|
|
73
|
+
{align}
|
|
74
|
+
{sideOffset}
|
|
75
|
+
{trapFocus}
|
|
76
|
+
customAnchor={anchor}
|
|
77
|
+
collisionPadding={8}
|
|
78
|
+
aria-label={ariaLabel}
|
|
79
|
+
onOpenAutoFocus={(e) => {
|
|
80
|
+
if (!autoFocus) e.preventDefault();
|
|
81
|
+
}}
|
|
82
|
+
class={cn(
|
|
83
|
+
'z-50 w-72 rounded-lg border border-border bg-popover p-3 text-sm text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 motion-reduce:animate-none',
|
|
84
|
+
className
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{@render children()}
|
|
88
|
+
</PopoverPrimitive.Content>
|
|
89
|
+
</PopoverPrimitive.Portal>
|
|
90
|
+
</PopoverPrimitive.Root>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Whether the popover is shown (bindable) */
|
|
5
|
+
open?: boolean;
|
|
6
|
+
onOpenChange?: (open: boolean) => void;
|
|
7
|
+
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
8
|
+
align?: 'start' | 'center' | 'end';
|
|
9
|
+
sideOffset?: number;
|
|
10
|
+
/** Element (or selector) to position against instead of the trigger */
|
|
11
|
+
anchor?: HTMLElement | string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Move focus into the content when it opens (default true). Set false to
|
|
14
|
+
* keep focus where it is, e.g. in a text field driving the popover.
|
|
15
|
+
*/
|
|
16
|
+
autoFocus?: boolean;
|
|
17
|
+
/** Keep Tab inside the content while open (default false) */
|
|
18
|
+
trapFocus?: boolean;
|
|
19
|
+
/** Accessible name for the content when it has no visible heading */
|
|
20
|
+
'aria-label'?: string;
|
|
21
|
+
class?: string;
|
|
22
|
+
trigger?: Snippet<[TriggerSnippetProps]>;
|
|
23
|
+
children: Snippet;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Floating content anchored to a trigger (Bits UI Popover). The `trigger`
|
|
27
|
+
* snippet receives `{ props }`: spread them on your own focusable element
|
|
28
|
+
* (`<Button {...props}>`). Without a trigger, anchor it with `anchor` (an
|
|
29
|
+
* element or selector) and control it through `open`, e.g. a suggestion list
|
|
30
|
+
* under a text field that must keep focus (`autoFocus={false}`).
|
|
31
|
+
*/
|
|
32
|
+
declare const Popover: import("svelte").Component<Props, {}, "open">;
|
|
33
|
+
type Popover = ReturnType<typeof Popover>;
|
|
34
|
+
export default Popover;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Two panes split by a draggable handle. `size` is the start pane's share in
|
|
4
|
+
percent; the end pane takes the rest. The handle is a focusable
|
|
5
|
+
`separator`: arrow keys move it by `step`, Home/End jump to `min`/`max`.
|
|
6
|
+
With `persistKey` the size is remembered in localStorage.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts" module>
|
|
9
|
+
const STORAGE_PREFIX = 'classic-theme:resizable:';
|
|
10
|
+
|
|
11
|
+
export function clampSize(value: number, min: number, max: number): number {
|
|
12
|
+
return Math.min(Math.max(value, min), max);
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<script lang="ts">
|
|
17
|
+
import type { Snippet } from 'svelte';
|
|
18
|
+
import { cn } from '../utils.js';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
/** Start pane share in percent (bindable) */
|
|
22
|
+
size?: number;
|
|
23
|
+
/** Smallest start share in percent (default 20) */
|
|
24
|
+
min?: number;
|
|
25
|
+
/** Largest start share in percent (default 80) */
|
|
26
|
+
max?: number;
|
|
27
|
+
/** Keyboard step in percent (default 5) */
|
|
28
|
+
step?: number;
|
|
29
|
+
/** `horizontal`: side by side (default); `vertical`: stacked */
|
|
30
|
+
orientation?: 'horizontal' | 'vertical';
|
|
31
|
+
/** Remember the size under this key */
|
|
32
|
+
persistKey?: string;
|
|
33
|
+
/** Accessible name for the handle (default "Resize panes") */
|
|
34
|
+
handleLabel?: string;
|
|
35
|
+
onResize?: (size: number) => void;
|
|
36
|
+
class?: string;
|
|
37
|
+
startClass?: string;
|
|
38
|
+
endClass?: string;
|
|
39
|
+
start: Snippet;
|
|
40
|
+
end: Snippet;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let {
|
|
44
|
+
size = $bindable(50),
|
|
45
|
+
min = 20,
|
|
46
|
+
max = 80,
|
|
47
|
+
step = 5,
|
|
48
|
+
orientation = 'horizontal',
|
|
49
|
+
persistKey,
|
|
50
|
+
handleLabel = 'Resize panes',
|
|
51
|
+
onResize,
|
|
52
|
+
class: className,
|
|
53
|
+
startClass,
|
|
54
|
+
endClass,
|
|
55
|
+
start,
|
|
56
|
+
end,
|
|
57
|
+
}: Props = $props();
|
|
58
|
+
|
|
59
|
+
const uid = $props.id();
|
|
60
|
+
let container = $state<HTMLDivElement | null>(null);
|
|
61
|
+
let dragging = $state(false);
|
|
62
|
+
const horizontal = $derived(orientation === 'horizontal');
|
|
63
|
+
|
|
64
|
+
// Read the remembered size once, after mount (no storage during SSR)
|
|
65
|
+
$effect(() => {
|
|
66
|
+
if (!persistKey) return;
|
|
67
|
+
try {
|
|
68
|
+
const stored = Number(localStorage.getItem(STORAGE_PREFIX + persistKey));
|
|
69
|
+
if (Number.isFinite(stored) && stored > 0) size = clampSize(stored, min, max);
|
|
70
|
+
} catch {
|
|
71
|
+
// Storage blocked: keep the default size
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function set(next: number, save = true) {
|
|
76
|
+
const value = Math.round(clampSize(next, min, max) * 10) / 10;
|
|
77
|
+
if (value === size) return;
|
|
78
|
+
size = value;
|
|
79
|
+
onResize?.(value);
|
|
80
|
+
if (save) persist();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function persist() {
|
|
84
|
+
if (!persistKey) return;
|
|
85
|
+
try {
|
|
86
|
+
localStorage.setItem(STORAGE_PREFIX + persistKey, String(size));
|
|
87
|
+
} catch {
|
|
88
|
+
// Storage blocked: the size just isn't remembered
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onkeydown(e: KeyboardEvent) {
|
|
93
|
+
const back = horizontal ? 'ArrowLeft' : 'ArrowUp';
|
|
94
|
+
const forward = horizontal ? 'ArrowRight' : 'ArrowDown';
|
|
95
|
+
if (e.key === back) set(size - step);
|
|
96
|
+
else if (e.key === forward) set(size + step);
|
|
97
|
+
else if (e.key === 'Home') set(min);
|
|
98
|
+
else if (e.key === 'End') set(max);
|
|
99
|
+
else return;
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fromPointer(e: PointerEvent) {
|
|
104
|
+
if (!container) return;
|
|
105
|
+
const rect = container.getBoundingClientRect();
|
|
106
|
+
const total = horizontal ? rect.width : rect.height;
|
|
107
|
+
if (total <= 0) return;
|
|
108
|
+
const offset = horizontal ? e.clientX - rect.left : e.clientY - rect.top;
|
|
109
|
+
set((offset / total) * 100, false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function onpointerdown(e: PointerEvent) {
|
|
113
|
+
if (e.button !== 0) return;
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
dragging = true;
|
|
116
|
+
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function onpointermove(e: PointerEvent) {
|
|
120
|
+
if (dragging) fromPointer(e);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function onpointerup(e: PointerEvent) {
|
|
124
|
+
if (!dragging) return;
|
|
125
|
+
dragging = false;
|
|
126
|
+
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
|
|
127
|
+
persist();
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<div
|
|
132
|
+
bind:this={container}
|
|
133
|
+
class={cn('flex h-full min-h-0 w-full min-w-0', horizontal ? 'flex-row' : 'flex-col', className)}
|
|
134
|
+
data-dragging={dragging || undefined}
|
|
135
|
+
>
|
|
136
|
+
<div
|
|
137
|
+
id={`${uid}-start`}
|
|
138
|
+
class={cn('min-h-0 min-w-0 shrink-0 overflow-hidden', startClass)}
|
|
139
|
+
style={`flex-basis:${size}%`}
|
|
140
|
+
>
|
|
141
|
+
{@render start()}
|
|
142
|
+
</div>
|
|
143
|
+
<!-- A focusable separator is an interactive widget (WAI-ARIA window splitter) -->
|
|
144
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
145
|
+
<div
|
|
146
|
+
role="separator"
|
|
147
|
+
tabindex="0"
|
|
148
|
+
aria-label={handleLabel}
|
|
149
|
+
aria-orientation={horizontal ? 'vertical' : 'horizontal'}
|
|
150
|
+
aria-controls={`${uid}-start`}
|
|
151
|
+
aria-valuenow={size}
|
|
152
|
+
aria-valuemin={min}
|
|
153
|
+
aria-valuemax={max}
|
|
154
|
+
class={cn(
|
|
155
|
+
'group relative z-10 flex shrink-0 touch-none select-none items-center justify-center bg-border outline-none transition-colors hover:bg-primary/40 focus-visible:bg-primary data-[dragging]:bg-primary',
|
|
156
|
+
horizontal ? 'w-px cursor-col-resize' : 'h-px cursor-row-resize'
|
|
157
|
+
)}
|
|
158
|
+
data-dragging={dragging || undefined}
|
|
159
|
+
{onkeydown}
|
|
160
|
+
{onpointerdown}
|
|
161
|
+
{onpointermove}
|
|
162
|
+
{onpointerup}
|
|
163
|
+
onpointercancel={onpointerup}
|
|
164
|
+
>
|
|
165
|
+
<!-- Wider invisible hit area around the 1px line -->
|
|
166
|
+
<span
|
|
167
|
+
class={cn(
|
|
168
|
+
'absolute',
|
|
169
|
+
horizontal ? 'inset-y-0 -left-1.5 -right-1.5' : 'inset-x-0 -top-1.5 -bottom-1.5'
|
|
170
|
+
)}
|
|
171
|
+
aria-hidden="true"
|
|
172
|
+
></span>
|
|
173
|
+
</div>
|
|
174
|
+
<div class={cn('min-h-0 min-w-0 flex-1 overflow-hidden', endClass)}>
|
|
175
|
+
{@render end()}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{#if dragging}
|
|
180
|
+
<!-- Keeps the resize cursor and stops iframes in the panes from swallowing pointer moves -->
|
|
181
|
+
<div
|
|
182
|
+
class={cn('fixed inset-0 z-50', horizontal ? 'cursor-col-resize' : 'cursor-row-resize')}
|
|
183
|
+
aria-hidden="true"
|
|
184
|
+
></div>
|
|
185
|
+
{/if}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare function clampSize(value: number, min: number, max: number): number;
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Start pane share in percent (bindable) */
|
|
5
|
+
size?: number;
|
|
6
|
+
/** Smallest start share in percent (default 20) */
|
|
7
|
+
min?: number;
|
|
8
|
+
/** Largest start share in percent (default 80) */
|
|
9
|
+
max?: number;
|
|
10
|
+
/** Keyboard step in percent (default 5) */
|
|
11
|
+
step?: number;
|
|
12
|
+
/** `horizontal`: side by side (default); `vertical`: stacked */
|
|
13
|
+
orientation?: 'horizontal' | 'vertical';
|
|
14
|
+
/** Remember the size under this key */
|
|
15
|
+
persistKey?: string;
|
|
16
|
+
/** Accessible name for the handle (default "Resize panes") */
|
|
17
|
+
handleLabel?: string;
|
|
18
|
+
onResize?: (size: number) => void;
|
|
19
|
+
class?: string;
|
|
20
|
+
startClass?: string;
|
|
21
|
+
endClass?: string;
|
|
22
|
+
start: Snippet;
|
|
23
|
+
end: Snippet;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Two panes split by a draggable handle. `size` is the start pane's share in
|
|
27
|
+
* percent; the end pane takes the rest. The handle is a focusable
|
|
28
|
+
* `separator`: arrow keys move it by `step`, Home/End jump to `min`/`max`.
|
|
29
|
+
* With `persistKey` the size is remembered in localStorage.
|
|
30
|
+
*/
|
|
31
|
+
declare const ResizablePanes: import("svelte").Component<Props, {}, "size">;
|
|
32
|
+
type ResizablePanes = ReturnType<typeof ResizablePanes>;
|
|
33
|
+
export default ResizablePanes;
|
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
/** Whether the textarea has an error */
|
|
21
21
|
'aria-invalid'?: boolean | 'true' | 'false';
|
|
22
22
|
onValueChange?: (value: string) => void;
|
|
23
|
+
/** Native input listener; runs after the value is updated */
|
|
24
|
+
oninput?: (e: Event & { currentTarget: HTMLTextAreaElement }) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Grow with the content instead of showing a resize handle. `rows` is the
|
|
27
|
+
* minimum height; past `maxRows` the textarea scrolls.
|
|
28
|
+
*/
|
|
29
|
+
autosize?: boolean;
|
|
30
|
+
/** Upper bound for `autosize`, in rows (default 8) */
|
|
31
|
+
maxRows?: number;
|
|
32
|
+
/** The textarea element (bindable), e.g. to focus it */
|
|
33
|
+
ref?: HTMLTextAreaElement | null;
|
|
23
34
|
[key: string]: unknown;
|
|
24
35
|
}
|
|
25
36
|
|
|
@@ -38,17 +49,50 @@
|
|
|
38
49
|
'aria-describedby': ariaDescribedby,
|
|
39
50
|
'aria-invalid': ariaInvalid,
|
|
40
51
|
onValueChange,
|
|
52
|
+
oninput,
|
|
53
|
+
autosize = false,
|
|
54
|
+
maxRows = 8,
|
|
55
|
+
ref = $bindable(null),
|
|
41
56
|
...restProps
|
|
42
57
|
}: Props = $props();
|
|
43
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Fit the height to the content: measure at `auto`, then clamp between
|
|
61
|
+
* `rows` and `maxRows` lines (plus padding and border, read from the
|
|
62
|
+
* computed style so consumer classes are respected).
|
|
63
|
+
*/
|
|
64
|
+
function fit(el: HTMLTextAreaElement) {
|
|
65
|
+
const style = getComputedStyle(el);
|
|
66
|
+
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 20;
|
|
67
|
+
const chrome =
|
|
68
|
+
parseFloat(style.paddingTop) +
|
|
69
|
+
parseFloat(style.paddingBottom) +
|
|
70
|
+
parseFloat(style.borderTopWidth) +
|
|
71
|
+
parseFloat(style.borderBottomWidth);
|
|
72
|
+
const min = rows * lineHeight + chrome;
|
|
73
|
+
const max = Math.max(rows, maxRows) * lineHeight + chrome;
|
|
74
|
+
el.style.height = 'auto';
|
|
75
|
+
const next = Math.min(Math.max(el.scrollHeight, min), max);
|
|
76
|
+
el.style.height = `${next}px`;
|
|
77
|
+
el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
$effect(() => {
|
|
81
|
+
// Re-fit when the value changes from outside too (cleared after send, prefilled)
|
|
82
|
+
void value;
|
|
83
|
+
if (autosize && ref) fit(ref);
|
|
84
|
+
});
|
|
85
|
+
|
|
44
86
|
function handleInput(e: Event) {
|
|
45
87
|
const target = e.target as HTMLTextAreaElement;
|
|
46
88
|
value = target.value;
|
|
47
89
|
onValueChange?.(target.value);
|
|
90
|
+
oninput?.(e as Event & { currentTarget: HTMLTextAreaElement });
|
|
48
91
|
}
|
|
49
92
|
</script>
|
|
50
93
|
|
|
51
94
|
<textarea
|
|
95
|
+
bind:this={ref}
|
|
52
96
|
{value}
|
|
53
97
|
{placeholder}
|
|
54
98
|
{disabled}
|
|
@@ -63,6 +107,7 @@
|
|
|
63
107
|
aria-invalid={ariaInvalid}
|
|
64
108
|
class={cn(
|
|
65
109
|
'flex min-h-[80px] w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-base sm:text-sm ring-offset-background placeholder:text-gray-500 dark:placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y',
|
|
110
|
+
autosize && 'min-h-0 resize-none',
|
|
66
111
|
ariaInvalid === true || ariaInvalid === 'true'
|
|
67
112
|
? 'border-destructive focus-visible:ring-destructive'
|
|
68
113
|
: '',
|
|
@@ -17,8 +17,21 @@ interface Props {
|
|
|
17
17
|
/** Whether the textarea has an error */
|
|
18
18
|
'aria-invalid'?: boolean | 'true' | 'false';
|
|
19
19
|
onValueChange?: (value: string) => void;
|
|
20
|
+
/** Native input listener; runs after the value is updated */
|
|
21
|
+
oninput?: (e: Event & {
|
|
22
|
+
currentTarget: HTMLTextAreaElement;
|
|
23
|
+
}) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Grow with the content instead of showing a resize handle. `rows` is the
|
|
26
|
+
* minimum height; past `maxRows` the textarea scrolls.
|
|
27
|
+
*/
|
|
28
|
+
autosize?: boolean;
|
|
29
|
+
/** Upper bound for `autosize`, in rows (default 8) */
|
|
30
|
+
maxRows?: number;
|
|
31
|
+
/** The textarea element (bindable), e.g. to focus it */
|
|
32
|
+
ref?: HTMLTextAreaElement | null;
|
|
20
33
|
[key: string]: unknown;
|
|
21
34
|
}
|
|
22
|
-
declare const Textarea: import("svelte").Component<Props, {}, "value">;
|
|
35
|
+
declare const Textarea: import("svelte").Component<Props, {}, "value" | "ref">;
|
|
23
36
|
type Textarea = ReturnType<typeof Textarea>;
|
|
24
37
|
export default Textarea;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { cn } from '../utils.js';
|
|
4
|
+
import type { ToolbarCountProps } from '../utils/result-count.js';
|
|
5
|
+
import ToolbarCount from './ToolbarCount.svelte';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Toolbar - dark strip for list controls (search, filters, result count,
|
|
@@ -17,25 +19,28 @@
|
|
|
17
19
|
label: string;
|
|
18
20
|
/** Leading controls (search, filters, result count) */
|
|
19
21
|
children?: Snippet;
|
|
20
|
-
/**
|
|
22
|
+
/** Result count after the leading controls, e.g. `{ total, noun: 'home', page }` */
|
|
23
|
+
count?: ToolbarCountProps;
|
|
24
|
+
/** Right-aligned cluster: scope pickers (All/Mine), SegmentedControl, ViewToggle */
|
|
21
25
|
end?: Snippet;
|
|
22
26
|
class?: string;
|
|
23
27
|
[key: string]: unknown;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
let { label, children, end, class: className, ...restProps }: Props = $props();
|
|
30
|
+
let { label, children, count, end, class: className, ...restProps }: Props = $props();
|
|
27
31
|
</script>
|
|
28
32
|
|
|
29
33
|
<div
|
|
30
34
|
role="group"
|
|
31
35
|
aria-label={label}
|
|
32
36
|
class={cn(
|
|
33
|
-
'flex flex-wrap items-center gap-2 rounded-lg bg-toolbar px-3 py-2 text-toolbar-foreground [--secondary-border:var(--toolbar-border)]',
|
|
37
|
+
'flex flex-wrap items-center gap-2 rounded-lg bg-toolbar px-3 py-2 text-toolbar-foreground [--label-foreground:var(--toolbar-foreground)] [--secondary-border:var(--toolbar-border)]',
|
|
34
38
|
className
|
|
35
39
|
)}
|
|
36
40
|
{...restProps}
|
|
37
41
|
>
|
|
38
42
|
{@render children?.()}
|
|
43
|
+
{#if count}<ToolbarCount {...count} />{/if}
|
|
39
44
|
{#if end}
|
|
40
45
|
<div class="ml-auto flex flex-wrap items-center gap-2">{@render end()}</div>
|
|
41
46
|
{/if}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { ToolbarCountProps } from '../utils/result-count.js';
|
|
2
3
|
/**
|
|
3
4
|
* Toolbar - dark strip for list controls (search, filters, result count,
|
|
4
5
|
* grouping and view toggles) under a page header.
|
|
@@ -14,7 +15,9 @@ interface Props {
|
|
|
14
15
|
label: string;
|
|
15
16
|
/** Leading controls (search, filters, result count) */
|
|
16
17
|
children?: Snippet;
|
|
17
|
-
/**
|
|
18
|
+
/** Result count after the leading controls, e.g. `{ total, noun: 'home', page }` */
|
|
19
|
+
count?: ToolbarCountProps;
|
|
20
|
+
/** Right-aligned cluster: scope pickers (All/Mine), SegmentedControl, ViewToggle */
|
|
18
21
|
end?: Snippet;
|
|
19
22
|
class?: string;
|
|
20
23
|
[key: string]: unknown;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { resultCountText, type ToolbarCountProps } from '../utils/result-count.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ToolbarCount - the result count on a Toolbar / FilterToolbar strip, in the
|
|
6
|
+
* one wording `resultCountText` defines ("Showing 1–50 of 7,289 homes",
|
|
7
|
+
* "12 of 40 homes", "40 homes", "No homes found"). Inherits the strip's
|
|
8
|
+
* light text and announces changes politely. Renders nothing while `total`
|
|
9
|
+
* is undefined (first load). Usually passed as the strip's `count` prop.
|
|
10
|
+
*/
|
|
11
|
+
let { note, ...input }: ToolbarCountProps = $props();
|
|
12
|
+
|
|
13
|
+
const text = $derived(resultCountText(input));
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
{#if text}
|
|
17
|
+
<span class="whitespace-nowrap text-sm tabular-nums" role="status">
|
|
18
|
+
{text}{note && input.total ? ` · ${note}` : ''}
|
|
19
|
+
</span>
|
|
20
|
+
{/if}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../utils.js';
|
|
3
|
+
import Input from './Input.svelte';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ToolbarSearch - the search field for a Toolbar / FilterToolbar strip:
|
|
7
|
+
* full width on phones, a fixed `sm:w-64` beside the filters, and an
|
|
8
|
+
* accessible name (strips have no visible labels). End `placeholder` with
|
|
9
|
+
* "…", e.g. "Search homes…".
|
|
10
|
+
*/
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Accessible name, e.g. "Search homes" */
|
|
13
|
+
label: string;
|
|
14
|
+
value?: string;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
id?: string;
|
|
17
|
+
class?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
label,
|
|
23
|
+
value = $bindable(''),
|
|
24
|
+
placeholder,
|
|
25
|
+
id,
|
|
26
|
+
class: className,
|
|
27
|
+
...restProps
|
|
28
|
+
}: Props = $props();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<Input
|
|
32
|
+
{id}
|
|
33
|
+
type="search"
|
|
34
|
+
aria-label={label}
|
|
35
|
+
placeholder={placeholder ?? `${label}…`}
|
|
36
|
+
class={cn('w-full sm:w-64', className)}
|
|
37
|
+
bind:value
|
|
38
|
+
{...restProps}
|
|
39
|
+
/>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolbarSearch - the search field for a Toolbar / FilterToolbar strip:
|
|
3
|
+
* full width on phones, a fixed `sm:w-64` beside the filters, and an
|
|
4
|
+
* accessible name (strips have no visible labels). End `placeholder` with
|
|
5
|
+
* "…", e.g. "Search homes…".
|
|
6
|
+
*/
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Accessible name, e.g. "Search homes" */
|
|
9
|
+
label: string;
|
|
10
|
+
value?: string;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
class?: string;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
declare const ToolbarSearch: import("svelte").Component<Props, {}, "value">;
|
|
17
|
+
type ToolbarSearch = ReturnType<typeof ToolbarSearch>;
|
|
18
|
+
export default ToolbarSearch;
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export { default as Checkbox } from './components/Checkbox.svelte';
|
|
|
17
17
|
export { default as Switch } from './components/Switch.svelte';
|
|
18
18
|
export { default as Separator } from './components/Separator.svelte';
|
|
19
19
|
export { default as Icon, type IconName } from './components/Icon.svelte';
|
|
20
|
+
export { default as CopyButton } from './components/CopyButton.svelte';
|
|
21
|
+
export { default as Kbd, formatKey, isApplePlatform } from './components/Kbd.svelte';
|
|
20
22
|
export { default as Card } from './components/Card.svelte';
|
|
21
23
|
export { default as CardHeader } from './components/CardHeader.svelte';
|
|
22
24
|
export { default as CardTitle } from './components/CardTitle.svelte';
|
|
@@ -35,15 +37,19 @@ export { default as ProgressBar } from './components/ProgressBar.svelte';
|
|
|
35
37
|
export { default as LoadingOverlay } from './components/LoadingOverlay.svelte';
|
|
36
38
|
export { default as LoadingPage } from './components/LoadingPage.svelte';
|
|
37
39
|
export { default as LoadingSection } from './components/LoadingSection.svelte';
|
|
40
|
+
export { default as EmptyState } from './components/EmptyState.svelte';
|
|
38
41
|
export { default as LiveRegion } from './components/LiveRegion.svelte';
|
|
39
42
|
export { default as SkipLink } from './components/SkipLink.svelte';
|
|
40
43
|
export { default as Dialog } from './components/Dialog.svelte';
|
|
41
44
|
export { default as AlertDialog } from './components/AlertDialog.svelte';
|
|
42
45
|
export { default as Tooltip } from './components/Tooltip.svelte';
|
|
46
|
+
export { default as Popover } from './components/Popover.svelte';
|
|
47
|
+
export { default as Collapsible } from './components/Collapsible.svelte';
|
|
43
48
|
export { default as DropdownMenu } from './components/DropdownMenu.svelte';
|
|
44
49
|
export { default as ContextMenu } from './components/ContextMenu.svelte';
|
|
45
50
|
export { default as AppLauncher, type AppLauncherItem } from './components/AppLauncher.svelte';
|
|
46
51
|
export { default as AppLauncherGrid } from './components/AppLauncherGrid.svelte';
|
|
52
|
+
export { default as ResizablePanes, clampSize } from './components/ResizablePanes.svelte';
|
|
47
53
|
export { default as DataPanel } from './components/DataPanel.svelte';
|
|
48
54
|
export { default as DataPanelHeader } from './components/DataPanelHeader.svelte';
|
|
49
55
|
export { default as DataPanelContent } from './components/DataPanelContent.svelte';
|
|
@@ -65,6 +71,9 @@ export { default as Tabs } from './components/Tabs.svelte';
|
|
|
65
71
|
export { default as TabPanel } from './components/TabPanel.svelte';
|
|
66
72
|
export { default as Toolbar } from './components/Toolbar.svelte';
|
|
67
73
|
export { default as FilterToolbar } from './components/FilterToolbar.svelte';
|
|
74
|
+
export { default as ToolbarCount } from './components/ToolbarCount.svelte';
|
|
75
|
+
export { default as ToolbarSearch } from './components/ToolbarSearch.svelte';
|
|
76
|
+
export { resultCountText, type ResultCountInput, type ToolbarCountProps, } from './utils/result-count.js';
|
|
68
77
|
export { default as FilterPanel } from './components/FilterPanel.svelte';
|
|
69
78
|
export { default as ActiveFilterChips, type ActiveFilterChip, } from './components/ActiveFilterChips.svelte';
|
|
70
79
|
export { default as SegmentedControl, type SegmentedControlOption, } from './components/SegmentedControl.svelte';
|
package/dist/lib/index.js
CHANGED
|
@@ -18,6 +18,8 @@ export { default as Checkbox } from './components/Checkbox.svelte';
|
|
|
18
18
|
export { default as Switch } from './components/Switch.svelte';
|
|
19
19
|
export { default as Separator } from './components/Separator.svelte';
|
|
20
20
|
export { default as Icon } from './components/Icon.svelte';
|
|
21
|
+
export { default as CopyButton } from './components/CopyButton.svelte';
|
|
22
|
+
export { default as Kbd, formatKey, isApplePlatform } from './components/Kbd.svelte';
|
|
21
23
|
// Card Components
|
|
22
24
|
export { default as Card } from './components/Card.svelte';
|
|
23
25
|
export { default as CardHeader } from './components/CardHeader.svelte';
|
|
@@ -41,6 +43,7 @@ export { default as ProgressBar } from './components/ProgressBar.svelte';
|
|
|
41
43
|
export { default as LoadingOverlay } from './components/LoadingOverlay.svelte';
|
|
42
44
|
export { default as LoadingPage } from './components/LoadingPage.svelte';
|
|
43
45
|
export { default as LoadingSection } from './components/LoadingSection.svelte';
|
|
46
|
+
export { default as EmptyState } from './components/EmptyState.svelte';
|
|
44
47
|
// Accessibility Components
|
|
45
48
|
export { default as LiveRegion } from './components/LiveRegion.svelte';
|
|
46
49
|
export { default as SkipLink } from './components/SkipLink.svelte';
|
|
@@ -48,10 +51,14 @@ export { default as SkipLink } from './components/SkipLink.svelte';
|
|
|
48
51
|
export { default as Dialog } from './components/Dialog.svelte';
|
|
49
52
|
export { default as AlertDialog } from './components/AlertDialog.svelte';
|
|
50
53
|
export { default as Tooltip } from './components/Tooltip.svelte';
|
|
54
|
+
export { default as Popover } from './components/Popover.svelte';
|
|
55
|
+
export { default as Collapsible } from './components/Collapsible.svelte';
|
|
51
56
|
export { default as DropdownMenu } from './components/DropdownMenu.svelte';
|
|
52
57
|
export { default as ContextMenu } from './components/ContextMenu.svelte';
|
|
53
58
|
export { default as AppLauncher } from './components/AppLauncher.svelte';
|
|
54
59
|
export { default as AppLauncherGrid } from './components/AppLauncherGrid.svelte';
|
|
60
|
+
// Layout primitives
|
|
61
|
+
export { default as ResizablePanes, clampSize } from './components/ResizablePanes.svelte';
|
|
55
62
|
// DataPanel Components
|
|
56
63
|
export { default as DataPanel } from './components/DataPanel.svelte';
|
|
57
64
|
export { default as DataPanelHeader } from './components/DataPanelHeader.svelte';
|
|
@@ -78,6 +85,9 @@ export { default as TabPanel } from './components/TabPanel.svelte';
|
|
|
78
85
|
// Toolbar controls
|
|
79
86
|
export { default as Toolbar } from './components/Toolbar.svelte';
|
|
80
87
|
export { default as FilterToolbar } from './components/FilterToolbar.svelte';
|
|
88
|
+
export { default as ToolbarCount } from './components/ToolbarCount.svelte';
|
|
89
|
+
export { default as ToolbarSearch } from './components/ToolbarSearch.svelte';
|
|
90
|
+
export { resultCountText, } from './utils/result-count.js';
|
|
81
91
|
export { default as FilterPanel } from './components/FilterPanel.svelte';
|
|
82
92
|
export { default as ActiveFilterChips, } from './components/ActiveFilterChips.svelte';
|
|
83
93
|
export { default as SegmentedControl, } from './components/SegmentedControl.svelte';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one result-count wording for list toolbars (`ToolbarCount`, and the
|
|
3
|
+
* `count` prop on `Toolbar` / `FilterToolbar`):
|
|
4
|
+
*
|
|
5
|
+
* - server-paged, more than one page: "Showing 1–50 of 7,289 homes"
|
|
6
|
+
* - locally filtered (`of` = unfiltered size): "12 of 40 homes"
|
|
7
|
+
* - otherwise: "40 homes" / "1 home"
|
|
8
|
+
* - nothing matches: `empty` (default "No homes found")
|
|
9
|
+
*
|
|
10
|
+
* Returns '' while the first load is pending (`total` undefined), so the strip
|
|
11
|
+
* never flashes "No homes found" before the data lands.
|
|
12
|
+
*/
|
|
13
|
+
export interface ResultCountInput {
|
|
14
|
+
/** Rows matching the current search/filters (server total or filtered length); undefined while loading. */
|
|
15
|
+
total: number | undefined | null;
|
|
16
|
+
/** Singular noun, e.g. "home". */
|
|
17
|
+
noun: string;
|
|
18
|
+
/** Plural noun when it isn't `${noun}s`, e.g. "entries". */
|
|
19
|
+
plural?: string;
|
|
20
|
+
/** Server paging: renders the "Showing a–b of N" range when total spans pages. */
|
|
21
|
+
page?: {
|
|
22
|
+
page: number;
|
|
23
|
+
limit: number;
|
|
24
|
+
};
|
|
25
|
+
/** Unfiltered size for locally filtered lists: "12 of 40 homes". */
|
|
26
|
+
of?: number;
|
|
27
|
+
/** Zero-match text (default "No <plural> found"). */
|
|
28
|
+
empty?: string;
|
|
29
|
+
}
|
|
30
|
+
/** `ResultCountInput` plus a trailing remark after " · " (falsy hides it). */
|
|
31
|
+
export interface ToolbarCountProps extends ResultCountInput {
|
|
32
|
+
note?: string | false | null;
|
|
33
|
+
}
|
|
34
|
+
export declare function resultCountText({ total, noun, plural, page, of, empty, }: ResultCountInput): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const fmt = (n) => n.toLocaleString('en-US');
|
|
2
|
+
export function resultCountText({ total, noun, plural = `${noun}s`, page, of, empty, }) {
|
|
3
|
+
if (total == null)
|
|
4
|
+
return '';
|
|
5
|
+
if (total === 0)
|
|
6
|
+
return empty ?? `No ${plural} found`;
|
|
7
|
+
if (page && total > page.limit) {
|
|
8
|
+
const start = (page.page - 1) * page.limit + 1;
|
|
9
|
+
const end = Math.min(page.page * page.limit, total);
|
|
10
|
+
return `Showing ${fmt(start)}–${fmt(end)} of ${fmt(total)} ${plural}`;
|
|
11
|
+
}
|
|
12
|
+
if (of !== undefined && of !== total)
|
|
13
|
+
return `${fmt(total)} of ${fmt(of)} ${plural}`;
|
|
14
|
+
return `${fmt(total)} ${total === 1 ? noun : plural}`;
|
|
15
|
+
}
|