@sovereignfs/ui 0.23.0 → 0.33.1
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/Button.module.css +75 -8
- package/dist/Calendar.module.css +126 -0
- package/dist/Card.module.css +4 -2
- package/dist/Checkbox.module.css +14 -2
- package/dist/CodeTextarea.module.css +53 -0
- package/dist/ConfirmDialog.module.css +109 -0
- package/dist/DatePicker.module.css +57 -0
- package/dist/Dialog.module.css +77 -50
- package/dist/DragHandleRow.module.css +9 -3
- package/dist/Drawer.module.css +61 -0
- package/dist/Icon.module.css +5 -0
- package/dist/Menu.module.css +82 -0
- package/dist/NavTabs.module.css +4 -2
- package/dist/OverlayHeader.module.css +98 -0
- package/dist/Popover.module.css +31 -1
- package/dist/SegmentedControl.module.css +4 -2
- package/dist/Sheet.module.css +55 -0
- package/dist/SplitPane.module.css +85 -0
- package/dist/StatusBadge.module.css +59 -0
- package/dist/SystemBanner.module.css +4 -2
- package/dist/Tabs.module.css +4 -2
- package/dist/TagInput.module.css +141 -0
- package/dist/Toast.module.css +4 -2
- package/dist/Tooltip.module.css +6 -1
- package/dist/index.d.ts +493 -17
- package/dist/index.js +1390 -209
- package/dist/tokens/primitives.css +19 -0
- package/dist/tokens/semantic.css +13 -0
- package/package.json +1 -1
- package/src/__tests__/motion.test.tsx +105 -0
- package/src/components/Button/Button.module.css +75 -8
- package/src/components/Button/Button.stories.tsx +6 -0
- package/src/components/Button/Button.tsx +17 -1
- package/src/components/Button/__tests__/Button.test.tsx +19 -0
- package/src/components/Calendar/Calendar.module.css +126 -0
- package/src/components/Calendar/Calendar.tsx +213 -0
- package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
- package/src/components/Calendar/dateUtils.ts +97 -0
- package/src/components/Card/Card.module.css +4 -2
- package/src/components/Checkbox/Checkbox.module.css +14 -2
- package/src/components/Checkbox/Checkbox.tsx +22 -12
- package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
- package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
- package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
- package/src/components/DatePicker/DatePicker.module.css +57 -0
- package/src/components/DatePicker/DatePicker.tsx +103 -0
- package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
- package/src/components/Dialog/Dialog.module.css +77 -50
- package/src/components/Dialog/Dialog.stories.tsx +51 -1
- package/src/components/Dialog/Dialog.tsx +86 -22
- package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
- package/src/components/DragHandleRow/DragHandleRow.module.css +9 -3
- package/src/components/Drawer/Drawer.module.css +61 -0
- package/src/components/Drawer/Drawer.tsx +117 -13
- package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
- package/src/components/Icon/Icon.module.css +5 -0
- package/src/components/Icon/Icon.stories.tsx +4 -3
- package/src/components/Icon/Icon.tsx +1 -1
- package/src/components/Icon/icons/calendar.tsx +23 -0
- package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -4
- package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
- package/src/components/Menu/Menu.module.css +82 -0
- package/src/components/Menu/Menu.tsx +137 -0
- package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
- package/src/components/NavTabs/NavTabs.module.css +4 -2
- package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
- package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
- package/src/components/Popover/Popover.module.css +31 -1
- package/src/components/Popover/Popover.stories.tsx +126 -1
- package/src/components/Popover/Popover.tsx +140 -7
- package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
- package/src/components/Sheet/Sheet.module.css +55 -0
- package/src/components/Sheet/Sheet.tsx +151 -0
- package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
- package/src/components/SplitPane/SplitPane.module.css +85 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
- package/src/components/SplitPane/SplitPane.tsx +149 -0
- package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
- package/src/components/StatusBadge/StatusBadge.module.css +59 -0
- package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
- package/src/components/StatusBadge/StatusBadge.tsx +65 -0
- package/src/components/SystemBanner/SystemBanner.module.css +4 -2
- package/src/components/Tabs/Tabs.module.css +4 -2
- package/src/components/TagInput/TagInput.module.css +141 -0
- package/src/components/TagInput/TagInput.stories.tsx +109 -0
- package/src/components/TagInput/TagInput.tsx +174 -0
- package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
- package/src/components/Toast/Toast.module.css +4 -2
- package/src/components/Tooltip/Tooltip.module.css +6 -1
- package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
- package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
- package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useDoubleTap.ts +91 -0
- package/src/hooks/useIsMobile.ts +33 -0
- package/src/hooks/useLongPress.ts +185 -0
- package/src/index.ts +30 -1
- package/src/motion.ts +78 -0
- package/src/stories/DesignSystemOverview.stories.tsx +292 -4
- package/src/stories/InteractionHooks.stories.tsx +388 -0
- package/src/stories/TokenGallery.stories.tsx +85 -1
- package/src/tokens/primitives.css +19 -0
- package/src/tokens/semantic.css +13 -0
- package/src/components/Icon/icons/terminal.tsx +0 -19
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactElement } from 'react';
|
|
4
|
+
import { useIsMobile } from '../../hooks';
|
|
5
|
+
import { Drawer } from '../Drawer/Drawer';
|
|
6
|
+
import { Icon, type IconName } from '../Icon/Icon';
|
|
7
|
+
import { Popover } from '../Popover/Popover';
|
|
8
|
+
import styles from './Menu.module.css';
|
|
9
|
+
|
|
10
|
+
export interface MenuItem {
|
|
11
|
+
type?: 'item';
|
|
12
|
+
label: string;
|
|
13
|
+
onSelect: () => void;
|
|
14
|
+
icon?: IconName;
|
|
15
|
+
/** Styles the item as a destructive action (e.g. "Delete list"). */
|
|
16
|
+
destructive?: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Marks the item as one of a mutually-exclusive set (e.g. the active sort
|
|
20
|
+
* order) — renders a leading checkmark and `role="menuitemradio"` instead
|
|
21
|
+
* of `role="menuitem"`. Pass this on every item in the group, including
|
|
22
|
+
* the unchecked ones, so their labels stay aligned with the checked one's
|
|
23
|
+
* reserved checkmark gutter. Omit entirely for plain action items (e.g.
|
|
24
|
+
* "Delete list") — they render with no reserved leading space at all.
|
|
25
|
+
*/
|
|
26
|
+
checked?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A non-interactive section heading above a run of items (e.g. "Sort by"). */
|
|
30
|
+
export interface MenuLabel {
|
|
31
|
+
type: 'label';
|
|
32
|
+
label: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A visual divider between sections. */
|
|
36
|
+
export interface MenuSeparator {
|
|
37
|
+
type: 'separator';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type MenuEntry = MenuItem | MenuLabel | MenuSeparator;
|
|
41
|
+
|
|
42
|
+
export interface MenuProps {
|
|
43
|
+
/** The element that opens the menu — rendered as-is; the caller wires its
|
|
44
|
+
* onClick to flip `open` (same controlled pattern as `Popover`). */
|
|
45
|
+
trigger: ReactElement;
|
|
46
|
+
open: boolean;
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
items: MenuEntry[];
|
|
49
|
+
'aria-label': string;
|
|
50
|
+
/** Forwarded to `Popover` on desktop. Has no effect on mobile, where the
|
|
51
|
+
* menu is always a full-width `Drawer`. */
|
|
52
|
+
align?: 'left' | 'right';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Menu — an adaptive action menu: `Popover` on desktop, a bottom-sheet
|
|
57
|
+
* `Drawer` on mobile. Replaces the desktop-Popover/mobile-Drawer fork that
|
|
58
|
+
* `⋯` action menus (list options, row actions) otherwise re-derive per
|
|
59
|
+
* plugin. Same list of `items` renders in both presentations — only the
|
|
60
|
+
* surrounding chrome (floating panel vs. bottom sheet) differs, matching the
|
|
61
|
+
* platform's `useIsMobile` breakpoint.
|
|
62
|
+
*
|
|
63
|
+
* `items` accepts three entry shapes: a plain action (`MenuItem`, `type`
|
|
64
|
+
* omitted or `'item'`), a non-interactive section heading (`MenuLabel`,
|
|
65
|
+
* `type: 'label'`), and a `MenuSeparator` (`type: 'separator'`) — enough to
|
|
66
|
+
* express grouped sections like "Filter" / "Sort by" above a run of
|
|
67
|
+
* destructive actions, matching how native OS menus lay out mixed
|
|
68
|
+
* action/selection groups.
|
|
69
|
+
*
|
|
70
|
+
* Selecting an item both closes the menu and calls its `onSelect` — a
|
|
71
|
+
* consumer's `onSelect` never needs to call `onClose` itself.
|
|
72
|
+
*/
|
|
73
|
+
export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, align }: MenuProps) {
|
|
74
|
+
const isMobile = useIsMobile();
|
|
75
|
+
|
|
76
|
+
const list = (
|
|
77
|
+
<ul className={styles.list} role="menu">
|
|
78
|
+
{items.map((entry, index) => {
|
|
79
|
+
// Entries are a static, order-stable list per render — nothing
|
|
80
|
+
// reorders — so an index key is safe here.
|
|
81
|
+
if (entry.type === 'separator') {
|
|
82
|
+
return <li key={index} role="separator" className={styles.separator} />;
|
|
83
|
+
}
|
|
84
|
+
if (entry.type === 'label') {
|
|
85
|
+
return (
|
|
86
|
+
<li key={index} role="presentation" className={styles.label}>
|
|
87
|
+
{entry.label}
|
|
88
|
+
</li>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const isCheckable = entry.checked !== undefined;
|
|
92
|
+
return (
|
|
93
|
+
<li key={index} role="none">
|
|
94
|
+
<button
|
|
95
|
+
role={isCheckable ? 'menuitemradio' : 'menuitem'}
|
|
96
|
+
aria-checked={isCheckable ? entry.checked : undefined}
|
|
97
|
+
type="button"
|
|
98
|
+
className={[styles.item, entry.destructive ? styles.itemDestructive : '']
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join(' ')}
|
|
101
|
+
disabled={entry.disabled}
|
|
102
|
+
onClick={() => {
|
|
103
|
+
onClose();
|
|
104
|
+
entry.onSelect();
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{isCheckable && (
|
|
108
|
+
<span className={styles.check} aria-hidden>
|
|
109
|
+
{entry.checked && <Icon name="check" size="sm" aria-hidden />}
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
{entry.icon && <Icon name={entry.icon} size="sm" aria-hidden />}
|
|
113
|
+
{entry.label}
|
|
114
|
+
</button>
|
|
115
|
+
</li>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</ul>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (isMobile) {
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
{trigger}
|
|
125
|
+
<Drawer open={open} onClose={onClose} aria-label={ariaLabel}>
|
|
126
|
+
{list}
|
|
127
|
+
</Drawer>
|
|
128
|
+
</>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<Popover trigger={trigger} open={open} onClose={onClose} aria-label={ariaLabel} align={align}>
|
|
134
|
+
{list}
|
|
135
|
+
</Popover>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Menu, type MenuItem } from '../Menu';
|
|
5
|
+
|
|
6
|
+
let mobile = false;
|
|
7
|
+
function installMatchMedia() {
|
|
8
|
+
vi.stubGlobal(
|
|
9
|
+
'matchMedia',
|
|
10
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
11
|
+
// useIsMobile's query is '(max-width: ...)'; everything else (e.g.
|
|
12
|
+
// Drawer's prefers-reduced-motion check) defaults to false so the
|
|
13
|
+
// mobile-branch Drawer isn't accidentally forced into the
|
|
14
|
+
// reduced-motion path.
|
|
15
|
+
matches: query.includes('max-width') ? mobile : false,
|
|
16
|
+
media: query,
|
|
17
|
+
addEventListener: vi.fn(),
|
|
18
|
+
removeEventListener: vi.fn(),
|
|
19
|
+
addListener: vi.fn(),
|
|
20
|
+
removeListener: vi.fn(),
|
|
21
|
+
dispatchEvent: vi.fn(),
|
|
22
|
+
})),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function installPointerCapture() {
|
|
27
|
+
if (!Element.prototype.setPointerCapture) {
|
|
28
|
+
Element.prototype.setPointerCapture = () => {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const items: MenuItem[] = [
|
|
33
|
+
{ label: 'Rename', onSelect: vi.fn() },
|
|
34
|
+
{ label: 'Delete', onSelect: vi.fn(), destructive: true },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
describe('Menu', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
mobile = false;
|
|
40
|
+
installMatchMedia();
|
|
41
|
+
installPointerCapture();
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.unstubAllGlobals();
|
|
45
|
+
cleanup();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renders the trigger regardless of open state', () => {
|
|
49
|
+
render(
|
|
50
|
+
<Menu
|
|
51
|
+
trigger={<button type="button">Open menu</button>}
|
|
52
|
+
open={false}
|
|
53
|
+
onClose={() => {}}
|
|
54
|
+
items={items}
|
|
55
|
+
aria-label="List actions"
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
expect(screen.getByRole('button', { name: 'Open menu' })).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders as a Popover on desktop', () => {
|
|
62
|
+
render(
|
|
63
|
+
<Menu
|
|
64
|
+
trigger={<button type="button">Open menu</button>}
|
|
65
|
+
open
|
|
66
|
+
onClose={() => {}}
|
|
67
|
+
items={items}
|
|
68
|
+
aria-label="List actions"
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
const menu = screen.getByRole('dialog', { name: 'List actions' });
|
|
72
|
+
// Popover's own aria-modal is explicitly false (non-modal floating panel).
|
|
73
|
+
expect(menu.getAttribute('aria-modal')).toBe('false');
|
|
74
|
+
expect(screen.getByRole('menuitem', { name: 'Rename' })).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders as a Drawer on mobile', () => {
|
|
78
|
+
mobile = true;
|
|
79
|
+
installMatchMedia();
|
|
80
|
+
render(
|
|
81
|
+
<Menu
|
|
82
|
+
trigger={<button type="button">Open menu</button>}
|
|
83
|
+
open
|
|
84
|
+
onClose={() => {}}
|
|
85
|
+
items={items}
|
|
86
|
+
aria-label="List actions"
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
const menu = screen.getByRole('dialog', { name: 'List actions' });
|
|
90
|
+
expect(menu.getAttribute('aria-modal')).toBe('true');
|
|
91
|
+
expect(screen.getByRole('menuitem', { name: 'Rename' })).toBeTruthy();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('calls both onClose and the item onSelect when an item is chosen', () => {
|
|
95
|
+
const onClose = vi.fn();
|
|
96
|
+
const onSelect = vi.fn();
|
|
97
|
+
render(
|
|
98
|
+
<Menu
|
|
99
|
+
trigger={<button type="button">Open menu</button>}
|
|
100
|
+
open
|
|
101
|
+
onClose={onClose}
|
|
102
|
+
items={[{ label: 'Rename', onSelect }]}
|
|
103
|
+
aria-label="List actions"
|
|
104
|
+
/>,
|
|
105
|
+
);
|
|
106
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }));
|
|
107
|
+
expect(onSelect).toHaveBeenCalledOnce();
|
|
108
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('disables an item and does not fire onSelect when clicked', () => {
|
|
112
|
+
const onSelect = vi.fn();
|
|
113
|
+
render(
|
|
114
|
+
<Menu
|
|
115
|
+
trigger={<button type="button">Open menu</button>}
|
|
116
|
+
open
|
|
117
|
+
onClose={() => {}}
|
|
118
|
+
items={[{ label: 'Archive', onSelect, disabled: true }]}
|
|
119
|
+
aria-label="List actions"
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Archive' }));
|
|
123
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('marks a destructive item distinctly from a default one', () => {
|
|
127
|
+
render(
|
|
128
|
+
<Menu
|
|
129
|
+
trigger={<button type="button">Open menu</button>}
|
|
130
|
+
open
|
|
131
|
+
onClose={() => {}}
|
|
132
|
+
items={items}
|
|
133
|
+
aria-label="List actions"
|
|
134
|
+
/>,
|
|
135
|
+
);
|
|
136
|
+
const renameClass = screen.getByRole('menuitem', { name: 'Rename' }).className;
|
|
137
|
+
const deleteClass = screen.getByRole('menuitem', { name: 'Delete' }).className;
|
|
138
|
+
expect(deleteClass).not.toBe(renameClass);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('renders section labels and separators as non-interactive entries', () => {
|
|
142
|
+
render(
|
|
143
|
+
<Menu
|
|
144
|
+
trigger={<button type="button">Open menu</button>}
|
|
145
|
+
open
|
|
146
|
+
onClose={() => {}}
|
|
147
|
+
items={[
|
|
148
|
+
{ type: 'label', label: 'Sort by' },
|
|
149
|
+
{ label: 'Title', onSelect: vi.fn(), checked: true },
|
|
150
|
+
{ type: 'separator' },
|
|
151
|
+
{ label: 'Delete list', onSelect: vi.fn(), destructive: true },
|
|
152
|
+
]}
|
|
153
|
+
aria-label="List actions"
|
|
154
|
+
/>,
|
|
155
|
+
);
|
|
156
|
+
expect(screen.getByText('Sort by').getAttribute('role')).toBe('presentation');
|
|
157
|
+
expect(screen.getByRole('separator')).toBeTruthy();
|
|
158
|
+
// Only the plain action item is a plain menuitem; the checkable one is a menuitemradio.
|
|
159
|
+
expect(screen.getAllByRole('menuitem')).toHaveLength(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('renders a checkable item as menuitemradio with aria-checked', () => {
|
|
163
|
+
render(
|
|
164
|
+
<Menu
|
|
165
|
+
trigger={<button type="button">Open menu</button>}
|
|
166
|
+
open
|
|
167
|
+
onClose={() => {}}
|
|
168
|
+
items={[
|
|
169
|
+
{ label: 'Manual', onSelect: vi.fn(), checked: false },
|
|
170
|
+
{ label: 'Title', onSelect: vi.fn(), checked: true },
|
|
171
|
+
]}
|
|
172
|
+
aria-label="List actions"
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
const unchecked = screen.getByRole('menuitemradio', { name: 'Manual' });
|
|
176
|
+
const checked = screen.getByRole('menuitemradio', { name: 'Title' });
|
|
177
|
+
expect(unchecked.getAttribute('aria-checked')).toBe('false');
|
|
178
|
+
expect(checked.getAttribute('aria-checked')).toBe('true');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('closes and selects when a checkable item is clicked', () => {
|
|
182
|
+
const onClose = vi.fn();
|
|
183
|
+
const onSelect = vi.fn();
|
|
184
|
+
render(
|
|
185
|
+
<Menu
|
|
186
|
+
trigger={<button type="button">Open menu</button>}
|
|
187
|
+
open
|
|
188
|
+
onClose={onClose}
|
|
189
|
+
items={[{ label: 'Title', onSelect, checked: false }]}
|
|
190
|
+
aria-label="List actions"
|
|
191
|
+
/>,
|
|
192
|
+
);
|
|
193
|
+
fireEvent.click(screen.getByRole('menuitemradio', { name: 'Title' }));
|
|
194
|
+
expect(onSelect).toHaveBeenCalledOnce();
|
|
195
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* OverlayHeader — shared fixed secondary header for Dialog's mobile mode,
|
|
2
|
+
* Sheet, and Drawer. Not itself pinned via position — the consumer renders it
|
|
3
|
+
* as a flex-shrink: 0 sibling before its own scrolling content region (see
|
|
4
|
+
* the component's own doc comment). */
|
|
5
|
+
|
|
6
|
+
.header {
|
|
7
|
+
flex-shrink: 0;
|
|
8
|
+
background: var(--sv-color-surface);
|
|
9
|
+
border-bottom: 1px solid var(--sv-color-border);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.row {
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: var(--sv-space-2);
|
|
16
|
+
padding: var(--sv-space-3) var(--sv-space-4);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.backButton {
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
width: var(--sv-touch-target-min, 44px);
|
|
24
|
+
height: var(--sv-touch-target-min, 44px);
|
|
25
|
+
flex-shrink: 0;
|
|
26
|
+
/* Pull the touch-target padding back so the chevron glyph still sits close
|
|
27
|
+
to the title, matching the visual (not hit-area) spacing of .row's own
|
|
28
|
+
gap — otherwise a 44px box reads as a large empty gap before the title. */
|
|
29
|
+
margin-left: calc(-1 * (var(--sv-touch-target-min, 44px) - var(--sv-icon-size-md)) / 2);
|
|
30
|
+
margin-right: calc(
|
|
31
|
+
-1 * (var(--sv-touch-target-min, 44px) - var(--sv-icon-size-md)) / 2 + var(--sv-space-1)
|
|
32
|
+
);
|
|
33
|
+
border: none;
|
|
34
|
+
border-radius: var(--sv-radius-md);
|
|
35
|
+
background: transparent;
|
|
36
|
+
color: var(--sv-color-text-muted);
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@media (hover: hover) {
|
|
41
|
+
.backButton:hover {
|
|
42
|
+
background: var(--sv-color-surface-sunken);
|
|
43
|
+
color: var(--sv-color-text-primary);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.backButton:focus-visible {
|
|
48
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
49
|
+
outline-offset: -2px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.title {
|
|
53
|
+
flex: 1;
|
|
54
|
+
min-width: 0;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
text-overflow: ellipsis;
|
|
57
|
+
white-space: nowrap;
|
|
58
|
+
font-size: var(--sv-font-size-xl);
|
|
59
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
60
|
+
color: var(--sv-color-text-primary);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.action {
|
|
64
|
+
flex-shrink: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.closeButton {
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
width: var(--sv-touch-target-min, 44px);
|
|
72
|
+
height: var(--sv-touch-target-min, 44px);
|
|
73
|
+
flex-shrink: 0;
|
|
74
|
+
/* Pull the touch-target padding back so the × sits flush with the row's own
|
|
75
|
+
edge padding, same reasoning as .backButton above. */
|
|
76
|
+
margin-right: calc(-1 * (var(--sv-touch-target-min, 44px) - var(--sv-icon-size-sm)) / 2);
|
|
77
|
+
border: none;
|
|
78
|
+
border-radius: var(--sv-radius-md);
|
|
79
|
+
background: transparent;
|
|
80
|
+
color: var(--sv-color-text-muted);
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@media (hover: hover) {
|
|
85
|
+
.closeButton:hover {
|
|
86
|
+
background: var(--sv-color-surface-sunken);
|
|
87
|
+
color: var(--sv-color-text-primary);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.closeButton:focus-visible {
|
|
92
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
93
|
+
outline-offset: -2px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.secondRow {
|
|
97
|
+
padding: 0 var(--sv-space-2) var(--sv-space-2);
|
|
98
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Icon } from '../Icon/Icon';
|
|
3
|
+
import styles from './OverlayHeader.module.css';
|
|
4
|
+
|
|
5
|
+
export interface OverlayHeaderProps {
|
|
6
|
+
/** Overlay title, shown between the optional back button and the trailing
|
|
7
|
+
* slots. Optional — some consumers (e.g. `Dialog`'s mobile bar) need the
|
|
8
|
+
* row's close button present even when no title applies. */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** Always renders a close button; called on click. */
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
/** When provided, renders a back-chevron button before the title (for
|
|
13
|
+
* hierarchical navigation within a single overlay — going back without
|
|
14
|
+
* fully closing it). Omit for a flat overlay that only ever closes. */
|
|
15
|
+
onBack?: () => void;
|
|
16
|
+
/** Trailing slot next to the close button — e.g. a "Save" action. */
|
|
17
|
+
action?: ReactNode;
|
|
18
|
+
/** A second row below the title row — e.g. a tab strip. */
|
|
19
|
+
secondRow?: ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* OverlayHeader — the shared fixed secondary header for `Dialog`'s mobile
|
|
25
|
+
* mode, `Sheet`, and `Drawer`: title + close (+ optional back button /
|
|
26
|
+
* trailing action / tab-strip row). Not itself `position: sticky` or
|
|
27
|
+
* `position: fixed` — it stays visually pinned because the consuming overlay
|
|
28
|
+
* renders it as a non-scrolling flex sibling before its own scrollable
|
|
29
|
+
* content region, the same technique `Dialog`'s `.mobileBar` already used
|
|
30
|
+
* before this component existed to generalize it.
|
|
31
|
+
*
|
|
32
|
+
* Content that needs a fully custom header (e.g. an editable title, or
|
|
33
|
+
* controls that aren't a simple title/back/action shape) should not use this
|
|
34
|
+
* component — render its own header row instead; `Sheet`'s `title` prop is
|
|
35
|
+
* optional specifically to leave that escape hatch open.
|
|
36
|
+
*/
|
|
37
|
+
export function OverlayHeader({
|
|
38
|
+
title,
|
|
39
|
+
onClose,
|
|
40
|
+
onBack,
|
|
41
|
+
action,
|
|
42
|
+
secondRow,
|
|
43
|
+
className,
|
|
44
|
+
}: OverlayHeaderProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={[styles.header, className].filter(Boolean).join(' ')}>
|
|
47
|
+
<div className={styles.row}>
|
|
48
|
+
{onBack && (
|
|
49
|
+
<button type="button" className={styles.backButton} aria-label="Back" onClick={onBack}>
|
|
50
|
+
<Icon name="chevron-left" size="md" aria-hidden />
|
|
51
|
+
</button>
|
|
52
|
+
)}
|
|
53
|
+
<span className={styles.title}>{title}</span>
|
|
54
|
+
{action && <div className={styles.action}>{action}</div>}
|
|
55
|
+
<button type="button" className={styles.closeButton} aria-label="Close" onClick={onClose}>
|
|
56
|
+
<Icon name="x" size="sm" aria-hidden />
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
{secondRow && <div className={styles.secondRow}>{secondRow}</div>}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { OverlayHeader } from '../OverlayHeader';
|
|
5
|
+
|
|
6
|
+
describe('OverlayHeader', () => {
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
it('renders the title and calls onClose on the close button', () => {
|
|
10
|
+
const onClose = vi.fn();
|
|
11
|
+
render(<OverlayHeader title="Account" onClose={onClose} />);
|
|
12
|
+
expect(screen.getByText('Account')).toBeTruthy();
|
|
13
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
14
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('does not render a back button when onBack is omitted', () => {
|
|
18
|
+
render(<OverlayHeader title="Account" onClose={() => {}} />);
|
|
19
|
+
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders and wires a back button when onBack is provided', () => {
|
|
23
|
+
const onBack = vi.fn();
|
|
24
|
+
render(<OverlayHeader title="Account" onClose={() => {}} onBack={onBack} />);
|
|
25
|
+
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
|
26
|
+
expect(onBack).toHaveBeenCalledOnce();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders the trailing action slot', () => {
|
|
30
|
+
render(
|
|
31
|
+
<OverlayHeader
|
|
32
|
+
title="Edit list"
|
|
33
|
+
onClose={() => {}}
|
|
34
|
+
action={<button type="button">Save</button>}
|
|
35
|
+
/>,
|
|
36
|
+
);
|
|
37
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders just the close button when title is omitted', () => {
|
|
41
|
+
const onClose = vi.fn();
|
|
42
|
+
render(<OverlayHeader onClose={onClose} />);
|
|
43
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
44
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders the second row', () => {
|
|
48
|
+
render(<OverlayHeader title="Account" onClose={() => {}} secondRow={<nav>Tab strip</nav>} />);
|
|
49
|
+
expect(screen.getByText('Tab strip')).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
border: 1px solid var(--sv-color-border);
|
|
12
12
|
border-radius: var(--sv-radius-2xl);
|
|
13
13
|
box-shadow: var(--sv-shadow-popover);
|
|
14
|
-
|
|
14
|
+
/* auto, not hidden: Popover.tsx applies an inline max-height only when the
|
|
15
|
+
panel is taller than the room actually available on whichever side it
|
|
16
|
+
opens (e.g. a tall calendar on a short mobile viewport, where neither
|
|
17
|
+
above nor below has enough space for the whole thing) — auto lets that
|
|
18
|
+
capped case scroll internally instead of silently clipping content with
|
|
19
|
+
no way to reach it. When no max-height is applied (the common case, panel
|
|
20
|
+
already fits), auto behaves identically to hidden — nothing to scroll,
|
|
21
|
+
content still clips at the rounded corners exactly as before. */
|
|
22
|
+
overflow-x: hidden;
|
|
23
|
+
overflow-y: auto;
|
|
15
24
|
/* Slide in from slightly above */
|
|
16
25
|
animation: sv-popover-in 0.12s ease;
|
|
17
26
|
}
|
|
@@ -42,3 +51,24 @@
|
|
|
42
51
|
.left {
|
|
43
52
|
left: 0;
|
|
44
53
|
}
|
|
54
|
+
|
|
55
|
+
/* Collision flip: opens above the trigger instead of below, when the panel
|
|
56
|
+
wouldn't fit within the viewport underneath it (see Popover.tsx's
|
|
57
|
+
layout-effect measurement). Same slide-in treatment, mirrored to slide up
|
|
58
|
+
from slightly below instead of down from slightly above. */
|
|
59
|
+
.upward {
|
|
60
|
+
top: auto;
|
|
61
|
+
bottom: calc(100% + var(--sv-space-2));
|
|
62
|
+
animation-name: sv-popover-in-upward;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@keyframes sv-popover-in-upward {
|
|
66
|
+
from {
|
|
67
|
+
opacity: 0;
|
|
68
|
+
transform: translateY(4px);
|
|
69
|
+
}
|
|
70
|
+
to {
|
|
71
|
+
opacity: 1;
|
|
72
|
+
transform: translateY(0);
|
|
73
|
+
}
|
|
74
|
+
}
|