@classic-homes/theme-svelte 1.11.0 → 1.13.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.
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { axe } from 'vitest-axe';
5
+ import SegmentedControl from '../../lib/components/SegmentedControl.svelte';
6
+ const options = [
7
+ { value: 'community', label: 'Community' },
8
+ { value: 'collection', label: 'Collection' },
9
+ { value: 'floor-plan', label: 'Floor Plan', disabled: true },
10
+ ];
11
+ describe('SegmentedControl accessibility', () => {
12
+ it('should have no axe violations', async () => {
13
+ const { container } = render(SegmentedControl, {
14
+ props: { options, label: 'Group homes by' },
15
+ });
16
+ expect(await axe(container)).toHaveNoViolations();
17
+ });
18
+ it('is a labelled radiogroup of radios', () => {
19
+ render(SegmentedControl, { props: { options, label: 'Group homes by' } });
20
+ const group = screen.getByRole('radiogroup', { name: 'Group homes by' });
21
+ expect(group).toBeInTheDocument();
22
+ expect(screen.getAllByRole('radio')).toHaveLength(3);
23
+ });
24
+ it('selects the first enabled option by default', () => {
25
+ render(SegmentedControl, { props: { options, label: 'Group homes by' } });
26
+ expect(screen.getByRole('radio', { name: 'Community' })).toHaveAttribute('aria-checked', 'true');
27
+ expect(screen.getByRole('radio', { name: 'Floor Plan' })).toBeDisabled();
28
+ });
29
+ it('moves selection with arrow keys and skips disabled options', async () => {
30
+ const user = userEvent.setup();
31
+ const onValueChange = vi.fn();
32
+ render(SegmentedControl, { props: { options, label: 'Group homes by', onValueChange } });
33
+ const community = screen.getByRole('radio', { name: 'Community' });
34
+ const collection = screen.getByRole('radio', { name: 'Collection' });
35
+ community.focus();
36
+ await user.keyboard('{ArrowRight}');
37
+ expect(collection).toHaveAttribute('aria-checked', 'true');
38
+ expect(onValueChange).toHaveBeenLastCalledWith('collection');
39
+ // Floor Plan is disabled, so the next step loops back to Community
40
+ await user.keyboard('{ArrowRight}');
41
+ expect(community).toHaveAttribute('aria-checked', 'true');
42
+ });
43
+ it('never deselects: clicking the selected option keeps it checked', async () => {
44
+ const user = userEvent.setup();
45
+ render(SegmentedControl, { props: { options, label: 'Group homes by' } });
46
+ const community = screen.getByRole('radio', { name: 'Community' });
47
+ await user.click(community);
48
+ expect(community).toHaveAttribute('aria-checked', 'true');
49
+ });
50
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/svelte';
3
+ import { axe } from 'vitest-axe';
4
+ import { createRawSnippet } from 'svelte';
5
+ import Toolbar from '../../lib/components/Toolbar.svelte';
6
+ const snippet = (html) => createRawSnippet(() => ({ render: () => html }));
7
+ describe('Toolbar accessibility', () => {
8
+ it('should have no axe violations', async () => {
9
+ const { container } = render(Toolbar, {
10
+ props: {
11
+ label: 'Inventory tools',
12
+ children: snippet('<button type="button">Filters</button>'),
13
+ end: snippet('<button type="button">Map</button>'),
14
+ },
15
+ });
16
+ expect(await axe(container)).toHaveNoViolations();
17
+ });
18
+ it('is a labelled group that renders both slots', () => {
19
+ render(Toolbar, {
20
+ props: {
21
+ label: 'Inventory tools',
22
+ children: snippet('<span>89 homes</span>'),
23
+ end: snippet('<button type="button">Map</button>'),
24
+ },
25
+ });
26
+ const group = screen.getByRole('group', { name: 'Inventory tools' });
27
+ expect(group).toHaveClass('bg-toolbar');
28
+ expect(screen.getByText('89 homes')).toBeInTheDocument();
29
+ expect(screen.getByRole('button', { name: 'Map' })).toBeInTheDocument();
30
+ });
31
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { axe } from 'vitest-axe';
5
+ import ViewToggle from '../../lib/components/ViewToggle.svelte';
6
+ describe('ViewToggle accessibility', () => {
7
+ it('should have no axe violations', async () => {
8
+ const { container } = render(ViewToggle);
9
+ expect(await axe(container)).toHaveNoViolations();
10
+ });
11
+ it('exposes list / grid / map radios with accessible names', () => {
12
+ render(ViewToggle);
13
+ expect(screen.getByRole('radiogroup', { name: 'View' })).toBeInTheDocument();
14
+ expect(screen.getByRole('radio', { name: 'List view' })).toHaveAttribute('aria-checked', 'true');
15
+ expect(screen.getByRole('radio', { name: 'Grid view' })).toBeInTheDocument();
16
+ expect(screen.getByRole('radio', { name: 'Map view' })).toBeInTheDocument();
17
+ });
18
+ it('changes the view with the keyboard and reports it', async () => {
19
+ const user = userEvent.setup();
20
+ const onValueChange = vi.fn();
21
+ render(ViewToggle, { props: { onValueChange } });
22
+ screen.getByRole('radio', { name: 'List view' }).focus();
23
+ await user.keyboard('{ArrowRight}');
24
+ expect(screen.getByRole('radio', { name: 'Grid view' })).toHaveAttribute('aria-checked', 'true');
25
+ expect(onValueChange).toHaveBeenLastCalledWith('grid');
26
+ });
27
+ it('honours a custom option set and value', () => {
28
+ render(ViewToggle, {
29
+ props: {
30
+ label: 'Layout',
31
+ value: 'cards',
32
+ options: [
33
+ { value: 'table', icon: 'list', label: 'Table' },
34
+ { value: 'cards', icon: 'grid', label: 'Cards' },
35
+ ],
36
+ },
37
+ });
38
+ expect(screen.getByRole('radio', { name: 'Cards' })).toHaveAttribute('aria-checked', 'true');
39
+ });
40
+ });
@@ -27,6 +27,20 @@ describe('ActionHeader', () => {
27
27
  expect(screen.getByRole('heading', { level: 2, name: 'Signs' })).toBeInTheDocument();
28
28
  expect(screen.getByText('All signage')).toBeInTheDocument();
29
29
  });
30
+ it('styles the title per heading level: serif italic h1, sans h2', () => {
31
+ const { unmount } = render(ActionHeader, { props: { title: 'Homes' } });
32
+ expect(screen.getByRole('heading', { level: 1 })).toHaveClass('font-serif', 'italic');
33
+ unmount();
34
+ render(ActionHeader, { props: { title: 'Flying Horse', headingLevel: 2 } });
35
+ expect(screen.getByRole('heading', { level: 2 })).toHaveClass('font-sans', 'font-semibold');
36
+ });
37
+ it('renders the caption under the actions', () => {
38
+ const { container } = render(ActionHeader, {
39
+ props: { title: 'Homes', caption: 'Updated 5 minutes ago', actions },
40
+ });
41
+ expect(screen.getByText('Updated 5 minutes ago')).toHaveClass('ah-caption');
42
+ expect(container.querySelector('header')).toHaveAttribute('data-has-caption');
43
+ });
30
44
  it('defaults to h1 and renders no action cluster without actions', () => {
31
45
  const { container } = render(ActionHeader, { props: { title: 'Account' } });
32
46
  expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
@@ -41,6 +41,8 @@
41
41
  title: string;
42
42
  /** One- or two-sentence description; clamped to two lines on narrow headers */
43
43
  subtitle?: string;
44
+ /** Small note under the actions, right-aligned (e.g. "Updated 5 minutes ago") */
45
+ caption?: string;
44
46
  /** Heading level for the title (default 1) */
45
47
  headingLevel?: 1 | 2 | 3;
46
48
  /** Renders a back (arrow) button linking here */
@@ -74,6 +76,7 @@
74
76
  let {
75
77
  title,
76
78
  subtitle,
79
+ caption,
77
80
  headingLevel = 1,
78
81
  backHref,
79
82
  backLabel,
@@ -131,8 +134,14 @@
131
134
  const hasLead = $derived(Boolean(backHref || media));
132
135
  const hasActions = $derived(primary.length > 0 || secondary.length > 0 || overflow.length > 0);
133
136
 
137
+ /** Mirrors Button's `outline` variant at `size="sm"` */
134
138
  const menuTriggerClass =
135
- 'inline-flex h-9 w-9 items-center justify-center rounded-md border-2 border-primary bg-transparent text-primary ring-offset-background hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
139
+ 'inline-flex h-9 w-9 items-center justify-center rounded-md border-2 border-outline bg-transparent text-foreground ring-offset-background hover:border-outline-hover hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
140
+
141
+ /** h1 is the display face (Spectral italic); lower levels are Figtree */
142
+ const titleTypeClass = $derived(
143
+ headingLevel === 1 ? 'font-serif font-medium italic' : 'font-sans font-semibold not-italic'
144
+ );
136
145
  </script>
137
146
 
138
147
  {#snippet moreMenu(items: DropdownMenuItem[])}
@@ -143,7 +152,13 @@
143
152
  </DropdownMenu>
144
153
  {/snippet}
145
154
 
146
- <header class={cn('ah', className)} data-collapse={collapse} {...restProps}>
155
+ <header
156
+ class={cn('ah', className)}
157
+ data-collapse={collapse}
158
+ data-level={headingLevel}
159
+ data-has-caption={caption ? '' : undefined}
160
+ {...restProps}
161
+ >
147
162
  <div class="ah-grid">
148
163
  {#if hasLead}
149
164
  <div class="ah-lead">
@@ -168,7 +183,10 @@
168
183
  <div class="flex min-w-0 items-center gap-2">
169
184
  <svelte:element
170
185
  this={headingTag}
171
- class="ah-title text-[length:var(--ah-title-size)] font-bold leading-[var(--ah-title-leading)] tracking-tight text-foreground"
186
+ class={cn(
187
+ 'ah-title text-[length:var(--ah-title-size)] leading-[var(--ah-title-leading)] text-foreground',
188
+ titleTypeClass
189
+ )}
172
190
  >
173
191
  {title}
174
192
  </svelte:element>
@@ -237,6 +255,10 @@
237
255
  </div>
238
256
  {/if}
239
257
 
258
+ {#if caption}
259
+ <p class="ah-caption text-xs text-muted-foreground">{caption}</p>
260
+ {/if}
261
+
240
262
  {#if tools}
241
263
  <div class="ah-tools">{@render tools()}</div>
242
264
  {/if}
@@ -261,8 +283,6 @@
261
283
  }
262
284
 
263
285
  .ah-grid {
264
- --ah-title-size: 1.25rem;
265
- --ah-title-leading: 1.75rem;
266
286
  --ah-subtitle-size: 0.875rem;
267
287
  --ah-subtitle-leading: 1.25rem;
268
288
  display: grid;
@@ -270,10 +290,25 @@
270
290
  grid-template-areas:
271
291
  'lead heading actions actions'
272
292
  'lead sub sub sub'
293
+ 'lead caption caption caption'
273
294
  'lead badges badges tools';
274
295
  align-items: center;
275
296
  }
276
297
 
298
+ /* Title scale per heading level (narrow) */
299
+ [data-level='1'] .ah-grid {
300
+ --ah-title-size: 1.75rem;
301
+ --ah-title-leading: 2rem;
302
+ }
303
+ [data-level='2'] .ah-grid {
304
+ --ah-title-size: 1.25rem;
305
+ --ah-title-leading: 1.75rem;
306
+ }
307
+ [data-level='3'] .ah-grid {
308
+ --ah-title-size: 1.125rem;
309
+ --ah-title-leading: 1.5rem;
310
+ }
311
+
277
312
  .ah-lead {
278
313
  grid-area: lead;
279
314
  align-self: start;
@@ -298,6 +333,8 @@
298
333
  -webkit-line-clamp: 2;
299
334
  line-clamp: 2;
300
335
  overflow: hidden;
336
+ /* Keep the italic overhang of the last glyph out of the clip */
337
+ padding-inline-end: 0.05em;
301
338
  }
302
339
 
303
340
  .ah-subtitle {
@@ -311,6 +348,14 @@
311
348
  overflow: hidden;
312
349
  }
313
350
 
351
+ .ah-caption {
352
+ grid-area: caption;
353
+ justify-self: end;
354
+ text-align: end;
355
+ margin-top: 0.25rem;
356
+ white-space: nowrap;
357
+ }
358
+
314
359
  .ah-actions {
315
360
  grid-area: actions;
316
361
  display: flex;
@@ -400,8 +445,6 @@
400
445
 
401
446
  @container action-header (min-width: 40rem) {
402
447
  .ah-grid {
403
- --ah-title-size: 1.5rem;
404
- --ah-title-leading: 2rem;
405
448
  --ah-subtitle-size: 1rem;
406
449
  --ah-subtitle-leading: 1.5rem;
407
450
  grid-template-areas:
@@ -410,6 +453,29 @@
410
453
  'lead badges badges badges';
411
454
  }
412
455
 
456
+ /* With a caption the actions sit on the title row and the caption under them */
457
+ [data-has-caption] .ah-grid {
458
+ grid-template-areas:
459
+ 'lead heading actions tools'
460
+ 'lead sub caption tools'
461
+ 'lead badges badges badges';
462
+ }
463
+
464
+ /* h1 keeps the theme's 2.5rem display size; leading is a touch looser than
465
+ the global 2rem because the title clamps to two lines with overflow hidden */
466
+ [data-level='1'] .ah-grid {
467
+ --ah-title-size: 2.5rem;
468
+ --ah-title-leading: 2.75rem;
469
+ }
470
+ [data-level='2'] .ah-grid {
471
+ --ah-title-size: 1.75rem;
472
+ --ah-title-leading: 2.25rem;
473
+ }
474
+ [data-level='3'] .ah-grid {
475
+ --ah-title-size: 1.5rem;
476
+ --ah-title-leading: 2rem;
477
+ }
478
+
413
479
  .ah-subtitle {
414
480
  -webkit-line-clamp: 3;
415
481
  line-clamp: 3;
@@ -31,6 +31,8 @@ interface Props {
31
31
  title: string;
32
32
  /** One- or two-sentence description; clamped to two lines on narrow headers */
33
33
  subtitle?: string;
34
+ /** Small note under the actions, right-aligned (e.g. "Updated 5 minutes ago") */
35
+ caption?: string;
34
36
  /** Heading level for the title (default 1) */
35
37
  headingLevel?: 1 | 2 | 3;
36
38
  /** Renders a back (arrow) button linking here */
@@ -8,11 +8,12 @@
8
8
  variants: {
9
9
  variant: {
10
10
  default: 'bg-primary text-primary-foreground shadow hover:bg-primary-hover',
11
- secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary-hover',
11
+ secondary:
12
+ 'border-2 border-secondary-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary-hover',
12
13
  destructive:
13
14
  'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover focus-visible:ring-destructive',
14
15
  outline:
15
- 'border-2 border-primary text-primary bg-transparent hover:bg-primary hover:text-primary-foreground',
16
+ 'border-2 border-outline bg-transparent text-foreground hover:border-outline-hover hover:bg-primary hover:text-primary-foreground',
16
17
  ghost: 'hover:bg-muted-hover hover:text-foreground',
17
18
  link: 'text-primary underline-offset-4 hover:underline',
18
19
  },
@@ -479,7 +479,7 @@
479
479
 
480
480
  {#if enablePagination && !loading && table.rows.length > 0}
481
481
  <!-- Pagination controls -->
482
- <div class="flex items-center justify-between border-t px-4 py-3">
482
+ <div class="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3">
483
483
  <div class="text-sm text-muted-foreground">
484
484
  {#if enableRowSelection && table.selectedCount > 0}
485
485
  {table.selectedCount} of {table.totalRows} row(s) selected
@@ -490,7 +490,7 @@
490
490
  <div class="flex items-center gap-2">
491
491
  <button
492
492
  type="button"
493
- class="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
493
+ class="min-h-11 rounded-md border px-3 py-1 text-sm disabled:opacity-50 sm:min-h-0"
494
494
  disabled={!table.canPreviousPage}
495
495
  onclick={() => table.previousPage()}
496
496
  >
@@ -501,7 +501,7 @@
501
501
  </span>
502
502
  <button
503
503
  type="button"
504
- class="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
504
+ class="min-h-11 rounded-md border px-3 py-1 text-sm disabled:opacity-50 sm:min-h-0"
505
505
  disabled={!table.canNextPage}
506
506
  onclick={() => table.nextPage()}
507
507
  >
@@ -54,7 +54,7 @@
54
54
  dark: {
55
55
  logo: '#C50F22', // Brand red
56
56
  title: '#000000', // Black for light backgrounds
57
- subtitle: '#666666', // Gray - #7a7a7a was 4.29:1 on white (WCAG 1.4.3 fail)
57
+ subtitle: 'var(--color-red-primary, #a91c22)', // Brand red (hex var: does not flip in dark mode)
58
58
  },
59
59
  light: {
60
60
  logo: '#ffffff',
@@ -64,7 +64,7 @@
64
64
  'mono-dark': {
65
65
  logo: '#002e3f', // Navy
66
66
  title: '#002e3f',
67
- subtitle: '#666666', // see dark variant note
67
+ subtitle: 'var(--color-red-primary, #a91c22)', // Brand red
68
68
  },
69
69
  'mono-light': {
70
70
  logo: '#ffffff',
@@ -166,7 +166,7 @@
166
166
  {/if}
167
167
  {#if subtitle}
168
168
  <span
169
- class="uppercase font-black leading-none m-0 p-0"
169
+ class="uppercase font-semibold leading-none m-0 p-0"
170
170
  style="font-family: 'Figtree', system-ui, sans-serif; font-size: {currentSize.subtitle}; color: {currentColors.subtitle};"
171
171
  >
172
172
  {subtitle}
@@ -212,7 +212,7 @@
212
212
  Classic Homes
213
213
  </span>
214
214
  {#if subtitle || envConfig}
215
- <div class="flex flex-row items-center justify-end gap-1.5 w-full">
215
+ <div class="flex flex-row items-center justify-start gap-1.5 w-full">
216
216
  {#if envConfig}
217
217
  <Badge
218
218
  variant={envConfig.variant}
@@ -224,7 +224,7 @@
224
224
  {/if}
225
225
  {#if subtitle}
226
226
  <span
227
- class="uppercase font-black leading-none m-0 p-0"
227
+ class="uppercase font-semibold leading-none m-0 p-0"
228
228
  style="font-family: 'Figtree', system-ui, sans-serif; font-size: {currentSize.subtitle}; color: {currentColors.subtitle};"
229
229
  >
230
230
  {subtitle}
@@ -178,7 +178,7 @@
178
178
  {@const labelText = options.find((o) => o.value === selectedValue)?.label}
179
179
  {#if labelText}
180
180
  <span
181
- class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground"
181
+ class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground"
182
182
  >
183
183
  {labelText}
184
184
  <button
@@ -315,7 +315,7 @@
315
315
  {@const labelText = options.find((o) => o.value === selectedValue)?.label}
316
316
  {#if labelText}
317
317
  <span
318
- class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground"
318
+ class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground"
319
319
  >
320
320
  {labelText}
321
321
  <button
@@ -6,10 +6,13 @@
6
6
  * Supports multiple layout variants and optional action buttons.
7
7
  *
8
8
  * Features:
9
- * - Multiple variants: default, hero, centered
9
+ * - Multiple variants: default, hero, centered, form
10
10
  * - Optional subtitle with branded styling
11
11
  * - Optional actions slot for CTA buttons
12
- * - Consistent typography using design tokens
12
+ * - Consistent typography using design tokens: `default` and `centered`
13
+ * inherit the theme heading styles for the rendered level (h1 = Spectral
14
+ * 500 italic, h2-h6 = Figtree 600); `hero` and `form` are display styles
15
+ * that opt out of them.
13
16
  */
14
17
  import type { Snippet } from 'svelte';
15
18
  import { cn } from '../utils.js';
@@ -26,25 +29,26 @@
26
29
  variant: {
27
30
  default: {
28
31
  container: '',
29
- title: 'text-3xl font-bold mb-2',
32
+ title: 'mb-2',
30
33
  subtitle: 'text-lg',
31
34
  actionsWrapper: 'mt-4',
32
35
  },
33
36
  hero: {
34
37
  container: '',
35
- title: 'text-5xl font-black uppercase tracking-tight mb-2',
38
+ title: 'font-sans text-5xl font-black not-italic uppercase tracking-tight mb-2',
36
39
  subtitle: 'text-lg font-serif italic',
37
40
  actionsWrapper: 'mt-6',
38
41
  },
39
42
  centered: {
40
43
  container: 'text-center',
41
- title: 'text-4xl font-bold mb-3',
44
+ title: 'mb-3',
42
45
  subtitle: 'text-xl max-w-2xl mx-auto',
43
46
  actionsWrapper: 'mt-6 justify-center',
44
47
  },
45
48
  form: {
46
49
  container: 'text-center mb-8',
47
- title: 'text-3xl md:text-4xl font-light tracking-tight text-brand-core-2',
50
+ title:
51
+ 'font-sans text-3xl md:text-4xl font-light not-italic tracking-tight text-brand-core-2',
48
52
  subtitle: 'mt-3 text-base md:text-lg text-gray-600',
49
53
  actionsWrapper: 'mt-6 justify-center',
50
54
  },
@@ -64,6 +68,12 @@
64
68
  subtitle?: string;
65
69
  /** Visual variant */
66
70
  variant?: PageHeaderVariants['variant'];
71
+ /**
72
+ * Heading element for the title (default `h1`). Pick the level that fits
73
+ * the page outline; `default`/`centered` variants take the theme's type
74
+ * style for that level.
75
+ */
76
+ as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
67
77
  /** Additional classes for the container */
68
78
  class?: string;
69
79
  /** Optional action buttons/content */
@@ -75,6 +85,7 @@
75
85
  title,
76
86
  subtitle,
77
87
  variant = 'default',
88
+ as = 'h1',
78
89
  class: className,
79
90
  actions,
80
91
  ...restProps
@@ -84,7 +95,7 @@
84
95
  </script>
85
96
 
86
97
  <header class={cn(styles.container(), className)} {...restProps}>
87
- <h1 class={styles.title()}>{title}</h1>
98
+ <svelte:element this={as} class={styles.title()}>{title}</svelte:element>
88
99
  {#if subtitle}
89
100
  <p class={styles.subtitle()}>{subtitle}</p>
90
101
  {/if}
@@ -5,10 +5,13 @@
5
5
  * Supports multiple layout variants and optional action buttons.
6
6
  *
7
7
  * Features:
8
- * - Multiple variants: default, hero, centered
8
+ * - Multiple variants: default, hero, centered, form
9
9
  * - Optional subtitle with branded styling
10
10
  * - Optional actions slot for CTA buttons
11
- * - Consistent typography using design tokens
11
+ * - Consistent typography using design tokens: `default` and `centered`
12
+ * inherit the theme heading styles for the rendered level (h1 = Spectral
13
+ * 500 italic, h2-h6 = Figtree 600); `hero` and `form` are display styles
14
+ * that opt out of them.
12
15
  */
13
16
  import type { Snippet } from 'svelte';
14
17
  declare const PageHeader: import("svelte").Component<{
@@ -19,6 +22,12 @@ declare const PageHeader: import("svelte").Component<{
19
22
  subtitle?: string;
20
23
  /** Visual variant */
21
24
  variant?: "default" | "form" | "hero" | "centered" | undefined;
25
+ /**
26
+ * Heading element for the title (default `h1`). Pick the level that fits
27
+ * the page outline; `default`/`centered` variants take the theme's type
28
+ * style for that level.
29
+ */
30
+ as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
22
31
  /** Additional classes for the container */
23
32
  class?: string;
24
33
  /** Optional action buttons/content */
@@ -91,7 +91,10 @@
91
91
  onValueChange={handleValueChange}
92
92
  class={cn(
93
93
  'grid gap-2',
94
- orientation === 'horizontal' ? 'grid-flow-col auto-cols-max' : 'grid-flow-row',
94
+ // Horizontal stacks below sm: a non-wrapping column flow overflows phones.
95
+ orientation === 'horizontal'
96
+ ? 'grid-flow-row sm:grid-flow-col sm:auto-cols-max'
97
+ : 'grid-flow-row',
95
98
  className
96
99
  )}
97
100
  aria-labelledby={label ? labelId : undefined}
@@ -0,0 +1,89 @@
1
+ <script lang="ts" module>
2
+ import type { IconName } from './Icon.svelte';
3
+
4
+ export interface SegmentedControlOption {
5
+ value: string;
6
+ label: string;
7
+ /** Leading icon */
8
+ icon?: IconName;
9
+ disabled?: boolean;
10
+ }
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
15
+ import { tv } from 'tailwind-variants';
16
+ import { cn } from '../utils.js';
17
+ import Icon from './Icon.svelte';
18
+
19
+ /**
20
+ * SegmentedControl - single-choice pill group (e.g. "Community | Collection | Floor Plan").
21
+ *
22
+ * A radio group under the hood: one option is always selected, arrow keys move
23
+ * between options, Tab leaves the control. Sits on light or dark surfaces
24
+ * (it carries its own `bg-muted` track), including inside `Toolbar`.
25
+ */
26
+ const segmentedControl = tv({
27
+ slots: {
28
+ root: 'inline-flex max-w-full items-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground',
29
+ item: 'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-sm font-medium ring-offset-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:shadow-sm',
30
+ },
31
+ variants: {
32
+ size: {
33
+ md: { root: 'h-10', item: 'h-8' },
34
+ sm: { root: 'h-9', item: 'h-7 px-2.5' },
35
+ },
36
+ },
37
+ defaultVariants: { size: 'md' },
38
+ });
39
+
40
+ interface Props {
41
+ options: SegmentedControlOption[];
42
+ /** Selected value (bindable); defaults to the first enabled option */
43
+ value?: string;
44
+ onValueChange?: (value: string) => void;
45
+ /** Accessible name of the group, e.g. "Group homes by" */
46
+ label: string;
47
+ size?: 'sm' | 'md';
48
+ /** Disables every option */
49
+ disabled?: boolean;
50
+ class?: string;
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ let {
55
+ options,
56
+ value = $bindable(options.find((o) => !o.disabled)?.value ?? ''),
57
+ onValueChange,
58
+ label,
59
+ size = 'md',
60
+ disabled = false,
61
+ class: className,
62
+ ...restProps
63
+ }: Props = $props();
64
+
65
+ const styles = $derived(segmentedControl({ size }));
66
+
67
+ function handleValueChange(next: string) {
68
+ if (!next || next === value) return;
69
+ value = next;
70
+ onValueChange?.(next);
71
+ }
72
+ </script>
73
+
74
+ <RadioGroupPrimitive.Root
75
+ {value}
76
+ onValueChange={handleValueChange}
77
+ {disabled}
78
+ orientation="horizontal"
79
+ aria-label={label}
80
+ class={cn(styles.root(), className)}
81
+ {...restProps}
82
+ >
83
+ {#each options as option (option.value)}
84
+ <RadioGroupPrimitive.Item value={option.value} disabled={option.disabled} class={styles.item()}>
85
+ {#if option.icon}<Icon name={option.icon} size={16} aria-hidden="true" />{/if}
86
+ {option.label}
87
+ </RadioGroupPrimitive.Item>
88
+ {/each}
89
+ </RadioGroupPrimitive.Root>
@@ -0,0 +1,24 @@
1
+ import type { IconName } from './Icon.svelte';
2
+ export interface SegmentedControlOption {
3
+ value: string;
4
+ label: string;
5
+ /** Leading icon */
6
+ icon?: IconName;
7
+ disabled?: boolean;
8
+ }
9
+ interface Props {
10
+ options: SegmentedControlOption[];
11
+ /** Selected value (bindable); defaults to the first enabled option */
12
+ value?: string;
13
+ onValueChange?: (value: string) => void;
14
+ /** Accessible name of the group, e.g. "Group homes by" */
15
+ label: string;
16
+ size?: 'sm' | 'md';
17
+ /** Disables every option */
18
+ disabled?: boolean;
19
+ class?: string;
20
+ [key: string]: unknown;
21
+ }
22
+ declare const SegmentedControl: import("svelte").Component<Props, {}, "value">;
23
+ type SegmentedControl = ReturnType<typeof SegmentedControl>;
24
+ export default SegmentedControl;
@@ -139,7 +139,7 @@
139
139
  {orientation}
140
140
  onValueChange={handleValueChange}
141
141
  onValueCommit={handleValueCommit}
142
- class={cn(styles.root(), 'rounded-full bg-secondary')}
142
+ class={cn(styles.root(), 'rounded-full bg-muted')}
143
143
  aria-describedby={describedBy}
144
144
  >
145
145
  <SliderPrimitive.Range class={styles.range()} />
@@ -0,0 +1,42 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils.js';
4
+
5
+ /**
6
+ * Toolbar - dark strip for list controls (search, filters, result count,
7
+ * grouping and view toggles) under a page header.
8
+ *
9
+ * Filled `secondary` buttons inside pick up the toolbar's light border via
10
+ * `--secondary-border`; other controls keep their own surfaces. Keyboard:
11
+ * Tab moves between controls, and each control (SegmentedControl,
12
+ * ViewToggle) owns its own arrow keys - so this is a labelled `group`, not
13
+ * an ARIA `toolbar` with roving focus.
14
+ */
15
+ interface Props {
16
+ /** Accessible name, e.g. "Inventory tools" */
17
+ label: string;
18
+ /** Leading controls (search, filters, result count) */
19
+ children?: Snippet;
20
+ /** Right-aligned cluster (e.g. SegmentedControl + ViewToggle) */
21
+ end?: Snippet;
22
+ class?: string;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ let { label, children, end, class: className, ...restProps }: Props = $props();
27
+ </script>
28
+
29
+ <div
30
+ role="group"
31
+ aria-label={label}
32
+ 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)]',
34
+ className
35
+ )}
36
+ {...restProps}
37
+ >
38
+ {@render children?.()}
39
+ {#if end}
40
+ <div class="ml-auto flex flex-wrap items-center gap-2">{@render end()}</div>
41
+ {/if}
42
+ </div>
@@ -0,0 +1,24 @@
1
+ import type { Snippet } from 'svelte';
2
+ /**
3
+ * Toolbar - dark strip for list controls (search, filters, result count,
4
+ * grouping and view toggles) under a page header.
5
+ *
6
+ * Filled `secondary` buttons inside pick up the toolbar's light border via
7
+ * `--secondary-border`; other controls keep their own surfaces. Keyboard:
8
+ * Tab moves between controls, and each control (SegmentedControl,
9
+ * ViewToggle) owns its own arrow keys - so this is a labelled `group`, not
10
+ * an ARIA `toolbar` with roving focus.
11
+ */
12
+ interface Props {
13
+ /** Accessible name, e.g. "Inventory tools" */
14
+ label: string;
15
+ /** Leading controls (search, filters, result count) */
16
+ children?: Snippet;
17
+ /** Right-aligned cluster (e.g. SegmentedControl + ViewToggle) */
18
+ end?: Snippet;
19
+ class?: string;
20
+ [key: string]: unknown;
21
+ }
22
+ declare const Toolbar: import("svelte").Component<Props, {}, "">;
23
+ type Toolbar = ReturnType<typeof Toolbar>;
24
+ export default Toolbar;
@@ -0,0 +1,84 @@
1
+ <script lang="ts" module>
2
+ import type { IconName } from './Icon.svelte';
3
+
4
+ export interface ViewToggleOption {
5
+ value: string;
6
+ icon: IconName;
7
+ /** Accessible name and tooltip, e.g. "List view" */
8
+ label: string;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ export const defaultViewToggleOptions: ViewToggleOption[] = [
13
+ { value: 'list', icon: 'list', label: 'List view' },
14
+ { value: 'grid', icon: 'grid', label: 'Grid view' },
15
+ { value: 'map', icon: 'map', label: 'Map view' },
16
+ ];
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
21
+ import { cn } from '../utils.js';
22
+ import Icon from './Icon.svelte';
23
+
24
+ /**
25
+ * ViewToggle - icon-only single choice between views (list / grid / map).
26
+ *
27
+ * Filled sage squares, brand-red when selected, darker sage on hover. The
28
+ * rest fill is `secondary-soft` (#969a82), which is 4.37:1 against the
29
+ * `Toolbar` background but only 2.90:1 on white: place it inside `Toolbar`
30
+ * (or another dark surface) rather than directly on the page.
31
+ */
32
+ interface Props {
33
+ options?: ViewToggleOption[];
34
+ /** Selected view (bindable); defaults to the first enabled option */
35
+ value?: string;
36
+ onValueChange?: (value: string) => void;
37
+ /** Accessible name of the group */
38
+ label?: string;
39
+ disabled?: boolean;
40
+ class?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ let {
45
+ options = defaultViewToggleOptions,
46
+ value = $bindable(options.find((o) => !o.disabled)?.value ?? ''),
47
+ onValueChange,
48
+ label = 'View',
49
+ disabled = false,
50
+ class: className,
51
+ ...restProps
52
+ }: Props = $props();
53
+
54
+ const itemClass =
55
+ 'inline-flex h-9 w-9 items-center justify-center rounded-md bg-secondary-soft text-secondary-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=unchecked]:hover:bg-secondary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground';
56
+
57
+ function handleValueChange(next: string) {
58
+ if (!next || next === value) return;
59
+ value = next;
60
+ onValueChange?.(next);
61
+ }
62
+ </script>
63
+
64
+ <RadioGroupPrimitive.Root
65
+ {value}
66
+ onValueChange={handleValueChange}
67
+ {disabled}
68
+ orientation="horizontal"
69
+ aria-label={label}
70
+ class={cn('inline-flex items-center gap-1', className)}
71
+ {...restProps}
72
+ >
73
+ {#each options as option (option.value)}
74
+ <RadioGroupPrimitive.Item
75
+ value={option.value}
76
+ disabled={option.disabled}
77
+ aria-label={option.label}
78
+ title={option.label}
79
+ class={itemClass}
80
+ >
81
+ <Icon name={option.icon} size={18} aria-hidden="true" />
82
+ </RadioGroupPrimitive.Item>
83
+ {/each}
84
+ </RadioGroupPrimitive.Root>
@@ -0,0 +1,31 @@
1
+ import type { IconName } from './Icon.svelte';
2
+ export interface ViewToggleOption {
3
+ value: string;
4
+ icon: IconName;
5
+ /** Accessible name and tooltip, e.g. "List view" */
6
+ label: string;
7
+ disabled?: boolean;
8
+ }
9
+ export declare const defaultViewToggleOptions: ViewToggleOption[];
10
+ /**
11
+ * ViewToggle - icon-only single choice between views (list / grid / map).
12
+ *
13
+ * Filled sage squares, brand-red when selected, darker sage on hover. The
14
+ * rest fill is `secondary-soft` (#969a82), which is 4.37:1 against the
15
+ * `Toolbar` background but only 2.90:1 on white: place it inside `Toolbar`
16
+ * (or another dark surface) rather than directly on the page.
17
+ */
18
+ interface Props {
19
+ options?: ViewToggleOption[];
20
+ /** Selected view (bindable); defaults to the first enabled option */
21
+ value?: string;
22
+ onValueChange?: (value: string) => void;
23
+ /** Accessible name of the group */
24
+ label?: string;
25
+ disabled?: boolean;
26
+ class?: string;
27
+ [key: string]: unknown;
28
+ }
29
+ declare const ViewToggle: import("svelte").Component<Props, {}, "value">;
30
+ type ViewToggle = ReturnType<typeof ViewToggle>;
31
+ export default ViewToggle;
@@ -186,7 +186,7 @@
186
186
  {/if}
187
187
 
188
188
  <!-- Title -->
189
- <h1 class="mb-4 text-2xl font-bold text-foreground sm:text-3xl">
189
+ <h1 class="mb-4 text-2xl font-medium text-foreground sm:text-3xl">
190
190
  {effectiveTitle}
191
191
  </h1>
192
192
 
@@ -82,6 +82,9 @@
82
82
  onClose?.();
83
83
  }
84
84
  }
85
+
86
+ // Full-width 44px touch targets on phones; stock size from sm up.
87
+ const footerButtonClass = 'h-11 w-full sm:h-10 sm:w-auto';
85
88
  </script>
86
89
 
87
90
  <Dialog {open} onOpenChange={handleOpenChange} size="lg">
@@ -172,27 +175,27 @@
172
175
 
173
176
  {#snippet footer()}
174
177
  {#if notification}
175
- <div class="flex items-center justify-between w-full">
176
- <div>
177
- {#if onDelete}
178
- <Button variant="destructive" onclick={handleDelete}>
179
- {#snippet children()}
180
- <Icon name="trash" size={16} />
181
- Delete
182
- {/snippet}
183
- </Button>
184
- {/if}
185
- </div>
186
- <div class="flex items-center gap-2">
178
+ <!-- Phone: full-width stacked buttons, primary on top, Delete at the bottom.
179
+ sm+: one row, Delete left, the rest pushed right. -->
180
+ <div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center">
181
+ {#if onDelete}
182
+ <Button variant="destructive" class={footerButtonClass} onclick={handleDelete}>
183
+ {#snippet children()}
184
+ <Icon name="trash" size={16} />
185
+ Delete
186
+ {/snippet}
187
+ </Button>
188
+ {/if}
189
+ <div class="flex flex-col-reverse gap-2 sm:ml-auto sm:flex-row sm:items-center">
187
190
  {#if notification.read && onMarkUnread}
188
- <Button variant="outline" onclick={handleMarkUnread}>
191
+ <Button class={footerButtonClass} variant="outline" onclick={handleMarkUnread}>
189
192
  {#snippet children()}
190
193
  <Icon name="mail" size={16} />
191
194
  Mark Unread
192
195
  {/snippet}
193
196
  </Button>
194
197
  {:else if !notification.read && onMarkRead}
195
- <Button variant="outline" onclick={handleMarkRead}>
198
+ <Button class={footerButtonClass} variant="outline" onclick={handleMarkRead}>
196
199
  {#snippet children()}
197
200
  <Icon name="check" size={16} />
198
201
  Mark Read
@@ -200,7 +203,7 @@
200
203
  </Button>
201
204
  {/if}
202
205
  {#if notification.actionUrl}
203
- <Button href={notification.actionUrl}>
206
+ <Button class={footerButtonClass} href={notification.actionUrl}>
204
207
  {#snippet children()}
205
208
  {notification.actionLabel || 'View Details'}
206
209
  {/snippet}
@@ -58,6 +58,9 @@ export { default as Signature } from './components/Signature.svelte';
58
58
  export { default as DataTable } from './components/DataTable.svelte';
59
59
  export { default as Tabs } from './components/Tabs.svelte';
60
60
  export { default as TabPanel } from './components/TabPanel.svelte';
61
+ export { default as Toolbar } from './components/Toolbar.svelte';
62
+ export { default as SegmentedControl, type SegmentedControlOption, } from './components/SegmentedControl.svelte';
63
+ export { default as ViewToggle, type ViewToggleOption, defaultViewToggleOptions, } from './components/ViewToggle.svelte';
61
64
  export { AspectRatio, Image, Video, Audio, MediaEmbed, MediaGrid, MediaCarousel, Lightbox, } from './components/media/index.js';
62
65
  export type { MediaGridColumns } from './components/media/MediaGrid.svelte';
63
66
  export type { DropdownMenuItem, DropdownMenuGroup, SelectOption, SelectGroup, Tab, FileMetadata, DataTableColumn, DataTableFilterFn, DataTableFilterOption, RadioOption, ComboboxOption, MultiSelectOption, SignatureData, SignatureStroke, SignaturePoint, SignatureMetadata, SignatureFont, SignatureValidationResult, MediaKind, MediaEmbedProvider, MediaSource, MediaTextTrack, MediaItemBase, ImageMediaItem, VideoMediaItem, AudioMediaItem, EmbedMediaItem, MediaItem, MediaAspectRatio, MediaFit, CloudflareStreamAccount, MatterportPlayerOptions, } from './types/components.js';
package/dist/lib/index.js CHANGED
@@ -70,6 +70,10 @@ export { default as DataTable } from './components/DataTable.svelte';
70
70
  // Tabs
71
71
  export { default as Tabs } from './components/Tabs.svelte';
72
72
  export { default as TabPanel } from './components/TabPanel.svelte';
73
+ // Toolbar controls
74
+ export { default as Toolbar } from './components/Toolbar.svelte';
75
+ export { default as SegmentedControl, } from './components/SegmentedControl.svelte';
76
+ export { default as ViewToggle, defaultViewToggleOptions, } from './components/ViewToggle.svelte';
73
77
  // Media Components
74
78
  export { AspectRatio, Image, Video, Audio, MediaEmbed, MediaGrid, MediaCarousel, Lightbox, } from './components/media/index.js';
75
79
  // DataTable sub-components and utilities
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",