@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.
- package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +50 -0
- package/dist/__tests__/a11y/Toolbar.a11y.test.js +31 -0
- package/dist/__tests__/a11y/ViewToggle.a11y.test.js +40 -0
- package/dist/__tests__/action-header.test.js +14 -0
- package/dist/lib/components/ActionHeader.svelte +73 -7
- package/dist/lib/components/ActionHeader.svelte.d.ts +2 -0
- package/dist/lib/components/Button.svelte +3 -2
- package/dist/lib/components/DataTable.svelte +3 -3
- package/dist/lib/components/LogoMain.svelte +5 -5
- package/dist/lib/components/MultiSelect.svelte +2 -2
- package/dist/lib/components/PageHeader.svelte +18 -7
- package/dist/lib/components/PageHeader.svelte.d.ts +11 -2
- package/dist/lib/components/RadioGroup.svelte +4 -1
- package/dist/lib/components/SegmentedControl.svelte +89 -0
- package/dist/lib/components/SegmentedControl.svelte.d.ts +24 -0
- package/dist/lib/components/Slider.svelte +1 -1
- package/dist/lib/components/Toolbar.svelte +42 -0
- package/dist/lib/components/Toolbar.svelte.d.ts +24 -0
- package/dist/lib/components/ViewToggle.svelte +84 -0
- package/dist/lib/components/ViewToggle.svelte.d.ts +31 -0
- package/dist/lib/components/layout/ErrorLayout.svelte +1 -1
- package/dist/lib/components/notifications/NotificationDialog.svelte +18 -15
- package/dist/lib/index.d.ts +3 -0
- package/dist/lib/index.js +4 -0
- package/package.json +1 -1
|
@@ -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-
|
|
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
|
|
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=
|
|
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:
|
|
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-
|
|
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: '#
|
|
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: '#
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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: '
|
|
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: '
|
|
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:
|
|
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
|
-
<
|
|
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
|
-
|
|
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-
|
|
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;
|
|
@@ -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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<div class="flex
|
|
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}
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -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
|