@sovereignfs/ui 0.21.3 → 0.33.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.
Files changed (130) hide show
  1. package/dist/Button.module.css +75 -8
  2. package/dist/Calendar.module.css +126 -0
  3. package/dist/Card.module.css +4 -2
  4. package/dist/Checkbox.module.css +109 -0
  5. package/dist/CodeTextarea.module.css +53 -0
  6. package/dist/ConfirmDialog.module.css +109 -0
  7. package/dist/DatePicker.module.css +57 -0
  8. package/dist/Dialog.module.css +73 -46
  9. package/dist/DragHandleRow.module.css +57 -0
  10. package/dist/Drawer.module.css +61 -0
  11. package/dist/FormField.module.css +5 -0
  12. package/dist/Icon.module.css +5 -0
  13. package/dist/Input.module.css +10 -0
  14. package/dist/Menu.module.css +82 -0
  15. package/dist/NavTabs.module.css +4 -2
  16. package/dist/OverlayHeader.module.css +98 -0
  17. package/dist/Popover.module.css +31 -1
  18. package/dist/SegmentedControl.module.css +4 -2
  19. package/dist/Select.module.css +9 -0
  20. package/dist/Sheet.module.css +55 -0
  21. package/dist/SplitPane.module.css +85 -0
  22. package/dist/StatusBadge.module.css +59 -0
  23. package/dist/SystemBanner.module.css +4 -2
  24. package/dist/Tabs.module.css +4 -2
  25. package/dist/TagInput.module.css +141 -0
  26. package/dist/Textarea.module.css +39 -0
  27. package/dist/Toast.module.css +7 -5
  28. package/dist/Toggle.module.css +1 -1
  29. package/dist/Tooltip.module.css +6 -1
  30. package/dist/index.d.ts +549 -20
  31. package/dist/index.js +1496 -193
  32. package/dist/tokens/primitives.css +19 -0
  33. package/dist/tokens/semantic.css +19 -0
  34. package/package.json +1 -1
  35. package/src/__tests__/motion.test.tsx +105 -0
  36. package/src/components/Button/Button.module.css +75 -8
  37. package/src/components/Button/Button.stories.tsx +6 -0
  38. package/src/components/Button/Button.tsx +17 -1
  39. package/src/components/Button/__tests__/Button.test.tsx +19 -0
  40. package/src/components/Calendar/Calendar.module.css +126 -0
  41. package/src/components/Calendar/Calendar.tsx +213 -0
  42. package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
  43. package/src/components/Calendar/dateUtils.ts +97 -0
  44. package/src/components/Card/Card.module.css +4 -2
  45. package/src/components/Checkbox/Checkbox.module.css +109 -0
  46. package/src/components/Checkbox/Checkbox.tsx +78 -0
  47. package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
  48. package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
  49. package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
  50. package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
  51. package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
  52. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
  53. package/src/components/DatePicker/DatePicker.module.css +57 -0
  54. package/src/components/DatePicker/DatePicker.tsx +103 -0
  55. package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
  56. package/src/components/Dialog/Dialog.module.css +73 -46
  57. package/src/components/Dialog/Dialog.stories.tsx +51 -1
  58. package/src/components/Dialog/Dialog.tsx +86 -22
  59. package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
  60. package/src/components/DragHandleRow/DragHandleRow.module.css +57 -0
  61. package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
  62. package/src/components/Drawer/Drawer.module.css +61 -0
  63. package/src/components/Drawer/Drawer.tsx +117 -13
  64. package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
  65. package/src/components/FormField/FormField.module.css +5 -0
  66. package/src/components/FormField/FormField.tsx +36 -11
  67. package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
  68. package/src/components/Icon/Icon.module.css +5 -0
  69. package/src/components/Icon/Icon.stories.tsx +4 -3
  70. package/src/components/Icon/Icon.tsx +1 -1
  71. package/src/components/Icon/icons/calendar.tsx +23 -0
  72. package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
  73. package/src/components/Icon/icons/index.ts +8 -4
  74. package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
  75. package/src/components/Input/Input.module.css +10 -0
  76. package/src/components/Menu/Menu.module.css +82 -0
  77. package/src/components/Menu/Menu.tsx +137 -0
  78. package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
  79. package/src/components/NavTabs/NavTabs.module.css +4 -2
  80. package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
  81. package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
  82. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
  83. package/src/components/Popover/Popover.module.css +31 -1
  84. package/src/components/Popover/Popover.stories.tsx +126 -1
  85. package/src/components/Popover/Popover.tsx +140 -7
  86. package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
  87. package/src/components/Select/Select.module.css +9 -0
  88. package/src/components/Select/Select.tsx +10 -2
  89. package/src/components/Sheet/Sheet.module.css +55 -0
  90. package/src/components/Sheet/Sheet.tsx +151 -0
  91. package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
  92. package/src/components/SplitPane/SplitPane.module.css +85 -0
  93. package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
  94. package/src/components/SplitPane/SplitPane.tsx +149 -0
  95. package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
  96. package/src/components/StatusBadge/StatusBadge.module.css +59 -0
  97. package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
  98. package/src/components/StatusBadge/StatusBadge.tsx +65 -0
  99. package/src/components/SystemBanner/SystemBanner.module.css +4 -2
  100. package/src/components/Tabs/Tabs.module.css +4 -2
  101. package/src/components/TagInput/TagInput.module.css +141 -0
  102. package/src/components/TagInput/TagInput.stories.tsx +109 -0
  103. package/src/components/TagInput/TagInput.tsx +174 -0
  104. package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
  105. package/src/components/Textarea/Textarea.module.css +39 -0
  106. package/src/components/Textarea/Textarea.tsx +15 -0
  107. package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
  108. package/src/components/Toast/Toast.module.css +7 -5
  109. package/src/components/Toggle/Toggle.module.css +1 -1
  110. package/src/components/Tooltip/Tooltip.module.css +6 -1
  111. package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
  112. package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
  113. package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
  114. package/src/hooks/index.ts +4 -0
  115. package/src/hooks/useDoubleTap.ts +91 -0
  116. package/src/hooks/useIsMobile.ts +33 -0
  117. package/src/hooks/useLongPress.ts +185 -0
  118. package/src/index.ts +37 -2
  119. package/src/motion.ts +78 -0
  120. package/src/stories/Checkbox.stories.tsx +45 -0
  121. package/src/stories/DesignSystemOverview.stories.tsx +354 -8
  122. package/src/stories/DragHandleRow.stories.tsx +41 -0
  123. package/src/stories/FormField.stories.tsx +8 -8
  124. package/src/stories/InteractionHooks.stories.tsx +388 -0
  125. package/src/stories/MobilePatterns.stories.tsx +4 -4
  126. package/src/stories/Textarea.stories.tsx +51 -0
  127. package/src/stories/TokenGallery.stories.tsx +87 -1
  128. package/src/tokens/primitives.css +19 -0
  129. package/src/tokens/semantic.css +19 -0
  130. package/src/components/Icon/icons/terminal.tsx +0 -19
