@classic-homes/theme-svelte 1.10.1 → 1.12.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/DropdownMenu.a11y.test.js +28 -0
- package/dist/__tests__/action-header.test.js +136 -0
- package/dist/lib/components/ActionHeader.svelte +444 -0
- package/dist/lib/components/ActionHeader.svelte.d.ts +65 -0
- package/dist/lib/components/DataPanelHeader.svelte +31 -50
- package/dist/lib/components/DataTable.svelte +3 -3
- package/dist/lib/components/DropdownMenu.svelte +56 -49
- package/dist/lib/components/DropdownMenu.svelte.d.ts +9 -0
- package/dist/lib/components/RadioGroup.svelte +4 -1
- package/dist/lib/components/notifications/NotificationDialog.svelte +18 -15
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/types/components.d.ts +5 -0
- package/package.json +1 -1
|
@@ -101,4 +101,32 @@ describe('DropdownMenu accessibility', () => {
|
|
|
101
101
|
const menuItems = screen.getAllByRole('menuitem');
|
|
102
102
|
expect(menuItems.length).toBeGreaterThan(0);
|
|
103
103
|
});
|
|
104
|
+
it('applies triggerLabel and triggerClass to the trigger button', () => {
|
|
105
|
+
render(DropdownMenu, {
|
|
106
|
+
props: {
|
|
107
|
+
items,
|
|
108
|
+
trigger: createTriggerSnippet(),
|
|
109
|
+
triggerLabel: 'More actions',
|
|
110
|
+
triggerClass: 'custom-trigger',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const trigger = screen.getByRole('button', { name: 'More actions' });
|
|
114
|
+
expect(trigger).toHaveClass('custom-trigger');
|
|
115
|
+
// No nested buttons
|
|
116
|
+
expect(trigger.querySelector('button')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
it('renders href items as links', async () => {
|
|
119
|
+
render(DropdownMenu, {
|
|
120
|
+
props: {
|
|
121
|
+
items: [{ label: 'Open', href: '/somewhere', icon: 'external-link' }, ...items],
|
|
122
|
+
trigger: createTriggerSnippet(),
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
// Earlier tests leave Bits UI's `pointer-events: none` on <body> in jsdom.
|
|
126
|
+
await userEvent.setup({ pointerEventsCheck: 0 }).click(screen.getByRole('button'));
|
|
127
|
+
const link = await screen.findByRole('menuitem', { name: 'Open' });
|
|
128
|
+
expect(link.tagName.toLowerCase()).toBe('a');
|
|
129
|
+
expect(link).toHaveAttribute('href', '/somewhere');
|
|
130
|
+
expect(screen.getByRole('menuitem', { name: 'Edit' }).tagName.toLowerCase()).toBe('div');
|
|
131
|
+
});
|
|
104
132
|
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, within } from '@testing-library/svelte';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { axe } from 'vitest-axe';
|
|
5
|
+
import { createRawSnippet } from 'svelte';
|
|
6
|
+
import ActionHeader from '../lib/components/ActionHeader.svelte';
|
|
7
|
+
// Bits UI leaves `pointer-events: none` on <body> after a menu closes in jsdom.
|
|
8
|
+
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
9
|
+
const snippet = (html) => createRawSnippet(() => ({ render: () => html }));
|
|
10
|
+
const actions = [
|
|
11
|
+
{ label: 'Export', icon: 'download', onSelect: () => { } },
|
|
12
|
+
{ label: 'Add sign', icon: 'plus', priority: 'primary', onSelect: () => { } },
|
|
13
|
+
{ label: 'Archive', priority: 'overflow', onSelect: () => { } },
|
|
14
|
+
{ label: 'Delete', priority: 'overflow', destructive: true, onSelect: () => { } },
|
|
15
|
+
{ label: 'Secret', priority: 'overflow', hidden: true, onSelect: () => { } },
|
|
16
|
+
];
|
|
17
|
+
async function openMenu(container, selector) {
|
|
18
|
+
const wrapper = container.querySelector(selector);
|
|
19
|
+
await user.click(within(wrapper).getByRole('button', { name: 'More actions' }));
|
|
20
|
+
return screen.findByRole('menu');
|
|
21
|
+
}
|
|
22
|
+
describe('ActionHeader', () => {
|
|
23
|
+
it('renders title and subtitle with the requested heading level', () => {
|
|
24
|
+
render(ActionHeader, {
|
|
25
|
+
props: { title: 'Signs', subtitle: 'All signage', headingLevel: 2 },
|
|
26
|
+
});
|
|
27
|
+
expect(screen.getByRole('heading', { level: 2, name: 'Signs' })).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByText('All signage')).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
it('defaults to h1 and renders no action cluster without actions', () => {
|
|
31
|
+
const { container } = render(ActionHeader, { props: { title: 'Account' } });
|
|
32
|
+
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
|
33
|
+
expect(container.querySelector('.ah-actions')).toBeNull();
|
|
34
|
+
expect(screen.queryByRole('button')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
it('renders a labelled back link', () => {
|
|
37
|
+
render(ActionHeader, { props: { title: 'Sign 12', backHref: '/signs', backLabel: 'Signs' } });
|
|
38
|
+
const back = screen.getByRole('link', { name: 'Back to Signs' });
|
|
39
|
+
expect(back).toHaveAttribute('href', '/signs');
|
|
40
|
+
});
|
|
41
|
+
it('places primary and secondary actions inline, and omits hidden ones', () => {
|
|
42
|
+
const { container } = render(ActionHeader, { props: { title: 'Signs', actions } });
|
|
43
|
+
const secondary = container.querySelector('.ah-secondary');
|
|
44
|
+
expect(within(secondary).getByRole('button', { name: 'Export' })).toBeInTheDocument();
|
|
45
|
+
expect(screen.getByRole('button', { name: 'Add sign' })).toBeInTheDocument();
|
|
46
|
+
expect(screen.queryByText('Secret')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
it('wide menu holds only overflow actions, destructive last', async () => {
|
|
49
|
+
const { container } = render(ActionHeader, { props: { title: 'Signs', actions } });
|
|
50
|
+
const menu = await openMenu(container, '.ah-menu-wide');
|
|
51
|
+
const names = within(menu)
|
|
52
|
+
.getAllByRole('menuitem')
|
|
53
|
+
.map((el) => el.textContent?.trim());
|
|
54
|
+
expect(names).toEqual(['Archive', 'Delete']);
|
|
55
|
+
const separator = within(menu).queryByRole('separator') ?? menu.querySelector('.h-px');
|
|
56
|
+
expect(separator).not.toBeNull();
|
|
57
|
+
});
|
|
58
|
+
it('narrow menu folds secondary actions in before overflow', async () => {
|
|
59
|
+
const { container } = render(ActionHeader, { props: { title: 'Signs', actions } });
|
|
60
|
+
const menu = await openMenu(container, '.ah-menu-narrow');
|
|
61
|
+
const names = within(menu)
|
|
62
|
+
.getAllByRole('menuitem')
|
|
63
|
+
.map((el) => el.textContent?.trim());
|
|
64
|
+
expect(names).toEqual(['Export', 'Archive', 'Delete']);
|
|
65
|
+
});
|
|
66
|
+
it('with no secondary actions renders a single overflow menu', () => {
|
|
67
|
+
const { container } = render(ActionHeader, {
|
|
68
|
+
props: {
|
|
69
|
+
title: 'Sign',
|
|
70
|
+
actions: [
|
|
71
|
+
{ label: 'Check in', priority: 'primary' },
|
|
72
|
+
{ label: 'Delete', priority: 'overflow', destructive: true },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
expect(container.querySelector('.ah-menu-narrow')).toBeNull();
|
|
77
|
+
expect(screen.getAllByRole('button', { name: 'More actions' })).toHaveLength(1);
|
|
78
|
+
});
|
|
79
|
+
it('collapseAt="never" keeps every non-primary action inline with no menu', () => {
|
|
80
|
+
render(ActionHeader, { props: { title: 'Signs', actions, collapseAt: 'never' } });
|
|
81
|
+
expect(screen.getByRole('button', { name: 'Archive' })).toBeInTheDocument();
|
|
82
|
+
expect(screen.queryByRole('button', { name: 'More actions' })).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
it('auto collapse uses lg with 2+ secondary actions, md otherwise', () => {
|
|
85
|
+
const { container, unmount } = render(ActionHeader, { props: { title: 'A', actions } });
|
|
86
|
+
expect(container.querySelector('header')).toHaveAttribute('data-collapse', 'md');
|
|
87
|
+
unmount();
|
|
88
|
+
const two = [{ label: 'One' }, { label: 'Two' }];
|
|
89
|
+
const second = render(ActionHeader, { props: { title: 'B', actions: two } });
|
|
90
|
+
expect(second.container.querySelector('header')).toHaveAttribute('data-collapse', 'lg');
|
|
91
|
+
});
|
|
92
|
+
it('calls onSelect for inline and menu actions', async () => {
|
|
93
|
+
const onPrimary = vi.fn();
|
|
94
|
+
const onOverflow = vi.fn();
|
|
95
|
+
const { container } = render(ActionHeader, {
|
|
96
|
+
props: {
|
|
97
|
+
title: 'Signs',
|
|
98
|
+
actions: [
|
|
99
|
+
{ label: 'Add', priority: 'primary', onSelect: onPrimary },
|
|
100
|
+
{ label: 'Archive', priority: 'overflow', onSelect: onOverflow },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
await user.click(screen.getByRole('button', { name: 'Add' }));
|
|
105
|
+
expect(onPrimary).toHaveBeenCalledOnce();
|
|
106
|
+
const menu = await openMenu(container, '.ah');
|
|
107
|
+
await user.click(within(menu).getByRole('menuitem', { name: 'Archive' }));
|
|
108
|
+
expect(onOverflow).toHaveBeenCalledOnce();
|
|
109
|
+
});
|
|
110
|
+
it('renders badges, tools and toolbar snippets', () => {
|
|
111
|
+
render(ActionHeader, {
|
|
112
|
+
props: {
|
|
113
|
+
title: 'Sign 12',
|
|
114
|
+
badges: snippet('<span>Active</span>'),
|
|
115
|
+
tools: snippet('<button type="button" aria-label="Related">R</button>'),
|
|
116
|
+
toolbar: snippet('<div>View toggle</div>'),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
expect(screen.getByText('Active')).toBeInTheDocument();
|
|
120
|
+
expect(screen.getByRole('button', { name: 'Related' })).toBeInTheDocument();
|
|
121
|
+
expect(screen.getByText('View toggle')).toBeInTheDocument();
|
|
122
|
+
});
|
|
123
|
+
it('has no axe violations', async () => {
|
|
124
|
+
const { container } = render(ActionHeader, {
|
|
125
|
+
props: {
|
|
126
|
+
title: 'Sign 12',
|
|
127
|
+
subtitle: 'Main entrance monument',
|
|
128
|
+
backHref: '/signs',
|
|
129
|
+
backLabel: 'Signs',
|
|
130
|
+
actions,
|
|
131
|
+
badges: snippet('<span>Active</span>'),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { IconName } from './Icon.svelte';
|
|
3
|
+
|
|
4
|
+
export interface ActionHeaderAction {
|
|
5
|
+
/** Button / menu item text (also the accessible name when shown icon-only) */
|
|
6
|
+
label: string;
|
|
7
|
+
/** Leading icon */
|
|
8
|
+
icon?: IconName;
|
|
9
|
+
/** Navigate instead of (or as well as) calling `onSelect` */
|
|
10
|
+
href?: string;
|
|
11
|
+
onSelect?: () => void;
|
|
12
|
+
/**
|
|
13
|
+
* - `primary`: always a visible button (keep it to one per header)
|
|
14
|
+
* - `secondary` (default): a visible button on wide headers, folded into the
|
|
15
|
+
* "More actions" menu when the header is narrow
|
|
16
|
+
* - `overflow`: always in the "More actions" menu
|
|
17
|
+
*/
|
|
18
|
+
priority?: 'primary' | 'secondary' | 'overflow';
|
|
19
|
+
/** Button variant when rendered inline (default: `default` for primary, `outline` otherwise) */
|
|
20
|
+
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link';
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/** Tooltip for inline buttons, e.g. why the action is disabled */
|
|
23
|
+
title?: string;
|
|
24
|
+
loading?: boolean;
|
|
25
|
+
/** Destructive styling in the menu; destructive items sort last, after a separator */
|
|
26
|
+
destructive?: boolean;
|
|
27
|
+
/** Omit the action (lets callers build the array with permission conditionals inline) */
|
|
28
|
+
hidden?: boolean;
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<script lang="ts">
|
|
33
|
+
import type { Snippet } from 'svelte';
|
|
34
|
+
import { cn } from '../utils.js';
|
|
35
|
+
import Button from './Button.svelte';
|
|
36
|
+
import Icon from './Icon.svelte';
|
|
37
|
+
import DropdownMenu, { type DropdownMenuItem } from './DropdownMenu.svelte';
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
/** Page or record title */
|
|
41
|
+
title: string;
|
|
42
|
+
/** One- or two-sentence description; clamped to two lines on narrow headers */
|
|
43
|
+
subtitle?: string;
|
|
44
|
+
/** Heading level for the title (default 1) */
|
|
45
|
+
headingLevel?: 1 | 2 | 3;
|
|
46
|
+
/** Renders a back (arrow) button linking here */
|
|
47
|
+
backHref?: string;
|
|
48
|
+
/** What the back button returns to, e.g. "Signs" → aria-label "Back to Signs" */
|
|
49
|
+
backLabel?: string;
|
|
50
|
+
/** Header actions; see `ActionHeaderAction.priority` for how they collapse */
|
|
51
|
+
actions?: ActionHeaderAction[];
|
|
52
|
+
/**
|
|
53
|
+
* Header width below which secondary actions fold into the "More actions"
|
|
54
|
+
* menu: `sm` ≈ 28rem, `md` ≈ 40rem, `lg` ≈ 52rem, `never` keeps them inline.
|
|
55
|
+
* `auto` (default) is `lg` with two or more secondary actions, else `md`.
|
|
56
|
+
*/
|
|
57
|
+
collapseAt?: 'auto' | 'sm' | 'md' | 'lg' | 'never';
|
|
58
|
+
/** Accessible name of the overflow menu trigger */
|
|
59
|
+
moreLabel?: string;
|
|
60
|
+
/** Leading visual after the back button (e.g. a record thumbnail) */
|
|
61
|
+
media?: Snippet;
|
|
62
|
+
/** Inline content right after the title (keep it small, e.g. one badge) */
|
|
63
|
+
titleSuffix?: Snippet;
|
|
64
|
+
/** Status badges / meta line under the title */
|
|
65
|
+
badges?: Snippet;
|
|
66
|
+
/** Always-visible icon buttons at the end of the header (e.g. related/source tools) */
|
|
67
|
+
tools?: Snippet;
|
|
68
|
+
/** Non-button controls (view toggles, selects) on their own row under the header */
|
|
69
|
+
toolbar?: Snippet;
|
|
70
|
+
class?: string;
|
|
71
|
+
[key: string]: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let {
|
|
75
|
+
title,
|
|
76
|
+
subtitle,
|
|
77
|
+
headingLevel = 1,
|
|
78
|
+
backHref,
|
|
79
|
+
backLabel,
|
|
80
|
+
actions = [],
|
|
81
|
+
collapseAt = 'auto',
|
|
82
|
+
moreLabel = 'More actions',
|
|
83
|
+
media,
|
|
84
|
+
titleSuffix,
|
|
85
|
+
badges,
|
|
86
|
+
tools,
|
|
87
|
+
toolbar,
|
|
88
|
+
class: className,
|
|
89
|
+
...restProps
|
|
90
|
+
}: Props = $props();
|
|
91
|
+
|
|
92
|
+
const visible = $derived(actions.filter((a) => !a.hidden));
|
|
93
|
+
const primary = $derived(visible.filter((a) => a.priority === 'primary'));
|
|
94
|
+
const secondary = $derived(
|
|
95
|
+
collapseAt === 'never'
|
|
96
|
+
? visible.filter((a) => a.priority !== 'primary')
|
|
97
|
+
: visible.filter((a) => (a.priority ?? 'secondary') === 'secondary')
|
|
98
|
+
);
|
|
99
|
+
const overflow = $derived(
|
|
100
|
+
collapseAt === 'never' ? [] : visible.filter((a) => a.priority === 'overflow')
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const collapse = $derived(
|
|
104
|
+
collapseAt === 'auto' ? (secondary.length >= 2 ? 'lg' : 'md') : collapseAt
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
function toMenu(list: ActionHeaderAction[]): DropdownMenuItem[] {
|
|
108
|
+
const safe = list.filter((a) => !a.destructive);
|
|
109
|
+
const danger = list.filter((a) => a.destructive);
|
|
110
|
+
const item = (a: ActionHeaderAction): DropdownMenuItem => ({
|
|
111
|
+
label: a.label,
|
|
112
|
+
icon: a.icon,
|
|
113
|
+
href: a.href,
|
|
114
|
+
onSelect: a.onSelect,
|
|
115
|
+
disabled: a.disabled || a.loading,
|
|
116
|
+
destructive: a.destructive,
|
|
117
|
+
});
|
|
118
|
+
return [
|
|
119
|
+
...safe.map(item),
|
|
120
|
+
...(safe.length && danger.length ? [{ type: 'separator' as const }] : []),
|
|
121
|
+
...danger.map(item),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Menu while secondary actions are inline (wide header) */
|
|
126
|
+
const wideMenu = $derived(toMenu(overflow));
|
|
127
|
+
/** Menu once secondary actions have folded in (narrow header) */
|
|
128
|
+
const narrowMenu = $derived(toMenu([...secondary, ...overflow]));
|
|
129
|
+
|
|
130
|
+
const headingTag = $derived(`h${headingLevel}`);
|
|
131
|
+
const hasLead = $derived(Boolean(backHref || media));
|
|
132
|
+
const hasActions = $derived(primary.length > 0 || secondary.length > 0 || overflow.length > 0);
|
|
133
|
+
|
|
134
|
+
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';
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
{#snippet moreMenu(items: DropdownMenuItem[])}
|
|
139
|
+
<DropdownMenu {items} align="end" triggerClass={menuTriggerClass} triggerLabel={moreLabel}>
|
|
140
|
+
{#snippet trigger()}
|
|
141
|
+
<Icon name="more-horizontal" size={16} aria-hidden="true" />
|
|
142
|
+
{/snippet}
|
|
143
|
+
</DropdownMenu>
|
|
144
|
+
{/snippet}
|
|
145
|
+
|
|
146
|
+
<header class={cn('ah', className)} data-collapse={collapse} {...restProps}>
|
|
147
|
+
<div class="ah-grid">
|
|
148
|
+
{#if hasLead}
|
|
149
|
+
<div class="ah-lead">
|
|
150
|
+
{#if backHref}
|
|
151
|
+
<Button
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="icon"
|
|
154
|
+
href={backHref}
|
|
155
|
+
class="h-9 w-9 shrink-0"
|
|
156
|
+
aria-label={backLabel ? `Back to ${backLabel}` : 'Back'}
|
|
157
|
+
>
|
|
158
|
+
<Icon name="arrow-left" size={18} aria-hidden="true" />
|
|
159
|
+
</Button>
|
|
160
|
+
{/if}
|
|
161
|
+
{#if media}
|
|
162
|
+
<div class="shrink-0">{@render media()}</div>
|
|
163
|
+
{/if}
|
|
164
|
+
</div>
|
|
165
|
+
{/if}
|
|
166
|
+
|
|
167
|
+
<div class="ah-heading">
|
|
168
|
+
<div class="flex min-w-0 items-center gap-2">
|
|
169
|
+
<svelte:element
|
|
170
|
+
this={headingTag}
|
|
171
|
+
class="ah-title text-[length:var(--ah-title-size)] font-bold leading-[var(--ah-title-leading)] tracking-tight text-foreground"
|
|
172
|
+
>
|
|
173
|
+
{title}
|
|
174
|
+
</svelte:element>
|
|
175
|
+
{#if titleSuffix}
|
|
176
|
+
<div class="flex shrink-0 items-center gap-2">{@render titleSuffix()}</div>
|
|
177
|
+
{/if}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{#if subtitle}
|
|
182
|
+
<p
|
|
183
|
+
class="ah-subtitle text-[length:var(--ah-subtitle-size)] leading-[var(--ah-subtitle-leading)] text-muted-foreground"
|
|
184
|
+
>
|
|
185
|
+
{subtitle}
|
|
186
|
+
</p>
|
|
187
|
+
{/if}
|
|
188
|
+
|
|
189
|
+
{#if hasActions}
|
|
190
|
+
<div class="ah-actions">
|
|
191
|
+
{#if secondary.length}
|
|
192
|
+
<div class="ah-secondary">
|
|
193
|
+
{#each secondary as action}
|
|
194
|
+
<Button
|
|
195
|
+
variant={action.variant ?? (action.destructive ? 'destructive' : 'outline')}
|
|
196
|
+
size="sm"
|
|
197
|
+
href={action.disabled ? undefined : action.href}
|
|
198
|
+
disabled={action.disabled}
|
|
199
|
+
loading={action.loading}
|
|
200
|
+
title={action.title}
|
|
201
|
+
onclick={() => action.onSelect?.()}
|
|
202
|
+
>
|
|
203
|
+
{#if action.icon}<Icon name={action.icon} size={16} aria-hidden="true" />{/if}
|
|
204
|
+
{action.label}
|
|
205
|
+
</Button>
|
|
206
|
+
{/each}
|
|
207
|
+
</div>
|
|
208
|
+
{/if}
|
|
209
|
+
|
|
210
|
+
{#each primary as action}
|
|
211
|
+
<Button
|
|
212
|
+
variant={action.variant ?? 'default'}
|
|
213
|
+
size="sm"
|
|
214
|
+
href={action.disabled ? undefined : action.href}
|
|
215
|
+
disabled={action.disabled}
|
|
216
|
+
loading={action.loading}
|
|
217
|
+
onclick={() => action.onSelect?.()}
|
|
218
|
+
title={action.title ?? action.label}
|
|
219
|
+
>
|
|
220
|
+
{#if action.icon}<Icon name={action.icon} size={16} aria-hidden="true" />{/if}
|
|
221
|
+
<span class="ah-primary-label" data-has-icon={action.icon ? '' : undefined}>
|
|
222
|
+
{action.label}
|
|
223
|
+
</span>
|
|
224
|
+
</Button>
|
|
225
|
+
{/each}
|
|
226
|
+
|
|
227
|
+
{#if secondary.length}
|
|
228
|
+
{#if wideMenu.length}
|
|
229
|
+
<div class="ah-menu-wide">{@render moreMenu(wideMenu)}</div>
|
|
230
|
+
{/if}
|
|
231
|
+
{#if collapseAt !== 'never'}
|
|
232
|
+
<div class="ah-menu-narrow">{@render moreMenu(narrowMenu)}</div>
|
|
233
|
+
{/if}
|
|
234
|
+
{:else if wideMenu.length}
|
|
235
|
+
{@render moreMenu(wideMenu)}
|
|
236
|
+
{/if}
|
|
237
|
+
</div>
|
|
238
|
+
{/if}
|
|
239
|
+
|
|
240
|
+
{#if tools}
|
|
241
|
+
<div class="ah-tools">{@render tools()}</div>
|
|
242
|
+
{/if}
|
|
243
|
+
|
|
244
|
+
{#if badges}
|
|
245
|
+
<div class="ah-badges">{@render badges()}</div>
|
|
246
|
+
{/if}
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{#if toolbar}
|
|
250
|
+
<div class="ah-toolbar">{@render toolbar()}</div>
|
|
251
|
+
{/if}
|
|
252
|
+
</header>
|
|
253
|
+
|
|
254
|
+
<style>
|
|
255
|
+
/* Layout is driven by the header's own width (container queries), not the
|
|
256
|
+
viewport, so it responds to the sidebar opening/collapsing too. Narrow is
|
|
257
|
+
the base; the 40rem query switches to the wide arrangement. */
|
|
258
|
+
.ah {
|
|
259
|
+
container: action-header / inline-size;
|
|
260
|
+
min-width: 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.ah-grid {
|
|
264
|
+
--ah-title-size: 1.25rem;
|
|
265
|
+
--ah-title-leading: 1.75rem;
|
|
266
|
+
--ah-subtitle-size: 0.875rem;
|
|
267
|
+
--ah-subtitle-leading: 1.25rem;
|
|
268
|
+
display: grid;
|
|
269
|
+
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
|
270
|
+
grid-template-areas:
|
|
271
|
+
'lead heading actions actions'
|
|
272
|
+
'lead sub sub sub'
|
|
273
|
+
'lead badges badges tools';
|
|
274
|
+
align-items: center;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.ah-lead {
|
|
278
|
+
grid-area: lead;
|
|
279
|
+
align-self: start;
|
|
280
|
+
display: flex;
|
|
281
|
+
align-items: center;
|
|
282
|
+
gap: 0.5rem;
|
|
283
|
+
margin-inline-end: 0.5rem;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.ah-heading {
|
|
287
|
+
grid-area: heading;
|
|
288
|
+
min-width: 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* Type sizes are applied by utility classes reading these variables: the
|
|
292
|
+
theme's global heading reset out-specifies component-scoped rules. */
|
|
293
|
+
.ah-title {
|
|
294
|
+
min-width: 0;
|
|
295
|
+
overflow-wrap: anywhere;
|
|
296
|
+
display: -webkit-box;
|
|
297
|
+
-webkit-box-orient: vertical;
|
|
298
|
+
-webkit-line-clamp: 2;
|
|
299
|
+
line-clamp: 2;
|
|
300
|
+
overflow: hidden;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.ah-subtitle {
|
|
304
|
+
grid-area: sub;
|
|
305
|
+
margin-top: 0.5rem;
|
|
306
|
+
min-width: 0;
|
|
307
|
+
display: -webkit-box;
|
|
308
|
+
-webkit-box-orient: vertical;
|
|
309
|
+
-webkit-line-clamp: 2;
|
|
310
|
+
line-clamp: 2;
|
|
311
|
+
overflow: hidden;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.ah-actions {
|
|
315
|
+
grid-area: actions;
|
|
316
|
+
display: flex;
|
|
317
|
+
flex-wrap: nowrap;
|
|
318
|
+
align-items: center;
|
|
319
|
+
justify-content: flex-end;
|
|
320
|
+
gap: 0.5rem;
|
|
321
|
+
margin-inline-start: 0.75rem;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.ah-secondary {
|
|
325
|
+
display: none;
|
|
326
|
+
align-items: center;
|
|
327
|
+
gap: 0.5rem;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.ah-menu-wide {
|
|
331
|
+
display: none;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.ah-tools {
|
|
335
|
+
grid-area: tools;
|
|
336
|
+
display: flex;
|
|
337
|
+
align-items: center;
|
|
338
|
+
justify-content: flex-end;
|
|
339
|
+
gap: 0.25rem;
|
|
340
|
+
margin-inline-start: 0.5rem;
|
|
341
|
+
margin-top: 0.5rem;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.ah-badges {
|
|
345
|
+
grid-area: badges;
|
|
346
|
+
display: flex;
|
|
347
|
+
flex-wrap: wrap;
|
|
348
|
+
align-items: center;
|
|
349
|
+
gap: 0.5rem;
|
|
350
|
+
min-width: 0;
|
|
351
|
+
margin-top: 0.5rem;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.ah-badges:empty {
|
|
355
|
+
display: none;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.ah-toolbar {
|
|
359
|
+
display: flex;
|
|
360
|
+
flex-wrap: wrap;
|
|
361
|
+
align-items: center;
|
|
362
|
+
gap: 0.5rem;
|
|
363
|
+
margin-top: 1rem;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* Very narrow (≤ 320px): a primary action with an icon drops its visible label. */
|
|
367
|
+
@container action-header (max-width: 20rem) {
|
|
368
|
+
.ah-primary-label[data-has-icon] {
|
|
369
|
+
position: absolute;
|
|
370
|
+
width: 1px;
|
|
371
|
+
height: 1px;
|
|
372
|
+
padding: 0;
|
|
373
|
+
margin: -1px;
|
|
374
|
+
overflow: hidden;
|
|
375
|
+
clip: rect(0, 0, 0, 0);
|
|
376
|
+
white-space: nowrap;
|
|
377
|
+
border-width: 0;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* Secondary actions inline, per collapseAt */
|
|
382
|
+
@container action-header (min-width: 28rem) {
|
|
383
|
+
[data-collapse='sm'] .ah-secondary {
|
|
384
|
+
display: flex;
|
|
385
|
+
}
|
|
386
|
+
[data-collapse='sm'] .ah-menu-wide {
|
|
387
|
+
display: block;
|
|
388
|
+
}
|
|
389
|
+
[data-collapse='sm'] .ah-menu-narrow {
|
|
390
|
+
display: none;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
[data-collapse='never'] .ah-secondary {
|
|
395
|
+
display: flex;
|
|
396
|
+
}
|
|
397
|
+
[data-collapse='never'] .ah-menu-narrow {
|
|
398
|
+
display: none;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
@container action-header (min-width: 40rem) {
|
|
402
|
+
.ah-grid {
|
|
403
|
+
--ah-title-size: 1.5rem;
|
|
404
|
+
--ah-title-leading: 2rem;
|
|
405
|
+
--ah-subtitle-size: 1rem;
|
|
406
|
+
--ah-subtitle-leading: 1.5rem;
|
|
407
|
+
grid-template-areas:
|
|
408
|
+
'lead heading actions tools'
|
|
409
|
+
'lead sub actions tools'
|
|
410
|
+
'lead badges badges badges';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.ah-subtitle {
|
|
414
|
+
-webkit-line-clamp: 3;
|
|
415
|
+
line-clamp: 3;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.ah-tools {
|
|
419
|
+
margin-top: 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
[data-collapse='md'] .ah-secondary {
|
|
423
|
+
display: flex;
|
|
424
|
+
}
|
|
425
|
+
[data-collapse='md'] .ah-menu-wide {
|
|
426
|
+
display: block;
|
|
427
|
+
}
|
|
428
|
+
[data-collapse='md'] .ah-menu-narrow {
|
|
429
|
+
display: none;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
@container action-header (min-width: 52rem) {
|
|
434
|
+
[data-collapse='lg'] .ah-secondary {
|
|
435
|
+
display: flex;
|
|
436
|
+
}
|
|
437
|
+
[data-collapse='lg'] .ah-menu-wide {
|
|
438
|
+
display: block;
|
|
439
|
+
}
|
|
440
|
+
[data-collapse='lg'] .ah-menu-narrow {
|
|
441
|
+
display: none;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
</style>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { IconName } from './Icon.svelte';
|
|
2
|
+
export interface ActionHeaderAction {
|
|
3
|
+
/** Button / menu item text (also the accessible name when shown icon-only) */
|
|
4
|
+
label: string;
|
|
5
|
+
/** Leading icon */
|
|
6
|
+
icon?: IconName;
|
|
7
|
+
/** Navigate instead of (or as well as) calling `onSelect` */
|
|
8
|
+
href?: string;
|
|
9
|
+
onSelect?: () => void;
|
|
10
|
+
/**
|
|
11
|
+
* - `primary`: always a visible button (keep it to one per header)
|
|
12
|
+
* - `secondary` (default): a visible button on wide headers, folded into the
|
|
13
|
+
* "More actions" menu when the header is narrow
|
|
14
|
+
* - `overflow`: always in the "More actions" menu
|
|
15
|
+
*/
|
|
16
|
+
priority?: 'primary' | 'secondary' | 'overflow';
|
|
17
|
+
/** Button variant when rendered inline (default: `default` for primary, `outline` otherwise) */
|
|
18
|
+
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link';
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Tooltip for inline buttons, e.g. why the action is disabled */
|
|
21
|
+
title?: string;
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
/** Destructive styling in the menu; destructive items sort last, after a separator */
|
|
24
|
+
destructive?: boolean;
|
|
25
|
+
/** Omit the action (lets callers build the array with permission conditionals inline) */
|
|
26
|
+
hidden?: boolean;
|
|
27
|
+
}
|
|
28
|
+
import type { Snippet } from 'svelte';
|
|
29
|
+
interface Props {
|
|
30
|
+
/** Page or record title */
|
|
31
|
+
title: string;
|
|
32
|
+
/** One- or two-sentence description; clamped to two lines on narrow headers */
|
|
33
|
+
subtitle?: string;
|
|
34
|
+
/** Heading level for the title (default 1) */
|
|
35
|
+
headingLevel?: 1 | 2 | 3;
|
|
36
|
+
/** Renders a back (arrow) button linking here */
|
|
37
|
+
backHref?: string;
|
|
38
|
+
/** What the back button returns to, e.g. "Signs" → aria-label "Back to Signs" */
|
|
39
|
+
backLabel?: string;
|
|
40
|
+
/** Header actions; see `ActionHeaderAction.priority` for how they collapse */
|
|
41
|
+
actions?: ActionHeaderAction[];
|
|
42
|
+
/**
|
|
43
|
+
* Header width below which secondary actions fold into the "More actions"
|
|
44
|
+
* menu: `sm` ≈ 28rem, `md` ≈ 40rem, `lg` ≈ 52rem, `never` keeps them inline.
|
|
45
|
+
* `auto` (default) is `lg` with two or more secondary actions, else `md`.
|
|
46
|
+
*/
|
|
47
|
+
collapseAt?: 'auto' | 'sm' | 'md' | 'lg' | 'never';
|
|
48
|
+
/** Accessible name of the overflow menu trigger */
|
|
49
|
+
moreLabel?: string;
|
|
50
|
+
/** Leading visual after the back button (e.g. a record thumbnail) */
|
|
51
|
+
media?: Snippet;
|
|
52
|
+
/** Inline content right after the title (keep it small, e.g. one badge) */
|
|
53
|
+
titleSuffix?: Snippet;
|
|
54
|
+
/** Status badges / meta line under the title */
|
|
55
|
+
badges?: Snippet;
|
|
56
|
+
/** Always-visible icon buttons at the end of the header (e.g. related/source tools) */
|
|
57
|
+
tools?: Snippet;
|
|
58
|
+
/** Non-button controls (view toggles, selects) on their own row under the header */
|
|
59
|
+
toolbar?: Snippet;
|
|
60
|
+
class?: string;
|
|
61
|
+
[key: string]: unknown;
|
|
62
|
+
}
|
|
63
|
+
declare const ActionHeader: import("svelte").Component<Props, {}, "">;
|
|
64
|
+
type ActionHeader = ReturnType<typeof ActionHeader>;
|
|
65
|
+
export default ActionHeader;
|
|
@@ -82,34 +82,39 @@
|
|
|
82
82
|
]);
|
|
83
83
|
</script>
|
|
84
84
|
|
|
85
|
+
{#snippet modeMenu()}
|
|
86
|
+
<DropdownMenu
|
|
87
|
+
items={menuItems}
|
|
88
|
+
side="bottom"
|
|
89
|
+
align="end"
|
|
90
|
+
triggerLabel="Panel options"
|
|
91
|
+
triggerClass="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
92
|
+
>
|
|
93
|
+
{#snippet trigger()}
|
|
94
|
+
<svg
|
|
95
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
96
|
+
width="16"
|
|
97
|
+
height="16"
|
|
98
|
+
viewBox="0 0 24 24"
|
|
99
|
+
fill="none"
|
|
100
|
+
stroke="currentColor"
|
|
101
|
+
stroke-width="2"
|
|
102
|
+
stroke-linecap="round"
|
|
103
|
+
stroke-linejoin="round"
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
>
|
|
106
|
+
<circle cx="12" cy="12" r="1" />
|
|
107
|
+
<circle cx="12" cy="5" r="1" />
|
|
108
|
+
<circle cx="12" cy="19" r="1" />
|
|
109
|
+
</svg>
|
|
110
|
+
{/snippet}
|
|
111
|
+
</DropdownMenu>
|
|
112
|
+
{/snippet}
|
|
113
|
+
|
|
85
114
|
<!-- When showModeMenuOnly is true, only render the dropdown menu -->
|
|
86
115
|
{#if showModeMenuOnly}
|
|
87
116
|
{#if !disableModeSwitch}
|
|
88
|
-
|
|
89
|
-
{#snippet trigger()}
|
|
90
|
-
<button
|
|
91
|
-
type="button"
|
|
92
|
-
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
93
|
-
aria-label="Panel options"
|
|
94
|
-
>
|
|
95
|
-
<svg
|
|
96
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
97
|
-
width="16"
|
|
98
|
-
height="16"
|
|
99
|
-
viewBox="0 0 24 24"
|
|
100
|
-
fill="none"
|
|
101
|
-
stroke="currentColor"
|
|
102
|
-
stroke-width="2"
|
|
103
|
-
stroke-linecap="round"
|
|
104
|
-
stroke-linejoin="round"
|
|
105
|
-
>
|
|
106
|
-
<circle cx="12" cy="12" r="1" />
|
|
107
|
-
<circle cx="12" cy="5" r="1" />
|
|
108
|
-
<circle cx="12" cy="19" r="1" />
|
|
109
|
-
</svg>
|
|
110
|
-
</button>
|
|
111
|
-
{/snippet}
|
|
112
|
-
</DropdownMenu>
|
|
117
|
+
{@render modeMenu()}
|
|
113
118
|
{/if}
|
|
114
119
|
{:else}
|
|
115
120
|
<div
|
|
@@ -164,31 +169,7 @@
|
|
|
164
169
|
|
|
165
170
|
<!-- Mode/Position menu -->
|
|
166
171
|
{#if !disableModeSwitch}
|
|
167
|
-
|
|
168
|
-
{#snippet trigger()}
|
|
169
|
-
<button
|
|
170
|
-
type="button"
|
|
171
|
-
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
172
|
-
aria-label="Panel options"
|
|
173
|
-
>
|
|
174
|
-
<svg
|
|
175
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
176
|
-
width="16"
|
|
177
|
-
height="16"
|
|
178
|
-
viewBox="0 0 24 24"
|
|
179
|
-
fill="none"
|
|
180
|
-
stroke="currentColor"
|
|
181
|
-
stroke-width="2"
|
|
182
|
-
stroke-linecap="round"
|
|
183
|
-
stroke-linejoin="round"
|
|
184
|
-
>
|
|
185
|
-
<circle cx="12" cy="12" r="1" />
|
|
186
|
-
<circle cx="12" cy="5" r="1" />
|
|
187
|
-
<circle cx="12" cy="19" r="1" />
|
|
188
|
-
</svg>
|
|
189
|
-
</button>
|
|
190
|
-
{/snippet}
|
|
191
|
-
</DropdownMenu>
|
|
172
|
+
{@render modeMenu()}
|
|
192
173
|
{/if}
|
|
193
174
|
|
|
194
175
|
<!-- Close button -->
|
|
@@ -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
|
>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
3
3
|
import { cn } from '../utils.js';
|
|
4
4
|
import type { Snippet } from 'svelte';
|
|
5
|
+
import Icon, { type IconName } from './Icon.svelte';
|
|
5
6
|
|
|
6
7
|
export interface DropdownMenuItem {
|
|
7
8
|
type?: 'item' | 'separator' | 'label';
|
|
@@ -11,6 +12,10 @@
|
|
|
11
12
|
onSelect?: () => void;
|
|
12
13
|
destructive?: boolean;
|
|
13
14
|
shortcut?: string;
|
|
15
|
+
/** Optional leading icon */
|
|
16
|
+
icon?: IconName;
|
|
17
|
+
/** Render the item as a link to this URL (selection still fires `onSelect`) */
|
|
18
|
+
href?: string;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export interface DropdownMenuGroup {
|
|
@@ -33,6 +38,10 @@
|
|
|
33
38
|
onOpenChange?: (open: boolean) => void;
|
|
34
39
|
/** Additional class for menu content */
|
|
35
40
|
class?: string;
|
|
41
|
+
/** Classes for the trigger `<button>` (style it here rather than nesting a button in `trigger`) */
|
|
42
|
+
triggerClass?: string;
|
|
43
|
+
/** Accessible name for the trigger `<button>` (for icon-only triggers) */
|
|
44
|
+
triggerLabel?: string;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
let {
|
|
@@ -43,6 +52,8 @@
|
|
|
43
52
|
open = $bindable(false),
|
|
44
53
|
onOpenChange,
|
|
45
54
|
class: className,
|
|
55
|
+
triggerClass,
|
|
56
|
+
triggerLabel,
|
|
46
57
|
}: Props = $props();
|
|
47
58
|
|
|
48
59
|
function handleOpenChange(newOpen: boolean) {
|
|
@@ -55,10 +66,52 @@
|
|
|
55
66
|
}
|
|
56
67
|
</script>
|
|
57
68
|
|
|
69
|
+
{#snippet itemContent(item: DropdownMenuItem)}
|
|
70
|
+
{#if item.icon}
|
|
71
|
+
<Icon name={item.icon} size={16} class="mr-2 shrink-0" aria-hidden="true" />
|
|
72
|
+
{/if}
|
|
73
|
+
{item.label}
|
|
74
|
+
{#if item.shortcut}
|
|
75
|
+
<span class="ml-auto pl-4 text-xs tracking-widest opacity-60" aria-hidden="true">
|
|
76
|
+
{item.shortcut}
|
|
77
|
+
</span>
|
|
78
|
+
<span class="sr-only">, keyboard shortcut {item.shortcut}</span>
|
|
79
|
+
{/if}
|
|
80
|
+
{/snippet}
|
|
81
|
+
|
|
82
|
+
{#snippet entry(item: DropdownMenuItem)}
|
|
83
|
+
{#if item.type === 'separator'}
|
|
84
|
+
<DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
|
|
85
|
+
{:else if item.type === 'label'}
|
|
86
|
+
<div role="presentation" class="px-2 py-1.5 text-sm font-semibold">
|
|
87
|
+
{item.label}
|
|
88
|
+
</div>
|
|
89
|
+
{:else}
|
|
90
|
+
{@const itemClass = cn(
|
|
91
|
+
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
|
|
92
|
+
item.destructive && 'text-destructive data-[highlighted]:text-destructive'
|
|
93
|
+
)}
|
|
94
|
+
<DropdownMenuPrimitive.Item
|
|
95
|
+
disabled={item.disabled}
|
|
96
|
+
onSelect={() => item.onSelect?.()}
|
|
97
|
+
class={itemClass}
|
|
98
|
+
aria-keyshortcuts={item.shortcut || undefined}
|
|
99
|
+
>
|
|
100
|
+
{#snippet child({ props })}
|
|
101
|
+
{#if item.href && !item.disabled}
|
|
102
|
+
<a href={item.href} {...props}>{@render itemContent(item)}</a>
|
|
103
|
+
{:else}
|
|
104
|
+
<div {...props}>{@render itemContent(item)}</div>
|
|
105
|
+
{/if}
|
|
106
|
+
{/snippet}
|
|
107
|
+
</DropdownMenuPrimitive.Item>
|
|
108
|
+
{/if}
|
|
109
|
+
{/snippet}
|
|
110
|
+
|
|
58
111
|
<DropdownMenuPrimitive.Root bind:open onOpenChange={handleOpenChange}>
|
|
59
112
|
<DropdownMenuPrimitive.Trigger>
|
|
60
113
|
{#snippet child({ props })}
|
|
61
|
-
<button type="button" {...props}>
|
|
114
|
+
<button type="button" {...props} class={triggerClass} aria-label={triggerLabel}>
|
|
62
115
|
{@render trigger()}
|
|
63
116
|
</button>
|
|
64
117
|
{/snippet}
|
|
@@ -85,57 +138,11 @@
|
|
|
85
138
|
</DropdownMenuPrimitive.GroupHeading>
|
|
86
139
|
{/if}
|
|
87
140
|
{#each item.items as subItem}
|
|
88
|
-
{
|
|
89
|
-
<DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
|
|
90
|
-
{:else if subItem.type === 'label'}
|
|
91
|
-
<div role="presentation" class="px-2 py-1.5 text-sm font-semibold">
|
|
92
|
-
{subItem.label}
|
|
93
|
-
</div>
|
|
94
|
-
{:else}
|
|
95
|
-
<DropdownMenuPrimitive.Item
|
|
96
|
-
disabled={subItem.disabled}
|
|
97
|
-
onSelect={() => subItem.onSelect?.()}
|
|
98
|
-
class={cn(
|
|
99
|
-
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
|
|
100
|
-
subItem.destructive && 'text-destructive data-[highlighted]:text-destructive'
|
|
101
|
-
)}
|
|
102
|
-
aria-keyshortcuts={subItem.shortcut || undefined}
|
|
103
|
-
>
|
|
104
|
-
{subItem.label}
|
|
105
|
-
{#if subItem.shortcut}
|
|
106
|
-
<span class="ml-auto text-xs tracking-widest opacity-60" aria-hidden="true">
|
|
107
|
-
{subItem.shortcut}
|
|
108
|
-
</span>
|
|
109
|
-
<span class="sr-only">, keyboard shortcut {subItem.shortcut}</span>
|
|
110
|
-
{/if}
|
|
111
|
-
</DropdownMenuPrimitive.Item>
|
|
112
|
-
{/if}
|
|
141
|
+
{@render entry(subItem)}
|
|
113
142
|
{/each}
|
|
114
143
|
</DropdownMenuPrimitive.Group>
|
|
115
|
-
{:else if item.type === 'separator'}
|
|
116
|
-
<DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
|
|
117
|
-
{:else if item.type === 'label'}
|
|
118
|
-
<div role="presentation" class="px-2 py-1.5 text-sm font-semibold">
|
|
119
|
-
{item.label}
|
|
120
|
-
</div>
|
|
121
144
|
{:else}
|
|
122
|
-
|
|
123
|
-
disabled={item.disabled}
|
|
124
|
-
onSelect={() => item.onSelect?.()}
|
|
125
|
-
class={cn(
|
|
126
|
-
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
|
|
127
|
-
item.destructive && 'text-destructive data-[highlighted]:text-destructive'
|
|
128
|
-
)}
|
|
129
|
-
aria-keyshortcuts={item.shortcut || undefined}
|
|
130
|
-
>
|
|
131
|
-
{item.label}
|
|
132
|
-
{#if item.shortcut}
|
|
133
|
-
<span class="ml-auto text-xs tracking-widest opacity-60" aria-hidden="true">
|
|
134
|
-
{item.shortcut}
|
|
135
|
-
</span>
|
|
136
|
-
<span class="sr-only">, keyboard shortcut {item.shortcut}</span>
|
|
137
|
-
{/if}
|
|
138
|
-
</DropdownMenuPrimitive.Item>
|
|
145
|
+
{@render entry(item)}
|
|
139
146
|
{/if}
|
|
140
147
|
{/each}
|
|
141
148
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type IconName } from './Icon.svelte';
|
|
2
3
|
export interface DropdownMenuItem {
|
|
3
4
|
type?: 'item' | 'separator' | 'label';
|
|
4
5
|
label?: string;
|
|
@@ -7,6 +8,10 @@ export interface DropdownMenuItem {
|
|
|
7
8
|
onSelect?: () => void;
|
|
8
9
|
destructive?: boolean;
|
|
9
10
|
shortcut?: string;
|
|
11
|
+
/** Optional leading icon */
|
|
12
|
+
icon?: IconName;
|
|
13
|
+
/** Render the item as a link to this URL (selection still fires `onSelect`) */
|
|
14
|
+
href?: string;
|
|
10
15
|
}
|
|
11
16
|
export interface DropdownMenuGroup {
|
|
12
17
|
label?: string;
|
|
@@ -27,6 +32,10 @@ interface Props {
|
|
|
27
32
|
onOpenChange?: (open: boolean) => void;
|
|
28
33
|
/** Additional class for menu content */
|
|
29
34
|
class?: string;
|
|
35
|
+
/** Classes for the trigger `<button>` (style it here rather than nesting a button in `trigger`) */
|
|
36
|
+
triggerClass?: string;
|
|
37
|
+
/** Accessible name for the trigger `<button>` (for icon-only triggers) */
|
|
38
|
+
triggerLabel?: string;
|
|
30
39
|
}
|
|
31
40
|
declare const DropdownMenu: import("svelte").Component<Props, {}, "open">;
|
|
32
41
|
type DropdownMenu = ReturnType<typeof DropdownMenu>;
|
|
@@ -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}
|
|
@@ -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
|
@@ -69,6 +69,7 @@ export { default as AvatarImage } from './components/AvatarImage.svelte';
|
|
|
69
69
|
export { default as AvatarFallback } from './components/AvatarFallback.svelte';
|
|
70
70
|
export { default as Section } from './components/Section.svelte';
|
|
71
71
|
export { default as PageHeader } from './components/PageHeader.svelte';
|
|
72
|
+
export { default as ActionHeader, type ActionHeaderAction } from './components/ActionHeader.svelte';
|
|
72
73
|
export { default as AppShell } from './components/layout/AppShell.svelte';
|
|
73
74
|
export { default as DashboardLayout } from './components/layout/DashboardLayout.svelte';
|
|
74
75
|
export { default as PublicLayout } from './components/layout/PublicLayout.svelte';
|
package/dist/lib/index.js
CHANGED
|
@@ -90,6 +90,7 @@ export { default as AvatarFallback } from './components/AvatarFallback.svelte';
|
|
|
90
90
|
// Page Components
|
|
91
91
|
export { default as Section } from './components/Section.svelte';
|
|
92
92
|
export { default as PageHeader } from './components/PageHeader.svelte';
|
|
93
|
+
export { default as ActionHeader } from './components/ActionHeader.svelte';
|
|
93
94
|
// Layout Components
|
|
94
95
|
export { default as AppShell } from './components/layout/AppShell.svelte';
|
|
95
96
|
export { default as DashboardLayout } from './components/layout/DashboardLayout.svelte';
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* These types are exported from components but need to be
|
|
5
5
|
* re-exported from a TypeScript file for proper type inference.
|
|
6
6
|
*/
|
|
7
|
+
import type { IconName } from '../components/Icon.svelte';
|
|
7
8
|
/**
|
|
8
9
|
* Dropdown Menu Types
|
|
9
10
|
*/
|
|
@@ -15,6 +16,10 @@ export interface DropdownMenuItem {
|
|
|
15
16
|
onSelect?: () => void;
|
|
16
17
|
destructive?: boolean;
|
|
17
18
|
shortcut?: string;
|
|
19
|
+
/** Optional leading icon */
|
|
20
|
+
icon?: IconName;
|
|
21
|
+
/** Render the item as a link to this URL (selection still fires `onSelect`) */
|
|
22
|
+
href?: string;
|
|
18
23
|
}
|
|
19
24
|
export interface DropdownMenuGroup {
|
|
20
25
|
label?: string;
|