@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.
@@ -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
- /** Accepted MIME types */
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 MIME types into friendly names (Images, PDF, Videos, ...).
116
+ * accepted types into friendly names (Images, PDF, Videos, DXF, ...).
111
117
  */
112
- const acceptedTypesLabel = $derived.by(() => {
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
- // Check if file type is accepted
147
- const isAccepted = acceptedTypes.some((type) => {
148
- if (type.endsWith('/*')) {
149
- const category = type.slice(0, -2);
150
- return file.type.startsWith(category);
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
- /** Accepted MIME types */
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 / summary text, after the filters or the button */
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 / summary text, after the filters or the button */
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
- /** Right-aligned cluster (e.g. SegmentedControl + ViewToggle) */
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
- /** Right-aligned cluster (e.g. SegmentedControl + ViewToggle) */
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,4 @@
1
+ import { type ToolbarCountProps } from '../utils/result-count.js';
2
+ declare const ToolbarCount: import("svelte").Component<ToolbarCountProps, {}, "">;
3
+ type ToolbarCount = ReturnType<typeof ToolbarCount>;
4
+ export default ToolbarCount;
@@ -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;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.39.0",
3
+ "version": "1.41.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",