@@ -0,0 +1,23 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function CalendarIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <path d="M8 2v4" />
18
+ <path d="M16 2v4" />
19
+ <rect width="18" height="18" x="3" y="4" rx="2" />
20
+ <path d="M3 10h18" />
21
+ </svg>
22
+ );
23
+ }
@@ -0,0 +1,22 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function EllipsisVerticalIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <circle cx="12" cy="12" r="1" />
18
+ <circle cx="12" cy="5" r="1" />
19
+ <circle cx="12" cy="19" r="1" />
20
+ </svg>
21
+ );
22
+ }
@@ -26,8 +26,9 @@ import { PackageIcon } from './package';
26
26
  import { Grid2x2Icon } from './grid-2x2';
27
27
  import { InfoIcon } from './info';
28
28
  import { AlertTriangleIcon } from './alert-triangle';
29
+ import { CalendarIcon } from './calendar';
29
30
  import { SlidersHorizontalIcon } from './sliders-horizontal';
30
- import { TerminalIcon } from './terminal';
31
+ import { EllipsisVerticalIcon } from './ellipsis-vertical';
31
32
 
32
33
  export type IconName =
33
34
  | 'house'
@@ -56,8 +57,9 @@ export type IconName =
56
57
  | 'grid-2x2'
57
58
  | 'info'
58
59
  | 'alert-triangle'
60
+ | 'calendar'
59
61
  | 'sliders-horizontal'
60
- | 'terminal';
62
+ | 'ellipsis-vertical';
61
63
 
62
64
  export type IconComponent = (props: SVGProps<SVGSVGElement>) => ReactElement;
63
65
 
@@ -88,8 +90,9 @@ export const ICONS: Record<IconName, IconComponent> = {
88
90
  'grid-2x2': Grid2x2Icon,
89
91
  info: InfoIcon,
90
92
  'alert-triangle': AlertTriangleIcon,
93
+ calendar: CalendarIcon,
91
94
  'sliders-horizontal': SlidersHorizontalIcon,
92
- terminal: TerminalIcon,
95
+ 'ellipsis-vertical': EllipsisVerticalIcon,
93
96
  };
94
97
 
95
98
  export { HouseIcon } from './house';
@@ -118,5 +121,6 @@ export { PackageIcon } from './package';
118
121
  export { Grid2x2Icon } from './grid-2x2';
119
122
  export { InfoIcon } from './info';
120
123
  export { AlertTriangleIcon } from './alert-triangle';
124
+ export { CalendarIcon } from './calendar';
121
125
  export { SlidersHorizontalIcon } from './sliders-horizontal';
