@classic-homes/theme-svelte 1.39.0 → 1.41.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/FileUpload.svelte +14 -34
- package/dist/lib/components/FileUpload.svelte.d.ts +6 -1
- package/dist/lib/components/FilterToolbar.svelte +7 -1
- package/dist/lib/components/FilterToolbar.svelte.d.ts +4 -1
- package/dist/lib/components/Label.svelte +1 -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 +4 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/utils/file-accept.d.ts +26 -0
- package/dist/lib/utils/file-accept.js +67 -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
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { FileMetadata, MediaItem } from '../types/components.js';
|
|
17
17
|
import { cn } from '../utils.js';
|
|
18
|
+
import { acceptLabel, matchesAccept } from '../utils/file-accept.js';
|
|
18
19
|
import { formatDuration } from '../utils/media.js';
|
|
19
20
|
import Button from './Button.svelte';
|
|
20
21
|
import Lightbox from './media/Lightbox.svelte';
|
|
@@ -29,7 +30,12 @@
|
|
|
29
30
|
maxFiles?: number;
|
|
30
31
|
/** Maximum file size in bytes (default: 10MB) */
|
|
31
32
|
maxSizeBytes?: number;
|
|
32
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Accepted file types, as `<input accept>` entries: MIME types
|
|
35
|
+
* (`application/pdf`), wildcards (`image/*`) or extensions (`.dxf`,
|
|
36
|
+
* matched case-insensitively against the file name). A file passes when
|
|
37
|
+
* any entry matches.
|
|
38
|
+
*/
|
|
33
39
|
acceptedTypes?: string[];
|
|
34
40
|
/** Whether upload is disabled */
|
|
35
41
|
disabled?: boolean;
|
|
@@ -107,29 +113,9 @@
|
|
|
107
113
|
/**
|
|
108
114
|
* Readable label for the accepted types shown in the drop zone.
|
|
109
115
|
* Uses the explicit `typesLabel` when provided, otherwise groups the
|
|
110
|
-
* accepted
|
|
116
|
+
* accepted types into friendly names (Images, PDF, Videos, DXF, ...).
|
|
111
117
|
*/
|
|
112
|
-
const acceptedTypesLabel = $derived
|
|
113
|
-
if (typesLabel) return typesLabel;
|
|
114
|
-
|
|
115
|
-
const groups: string[] = [];
|
|
116
|
-
const add = (name: string) => {
|
|
117
|
-
if (!groups.includes(name)) groups.push(name);
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
for (const type of acceptedTypes) {
|
|
121
|
-
if (type.startsWith('image/')) add('Images');
|
|
122
|
-
else if (type === 'application/pdf') add('PDF');
|
|
123
|
-
else if (type.startsWith('video/')) add('Videos');
|
|
124
|
-
else if (type.startsWith('audio/')) add('Audio');
|
|
125
|
-
else {
|
|
126
|
-
const subtype = type.split('/')[1];
|
|
127
|
-
add(subtype ? subtype.toUpperCase() : type);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return groups.join(', ');
|
|
132
|
-
});
|
|
118
|
+
const acceptedTypesLabel = $derived(typesLabel || acceptLabel(acceptedTypes));
|
|
133
119
|
|
|
134
120
|
// Helper to generate unique ID
|
|
135
121
|
function generateId(): string {
|
|
@@ -143,17 +129,11 @@
|
|
|
143
129
|
return `File "${file.name}" exceeds maximum size of ${maxMB}MB`;
|
|
144
130
|
}
|
|
145
131
|
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
return file.type === type;
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
if (!isAccepted) {
|
|
156
|
-
return `File type "${file.type}" is not allowed`;
|
|
132
|
+
// Same rules as the native `accept` attribute: extensions, wildcards, MIME types
|
|
133
|
+
if (!matchesAccept(file, acceptedTypes)) {
|
|
134
|
+
return file.type
|
|
135
|
+
? `File type "${file.type}" is not allowed`
|
|
136
|
+
: `File "${file.name}" is not an allowed type`;
|
|
157
137
|
}
|
|
158
138
|
|
|
159
139
|
return null;
|
|
@@ -22,7 +22,12 @@ interface Props {
|
|
|
22
22
|
maxFiles?: number;
|
|
23
23
|
/** Maximum file size in bytes (default: 10MB) */
|
|
24
24
|
maxSizeBytes?: number;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Accepted file types, as `<input accept>` entries: MIME types
|
|
27
|
+
* (`application/pdf`), wildcards (`image/*`) or extensions (`.dxf`,
|
|
28
|
+
* matched case-insensitively against the file name). A file passes when
|
|
29
|
+
* any entry matches.
|
|
30
|
+
*/
|
|
26
31
|
acceptedTypes?: string[];
|
|
27
32
|
/** Whether upload is disabled */
|
|
28
33
|
disabled?: boolean;
|
|
@@ -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;
|
|
@@ -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
|
)}
|
|
@@ -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
|
@@ -71,6 +71,9 @@ export { default as Tabs } from './components/Tabs.svelte';
|
|
|
71
71
|
export { default as TabPanel } from './components/TabPanel.svelte';
|
|
72
72
|
export { default as Toolbar } from './components/Toolbar.svelte';
|
|
73
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';
|
|
74
77
|
export { default as FilterPanel } from './components/FilterPanel.svelte';
|
|
75
78
|
export { default as ActiveFilterChips, type ActiveFilterChip, } from './components/ActiveFilterChips.svelte';
|
|
76
79
|
export { default as SegmentedControl, type SegmentedControlOption, } from './components/SegmentedControl.svelte';
|
|
@@ -113,6 +116,7 @@ export type { PanelEdge, PanelMode, PanelVariant, SnapPoint, CardConfig, Positio
|
|
|
113
116
|
export { cn } from './utils.js';
|
|
114
117
|
export { parseDate, toISOString, formatDate, formatDateTime, isSameDay } from './utils/date.js';
|
|
115
118
|
export { generateId, computeDescribedBy } from './utils/form.js';
|
|
119
|
+
export { matchesAccept, acceptLabel, type AcceptableFile } from './utils/file-accept.js';
|
|
116
120
|
export { formatDuration, formatDurationSpoken, inferMediaKind, parseEmbedUrl, buildEmbedSrc, getEmbedThumbnail, getMediaThumbnail, getMediaLabel, isTimeBasedMedia, isSpatialMedia, isHlsSource, resolveAspectRatio, toMediaSources, HLS_MIME_TYPE, DASH_MIME_TYPE, type ParsedEmbed, type EmbedSrcOptions, } from './utils/media.js';
|
|
117
121
|
export { cloudflareStream, cloudflareStreamUrls, cloudflareStreamThumbnail, cloudflareStreamHost, type CloudflareStreamItemOptions, type CloudflareStreamUrlOptions, type CloudflareStreamUrls, type CloudflareStreamThumbnailOptions, } from './utils/media.js';
|
|
118
122
|
export { matterportTour, buildMatterportSrc, fetchMatterportMetadata, type MatterportTourOptions, type MatterportMetadata, } from './utils/media.js';
|
package/dist/lib/index.js
CHANGED
|
@@ -85,6 +85,9 @@ export { default as TabPanel } from './components/TabPanel.svelte';
|
|
|
85
85
|
// Toolbar controls
|
|
86
86
|
export { default as Toolbar } from './components/Toolbar.svelte';
|
|
87
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';
|
|
88
91
|
export { default as FilterPanel } from './components/FilterPanel.svelte';
|
|
89
92
|
export { default as ActiveFilterChips, } from './components/ActiveFilterChips.svelte';
|
|
90
93
|
export { default as SegmentedControl, } from './components/SegmentedControl.svelte';
|
|
@@ -144,6 +147,7 @@ export { cn } from './utils.js';
|
|
|
144
147
|
export { parseDate, toISOString, formatDate, formatDateTime, isSameDay } from './utils/date.js';
|
|
145
148
|
// Form utilities
|
|
146
149
|
export { generateId, computeDescribedBy } from './utils/form.js';
|
|
150
|
+
export { matchesAccept, acceptLabel } from './utils/file-accept.js';
|
|
147
151
|
// Media utilities
|
|
148
152
|
export { formatDuration, formatDurationSpoken, inferMediaKind, parseEmbedUrl, buildEmbedSrc, getEmbedThumbnail, getMediaThumbnail, getMediaLabel, isTimeBasedMedia, isSpatialMedia, isHlsSource, resolveAspectRatio, toMediaSources, HLS_MIME_TYPE, DASH_MIME_TYPE, } from './utils/media.js';
|
|
149
153
|
// Cloudflare Stream helpers
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matching and labelling for a list of HTML `accept` entries, as used by
|
|
3
|
+
* `FileUpload`'s `acceptedTypes`. Mirrors the native `<input accept>` rules:
|
|
4
|
+
*
|
|
5
|
+
* - `.dxf` matches a file whose name ends in `.dxf`, case-insensitively
|
|
6
|
+
* (whatever MIME type the browser guessed, often `application/octet-stream` or '')
|
|
7
|
+
* - `image/*` matches any MIME type in the `image` major type
|
|
8
|
+
* - `application/pdf` matches that MIME type exactly, case-insensitively
|
|
9
|
+
*
|
|
10
|
+
* A file is accepted when any entry matches. An empty list accepts everything,
|
|
11
|
+
* as an absent `accept` attribute does.
|
|
12
|
+
*/
|
|
13
|
+
/** The parts of a `File` the matcher reads. */
|
|
14
|
+
export interface AcceptableFile {
|
|
15
|
+
name: string;
|
|
16
|
+
type: string;
|
|
17
|
+
}
|
|
18
|
+
/** Whether `file` matches any of the `accept` entries. */
|
|
19
|
+
export declare function matchesAccept(file: AcceptableFile, accept: readonly string[]): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Readable label for `accept` entries: MIME groups become friendly names
|
|
22
|
+
* (Images, PDF, Videos, Audio), other MIME types their uppercased subtype and
|
|
23
|
+
* extensions their uppercased name (`.dxf` → DXF). Duplicates collapse, so
|
|
24
|
+
* `['application/pdf', '.pdf']` reads "PDF".
|
|
25
|
+
*/
|
|
26
|
+
export declare function acceptLabel(accept: readonly string[]): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matching and labelling for a list of HTML `accept` entries, as used by
|
|
3
|
+
* `FileUpload`'s `acceptedTypes`. Mirrors the native `<input accept>` rules:
|
|
4
|
+
*
|
|
5
|
+
* - `.dxf` matches a file whose name ends in `.dxf`, case-insensitively
|
|
6
|
+
* (whatever MIME type the browser guessed, often `application/octet-stream` or '')
|
|
7
|
+
* - `image/*` matches any MIME type in the `image` major type
|
|
8
|
+
* - `application/pdf` matches that MIME type exactly, case-insensitively
|
|
9
|
+
*
|
|
10
|
+
* A file is accepted when any entry matches. An empty list accepts everything,
|
|
11
|
+
* as an absent `accept` attribute does.
|
|
12
|
+
*/
|
|
13
|
+
function entries(accept) {
|
|
14
|
+
return accept.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
/** Whether `file` matches any of the `accept` entries. */
|
|
17
|
+
export function matchesAccept(file, accept) {
|
|
18
|
+
const list = entries(accept);
|
|
19
|
+
if (list.length === 0)
|
|
20
|
+
return true;
|
|
21
|
+
const name = file.name.toLowerCase();
|
|
22
|
+
const type = file.type.trim().toLowerCase();
|
|
23
|
+
return list.some((entry) => {
|
|
24
|
+
if (entry.startsWith('.'))
|
|
25
|
+
return name.endsWith(entry) && name.length > entry.length;
|
|
26
|
+
if (entry === '*' || entry === '*/*')
|
|
27
|
+
return true;
|
|
28
|
+
if (entry.endsWith('/*'))
|
|
29
|
+
return type.startsWith(entry.slice(0, -1));
|
|
30
|
+
return type === entry;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Readable label for `accept` entries: MIME groups become friendly names
|
|
35
|
+
* (Images, PDF, Videos, Audio), other MIME types their uppercased subtype and
|
|
36
|
+
* extensions their uppercased name (`.dxf` → DXF). Duplicates collapse, so
|
|
37
|
+
* `['application/pdf', '.pdf']` reads "PDF".
|
|
38
|
+
*/
|
|
39
|
+
export function acceptLabel(accept) {
|
|
40
|
+
const groups = [];
|
|
41
|
+
const add = (label) => {
|
|
42
|
+
if (!groups.includes(label))
|
|
43
|
+
groups.push(label);
|
|
44
|
+
};
|
|
45
|
+
for (const raw of accept) {
|
|
46
|
+
const type = raw.trim().toLowerCase();
|
|
47
|
+
if (!type)
|
|
48
|
+
continue;
|
|
49
|
+
if (type.startsWith('.'))
|
|
50
|
+
add(type.slice(1).toUpperCase());
|
|
51
|
+
else if (type === '*' || type === '*/*')
|
|
52
|
+
add('Any file');
|
|
53
|
+
else if (type.startsWith('image/'))
|
|
54
|
+
add('Images');
|
|
55
|
+
else if (type === 'application/pdf')
|
|
56
|
+
add('PDF');
|
|
57
|
+
else if (type.startsWith('video/'))
|
|
58
|
+
add('Videos');
|
|
59
|
+
else if (type.startsWith('audio/'))
|
|
60
|
+
add('Audio');
|
|
61
|
+
else {
|
|
62
|
+
const subtype = type.split('/')[1];
|
|
63
|
+
add(subtype ? subtype.toUpperCase() : raw.trim());
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return groups.join(', ');
|
|
67
|
+
}
|
|
@@ -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
|
+
}
|