122
- export { TerminalIcon } from './terminal';
126
+ export { EllipsisVerticalIcon } from './ellipsis-vertical';
@@ -14,15 +14,15 @@ export function SlidersHorizontalIcon(props: SVGProps<SVGSVGElement>) {
14
14
  strokeLinejoin="round"
15
15
  {...props}
16
16
  >
17
- <line x1="21" x2="14" y1="4" y2="4" />
18
- <line x1="10" x2="3" y1="4" y2="4" />
19
- <line x1="21" x2="12" y1="12" y2="12" />
20
- <line x1="8" x2="3" y1="12" y2="12" />
21
- <line x1="21" x2="16" y1="20" y2="20" />
22
- <line x1="12" x2="3" y1="20" y2="20" />
23
- <line x1="14" x2="14" y1="2" y2="6" />
24
- <line x1="8" x2="8" y1="10" y2="14" />
25
- <line x1="16" x2="16" y1="18" y2="22" />
17
+ <path d="M10 5H3" />
18
+ <path d="M12 19H3" />
19
+ <path d="M14 3v4" />
20
+ <path d="M16 17v4" />
21
+ <path d="M21 12h-9" />
22
+ <path d="M21 19h-5" />
23
+ <path d="M21 5h-7" />
24
+ <path d="M8 10v4" />
25
+ <path d="M8 12H3" />
26
26
  </svg>
27
27
  );
28
28
  }
@@ -14,6 +14,16 @@
14
14
  box-shadow 0.15s ease;
15
15
  }
16
16
 
17
+ /* iOS Safari (tab and standalone PWA) auto-zooms the page whenever a focused
18
+ input's font-size is below 16px. The viewport intentionally allows pinch-zoom
19
+ (no maximum-scale — an accessibility requirement), so the only way to stop the
20
+ focus-zoom is to render the field at >=16px on touch devices. */
21
+ @media (pointer: coarse) {
22
+ .input {
23
+ font-size: var(--sv-font-size-md);
24
+ }
25
+ }
26
+
17
27
  .input::placeholder {
18
28
  color: var(--sv-color-text-muted);
19
29
  }
@@ -0,0 +1,82 @@
1
+ /* Menu — the item list shared by both presentations (Popover panel on
2
+ * desktop, Drawer panel on mobile); the presentational chrome around it is
3
+ * entirely owned by those two components, not duplicated here. */
4
+
5
+ .list {
6
+ list-style: none;
7
+ margin: 0;
8
+ padding: var(--sv-space-1);
9
+ display: flex;
10
+ flex-direction: column;
11
+ }
12
+
13
+ .item {
14
+ display: flex;
15
+ align-items: center;
16
+ gap: var(--sv-space-2);
17
+ width: 100%;
18
+ /* Min touch target (WCAG 2.5.5 / Apple HIG) — this menu renders as a
19
+ Drawer sheet on mobile, so every item must be comfortably tappable. */
20
+ min-height: var(--sv-touch-target-min, 44px);
21
+ padding: var(--sv-space-2) var(--sv-space-3);
22
+ border: none;
23
+ border-radius: var(--sv-radius-sm);
24
+ background: transparent;
25
+ color: var(--sv-color-text-primary);
26
+ font: inherit;
27
+ font-size: var(--sv-font-size-sm);
28
+ text-align: left;
29
+ cursor: pointer;
30
+ }
31
+
32
+ @media (hover: hover) {
33
+ .item:hover:not(:disabled) {
34
+ background: var(--sv-color-surface-sunken);
35
+ }
36
+ }
37
+
38
+ .item:focus-visible {
39
+ outline: 2px solid var(--sv-color-focus-ring);
40
+ outline-offset: -2px;
41
+ }
42
+
43
+ .item:disabled {
44
+ opacity: 0.5;
45
+ cursor: not-allowed;
46
+ }
47
+
48
+ .itemDestructive {
49
+ color: var(--sv-color-error-text);
50
+ }
51
+
52
+ @media (hover: hover) {
53
+ .itemDestructive:hover:not(:disabled) {
54
+ background: var(--sv-color-error-surface);
55
+ }
56
+ }
57
+
58
+ /* Reserved checkmark gutter for a checkable item — sized so unchecked
59
+ siblings in the same group still align with the checked one's icon. */
60
+ .check {
61
+ display: inline-flex;
62
+ flex-shrink: 0;
63
+ width: var(--sv-icon-size-sm);
64
+ height: var(--sv-icon-size-sm);
65
+ align-items: center;
66
+ justify-content: center;
67
+ }
68
+
69
+ .label {
70
+ padding: var(--sv-space-2) var(--sv-space-3) var(--sv-space-1);
71
+ font-size: var(--sv-font-size-label);
72
+ font-weight: var(--sv-font-weight-semibold);
73
+ color: var(--sv-color-text-muted);
74
+ text-transform: uppercase;
75
+ letter-spacing: 0.04em;
76
+ }
77
+
78
+ .separator {
79
+ height: 1px;
80
+ margin: var(--sv-space-1) var(--sv-space-2);
81
+ background: var(--sv-color-border);
82
+ }
@@ -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
+ });
@@ -17,8 +17,10 @@
17
17
  transition: color 0.1s ease;
18
18
  }
19
19
 
20
- .link:hover {
21
- color: var(--sv-color-text-primary);
20
+ @media (hover: hover) {
21
+ .link:hover {
22
+ color: var(--sv-color-text-primary);
23
+ }
22
24
  }
23
25
 
24
26
  .link:focus-visible {
@@ -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
+ }