@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,77 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { CodeTextarea } from './CodeTextarea';
3
+ import { FormField } from '../FormField/FormField';
4
+
5
+ const sampleMarkdown = `---
6
+ title: Editorial calendar
7
+ tags:
8
+ - planning
9
+ - launch
10
+ ---
11
+
12
+ # Launch notes
13
+
14
+ Draft the announcement and verify links before publishing.`;
15
+
16
+ const meta = {
17
+ title: 'Components/CodeTextarea',
18
+ component: CodeTextarea,
19
+ parameters: {
20
+ layout: 'padded',
21
+ docs: {
22
+ description: {
23
+ component:
24
+ 'Monospace textarea for Markdown, YAML, JSON, and other whitespace-sensitive content.',
25
+ },
26
+ },
27
+ },
28
+ args: {
29
+ defaultValue: sampleMarkdown,
30
+ 'aria-label': 'Markdown source',
31
+ },
32
+ } satisfies Meta<typeof CodeTextarea>;
33
+
34
+ export default meta;
35
+ type Story = StoryObj<typeof meta>;
36
+
37
+ export const Default: Story = {};
38
+
39
+ export const WithFormField: Story = {
40
+ render: () => (
41
+ <FormField label="Frontmatter" hint="YAML is saved exactly as typed." id="frontmatter-source">
42
+ {(field) => <CodeTextarea {...field} defaultValue={sampleMarkdown} />}
43
+ </FormField>
44
+ ),
45
+ };
46
+
47
+ export const Error: Story = {
48
+ render: () => (
49
+ <FormField label="Raw YAML" error="Line 3: expected a value." id="yaml-source">
50
+ {(field) => <CodeTextarea {...field} invalid defaultValue={'title:\ntags:\n -'} />}
51
+ </FormField>
52
+ ),
53
+ };
54
+
55
+ export const Disabled: Story = {
56
+ args: {
57
+ disabled: true,
58
+ defaultValue: sampleMarkdown,
59
+ },
60
+ };
61
+
62
+ export const LongContent: Story = {
63
+ args: {
64
+ defaultValue: Array.from({ length: 24 }, (_, index) => `- item-${index + 1}: pending`).join(
65
+ '\n',
66
+ ),
67
+ },
68
+ };
69
+
70
+ export const Mobile: Story = {
71
+ parameters: {
72
+ viewport: { defaultViewport: 'mobile1' },
73
+ },
74
+ args: {
75
+ defaultValue: sampleMarkdown,
76
+ },
77
+ };
@@ -0,0 +1,36 @@
1
+ import type { TextareaHTMLAttributes } from 'react';
2
+ import styles from './CodeTextarea.module.css';
3
+
4
+ export interface CodeTextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
5
+ invalid?: boolean;
6
+ }
7
+
8
+ /**
9
+ * CodeTextarea — monospace textarea for Markdown, YAML, JSON, and other
10
+ * whitespace-sensitive content. Pair with FormField for labels, hints, and
11
+ * validation text.
12
+ */
13
+ export function CodeTextarea({
14
+ className,
15
+ rows = 12,
16
+ spellCheck = false,
17
+ wrap = 'off',
18
+ invalid = false,
19
+ 'aria-invalid': ariaInvalid,
20
+ ...rest
21
+ }: CodeTextareaProps) {
22
+ const classes = [styles.textarea, invalid ? styles.invalid : undefined, className]
23
+ .filter(Boolean)
24
+ .join(' ');
25
+
26
+ return (
27
+ <textarea
28
+ rows={rows}
29
+ spellCheck={spellCheck}
30
+ wrap={wrap}
31
+ className={classes}
32
+ aria-invalid={ariaInvalid ?? (invalid || undefined)}
33
+ {...rest}
34
+ />
35
+ );
36
+ }
@@ -0,0 +1,109 @@
1
+ /* ConfirmDialog — built on the native <dialog> element (see the component's
2
+ * own doc comment for why: content-sized, top-layer stacking, ::backdrop for
3
+ * free). Tokens only. */
4
+
5
+ .dialog {
6
+ border: none;
7
+ border-radius: var(--sv-radius-lg);
8
+ background: var(--sv-color-surface);
9
+ box-shadow: var(--sv-shadow-overlay);
10
+ padding: 0;
11
+ width: 22rem;
12
+ max-width: calc(100vw - var(--sv-space-8));
13
+ }
14
+
15
+ .dialog::backdrop {
16
+ background: var(--sv-color-scrim);
17
+ }
18
+
19
+ .body {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: var(--sv-space-4);
23
+ padding: var(--sv-space-6);
24
+ }
25
+
26
+ .title {
27
+ margin: 0;
28
+ font-size: var(--sv-font-size-lg);
29
+ font-weight: var(--sv-font-weight-semibold);
30
+ color: var(--sv-color-text-primary);
31
+ }
32
+
33
+ .message {
34
+ margin: 0;
35
+ font-size: var(--sv-font-size-sm);
36
+ color: var(--sv-color-text-muted);
37
+ }
38
+
39
+ .error {
40
+ margin: 0;
41
+ font-size: var(--sv-font-size-xs);
42
+ color: var(--sv-color-error-text);
43
+ padding: var(--sv-space-2) var(--sv-space-3);
44
+ border: 1px solid var(--sv-color-error-border);
45
+ border-radius: var(--sv-radius-sm);
46
+ background-color: var(--sv-color-error-surface);
47
+ }
48
+
49
+ .actions {
50
+ display: flex;
51
+ gap: var(--sv-space-3);
52
+ justify-content: flex-end;
53
+ }
54
+
55
+ /* Matches Button's own .button + .md structural rules exactly (padding,
56
+ * radius, font weight, focus-visible ring, disabled state, touch target,
57
+ * hover guard, active/pressed state) so it reads as the same control family
58
+ * — only the color scheme differs. Not built as an override of Button's own
59
+ * CSS module: cross-module class-order in the cascade isn't guaranteed, and
60
+ * an inline-style override would defeat Button's own :hover rule (inline
61
+ * styles always win over an external stylesheet, hover or not). A plain
62
+ * button with its own tokens sidesteps both problems. */
63
+ .destructiveConfirm {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ gap: var(--sv-space-2);
68
+ padding: 13px var(--sv-space-4);
69
+ border: 1px solid transparent;
70
+ border-radius: var(--sv-radius-md);
71
+ font-family: var(--sv-font-family);
72
+ font-size: var(--sv-font-size-sm);
73
+ font-weight: var(--sv-font-weight-semibold);
74
+ line-height: 1;
75
+ cursor: pointer;
76
+ touch-action: manipulation;
77
+ background-color: var(--sv-color-error-solid);
78
+ color: var(--sv-color-text-on-error);
79
+ transition: background-color 0.15s ease;
80
+ }
81
+
82
+ @media (pointer: coarse) {
83
+ .destructiveConfirm {
84
+ min-height: var(--sv-touch-target-min, 44px);
85
+ }
86
+ }
87
+
88
+ /* Derived via color-mix from the semantic token, not a raw --sv-red-800
89
+ primitive (the original .dangerButton pattern this replaces did reference
90
+ the primitive directly — fixed here, not carried forward). */
91
+ @media (hover: hover) {
92
+ .destructiveConfirm:hover:not(:disabled) {
93
+ background-color: color-mix(in srgb, var(--sv-color-error-solid) 85%, black);
94
+ }
95
+ }
96
+
97
+ .destructiveConfirm:active:not(:disabled) {
98
+ background-color: color-mix(in srgb, var(--sv-color-error-solid) 80%, black);
99
+ }
100
+
101
+ .destructiveConfirm:focus-visible {
102
+ outline: 2px solid var(--sv-color-focus-ring);
103
+ outline-offset: 2px;
104
+ }
105
+
106
+ .destructiveConfirm:disabled {
107
+ opacity: 0.5;
108
+ cursor: not-allowed;
109
+ }
@@ -0,0 +1,124 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useEffect, useRef } from 'react';
4
+ import { Button } from '../Button/Button';
5
+ import styles from './ConfirmDialog.module.css';
6
+
7
+ export interface ConfirmDialogProps {
8
+ /** Whether the dialog is shown. */
9
+ open: boolean;
10
+ /** Called on Esc, backdrop click, or the Cancel button. */
11
+ onClose: () => void;
12
+ title: string;
13
+ /** Accepts rich content (e.g. `Remove <strong>{name}</strong>?`), not just
14
+ * plain text. */
15
+ message: ReactNode;
16
+ /** Called when the confirm action is clicked. Does not close the dialog
17
+ * itself — pass `pending`/close it via `onClose` from the caller once the
18
+ * action settles, so an async action that fails can keep the dialog open
19
+ * to show `error`. */
20
+ onConfirm: () => void;
21
+ confirmLabel?: string;
22
+ cancelLabel?: string;
23
+ /** Styles the confirm action as a destructive (red) action. */
24
+ destructive?: boolean;
25
+ /** Disables both actions — pair with a `confirmLabel` reflecting the
26
+ * in-flight state (e.g. "Removing…") for an `onConfirm` that triggers an
27
+ * async action. */
28
+ pending?: boolean;
29
+ /** Optional inline error, shown between the message and the actions — e.g.
30
+ * a failed async `onConfirm` that keeps the dialog open to report it. */
31
+ error?: ReactNode;
32
+ }
33
+
34
+ /**
35
+ * ConfirmDialog — a small, content-sized confirm/cancel prompt. Deliberately
36
+ * NOT built on `Dialog`, which is a fixed-size box by design (so switching
37
+ * views inside it never resizes the panel) — a confirm prompt is the opposite
38
+ * case, sized to its message. Built on the native `<dialog>` element instead,
39
+ * same as the pattern this component replaces (duplicated across the account
40
+ * and console plugins): top-layer rendering and `::backdrop` come from the
41
+ * browser for free, which is what makes a confirm opened *from inside*
42
+ * another overlay (Dialog, Sheet) reliably stack above it without a manual
43
+ * z-index scheme.
44
+ *
45
+ * Same presentation on desktop and mobile — a small centered card, not a
46
+ * full-screen sheet (unlike Dialog/Sheet, which do fork by viewport).
47
+ */
48
+ export function ConfirmDialog({
49
+ open,
50
+ onClose,
51
+ title,
52
+ message,
53
+ onConfirm,
54
+ confirmLabel = 'Confirm',
55
+ cancelLabel = 'Cancel',
56
+ destructive = false,
57
+ pending = false,
58
+ error,
59
+ }: ConfirmDialogProps) {
60
+ const dialogRef = useRef<HTMLDialogElement>(null);
61
+
62
+ useEffect(() => {
63
+ const el = dialogRef.current;
64
+ if (!el) return;
65
+ if (open) el.showModal();
66
+ else el.close();
67
+ }, [open]);
68
+
69
+ // The native <dialog> fires its own 'close' event on Esc (built-in, no
70
+ // listener needed for that) and whenever .close() runs — including the
71
+ // effect above reacting to `open` going false. Listening for it here is
72
+ // what makes Esc call the caller's onClose without a manual keydown handler.
73
+ useEffect(() => {
74
+ const el = dialogRef.current;
75
+ if (!el) return;
76
+ const handleClose = () => onClose();
77
+ el.addEventListener('close', handleClose);
78
+ return () => el.removeEventListener('close', handleClose);
79
+ }, [onClose]);
80
+
81
+ return (
82
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
83
+ <dialog
84
+ ref={dialogRef}
85
+ className={styles.dialog}
86
+ aria-label={title}
87
+ onClick={(e) => {
88
+ if (e.target === e.currentTarget) onClose();
89
+ }}
90
+ >
91
+ <div className={styles.body}>
92
+ <h2 className={styles.title}>{title}</h2>
93
+ <div className={styles.message}>{message}</div>
94
+ {error && <p className={styles.error}>{error}</p>}
95
+ <div className={styles.actions}>
96
+ <Button type="button" variant="secondary" onClick={onClose} disabled={pending}>
97
+ {cancelLabel}
98
+ </Button>
99
+ {destructive ? (
100
+ // A solid red fill, not Button's own `destructive` variant (an
101
+ // outlined/ghost style meant for lower-emphasis placements) — a
102
+ // confirm action should read as strongly as `Button`'s primary
103
+ // variant does. Uses the same --sv-color-error-solid /
104
+ // -text-on-error tokens the codebase already introduced for this
105
+ // exact "reads as a filled action button, not a tinted status
106
+ // surface" case (see semantic.css).
107
+ <button
108
+ type="button"
109
+ className={styles.destructiveConfirm}
110
+ onClick={onConfirm}
111
+ disabled={pending}
112
+ >
113
+ {confirmLabel}
114
+ </button>
115
+ ) : (
116
+ <Button type="button" variant="primary" onClick={onConfirm} disabled={pending}>
117
+ {confirmLabel}
118
+ </Button>
119
+ )}
120
+ </div>
121
+ </div>
122
+ </dialog>
123
+ );
124
+ }
@@ -0,0 +1,146 @@
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 { ConfirmDialog } from '../ConfirmDialog';
5
+
6
+ // jsdom does not implement HTMLDialogElement.showModal()/close() (a
7
+ // long-standing, documented gap) — polyfill the minimum behavior the
8
+ // component depends on: the `open` attribute reflecting visibility, and
9
+ // close() dispatching the native 'close' event ConfirmDialog listens for.
10
+ beforeEach(() => {
11
+ HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) {
12
+ this.setAttribute('open', '');
13
+ };
14
+ HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) {
15
+ this.removeAttribute('open');
16
+ this.dispatchEvent(new Event('close'));
17
+ };
18
+ });
19
+
20
+ describe('ConfirmDialog', () => {
21
+ afterEach(cleanup);
22
+
23
+ it('renders the title and message when open', () => {
24
+ render(
25
+ <ConfirmDialog
26
+ open
27
+ onClose={() => {}}
28
+ onConfirm={() => {}}
29
+ title="Remove passkey"
30
+ message="This cannot be undone."
31
+ />,
32
+ );
33
+ expect(screen.getByText('Remove passkey')).toBeTruthy();
34
+ expect(screen.getByText('This cannot be undone.')).toBeTruthy();
35
+ });
36
+
37
+ it('renders rich message content', () => {
38
+ render(
39
+ <ConfirmDialog
40
+ open
41
+ onClose={() => {}}
42
+ onConfirm={() => {}}
43
+ title="Remove plugin"
44
+ message={
45
+ <>
46
+ Remove <strong>Example</strong>?
47
+ </>
48
+ }
49
+ />,
50
+ );
51
+ expect(screen.getByText('Example').tagName).toBe('STRONG');
52
+ });
53
+
54
+ it('calls onClose on Cancel', () => {
55
+ const onClose = vi.fn();
56
+ render(
57
+ <ConfirmDialog open onClose={onClose} onConfirm={() => {}} title="Remove" message="Sure?" />,
58
+ );
59
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
60
+ expect(onClose).toHaveBeenCalledOnce();
61
+ });
62
+
63
+ it('calls onConfirm on the confirm action, without itself closing', () => {
64
+ const onConfirm = vi.fn();
65
+ const onClose = vi.fn();
66
+ render(
67
+ <ConfirmDialog
68
+ open
69
+ onClose={onClose}
70
+ onConfirm={onConfirm}
71
+ title="Remove"
72
+ message="Sure?"
73
+ confirmLabel="Remove"
74
+ />,
75
+ );
76
+ fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
77
+ expect(onConfirm).toHaveBeenCalledOnce();
78
+ expect(onClose).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('uses custom confirm/cancel labels', () => {
82
+ render(
83
+ <ConfirmDialog
84
+ open
85
+ onClose={() => {}}
86
+ onConfirm={() => {}}
87
+ title="Sign out"
88
+ message="Sign out of this device?"
89
+ confirmLabel="Sign out"
90
+ cancelLabel="Stay signed in"
91
+ />,
92
+ );
93
+ expect(screen.getByRole('button', { name: 'Sign out' })).toBeTruthy();
94
+ expect(screen.getByRole('button', { name: 'Stay signed in' })).toBeTruthy();
95
+ });
96
+
97
+ it('disables both actions and shows the error slot when pending', () => {
98
+ render(
99
+ <ConfirmDialog
100
+ open
101
+ onClose={() => {}}
102
+ onConfirm={() => {}}
103
+ title="Remove plugin"
104
+ message="Sure?"
105
+ confirmLabel="Removing…"
106
+ pending
107
+ error="Server error — try again."
108
+ />,
109
+ );
110
+ expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(
111
+ true,
112
+ );
113
+ expect((screen.getByRole('button', { name: 'Removing…' }) as HTMLButtonElement).disabled).toBe(
114
+ true,
115
+ );
116
+ expect(screen.getByText('Server error — try again.')).toBeTruthy();
117
+ });
118
+
119
+ it('renders a destructive confirm action distinctly from the default variant', () => {
120
+ const { rerender } = render(
121
+ <ConfirmDialog
122
+ open
123
+ onClose={() => {}}
124
+ onConfirm={() => {}}
125
+ title="Remove"
126
+ message="Sure?"
127
+ confirmLabel="Remove"
128
+ />,
129
+ );
130
+ const defaultClass = screen.getByRole('button', { name: 'Remove' }).className;
131
+
132
+ rerender(
133
+ <ConfirmDialog
134
+ open
135
+ onClose={() => {}}
136
+ onConfirm={() => {}}
137
+ title="Remove"
138
+ message="Sure?"
139
+ confirmLabel="Remove"
140
+ destructive
141
+ />,
142
+ );
143
+ const destructiveClass = screen.getByRole('button', { name: 'Remove' }).className;
144
+ expect(destructiveClass).not.toBe(defaultClass);
145
+ });
146
+ });
@@ -0,0 +1,57 @@
1
+ /* DatePicker — trigger styled to match Input's height/border/radius/focus
2
+ * ring for visual consistency as a form field. */
3
+
4
+ .trigger {
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: space-between;
8
+ gap: var(--sv-space-2);
9
+ width: 100%;
10
+ padding: var(--sv-space-2) var(--sv-space-3);
11
+ font-family: var(--sv-font-family);
12
+ font-size: var(--sv-font-size-sm);
13
+ line-height: 1.5;
14
+ color: var(--sv-color-text-primary);
15
+ background-color: var(--sv-color-surface);
16
+ border: 1px solid var(--sv-color-border-strong);
17
+ border-radius: var(--sv-radius-md);
18
+ cursor: pointer;
19
+ transition:
20
+ border-color 0.15s ease,
21
+ box-shadow 0.15s ease;
22
+ }
23
+
24
+ /* Same iOS auto-zoom fix as Input — see its own module.css for why. */
25
+ @media (pointer: coarse) {
26
+ .trigger {
27
+ font-size: var(--sv-font-size-md);
28
+ }
29
+ }
30
+
31
+ .trigger:focus-visible {
32
+ outline: none;
33
+ border-color: var(--sv-color-accent);
34
+ box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
35
+ }
36
+
37
+ .trigger:disabled {
38
+ opacity: 0.5;
39
+ cursor: not-allowed;
40
+ }
41
+
42
+ .triggerValue {
43
+ color: var(--sv-color-text-primary);
44
+ }
45
+
46
+ .triggerPlaceholder {
47
+ color: var(--sv-color-text-muted);
48
+ }
49
+
50
+ .popoverBody {
51
+ padding: var(--sv-space-3);
52
+ }
53
+
54
+ .drawerBody {
55
+ padding: var(--sv-space-4);
56
+ padding-bottom: max(var(--sv-space-4), env(safe-area-inset-bottom));
57
+ }
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useIsMobile } from '../../hooks';
5
+ import { Calendar } from '../Calendar/Calendar';
6
+ import { formatDateShort } from '../Calendar/dateUtils';
7
+ import { Drawer } from '../Drawer/Drawer';
8
+ import { Icon } from '../Icon/Icon';
9
+ import { Popover } from '../Popover/Popover';
10
+ import styles from './DatePicker.module.css';
11
+
12
+ export interface DatePickerProps {
13
+ value: Date | null;
14
+ onChange: (date: Date) => void;
15
+ minDate?: Date;
16
+ maxDate?: Date;
17
+ placeholder?: string;
18
+ 'aria-label': string;
19
+ disabled?: boolean;
20
+ }
21
+
22
+ // Wide enough for the 7-column Calendar grid (44px touch-target cells) plus
23
+ // its own padding, without being so wide it looks odd as a Popover panel.
24
+ const POPOVER_WIDTH = 320;
25
+
26
+ /**
27
+ * DatePicker — a form field pairing a compact trigger with `Calendar`:
28
+ * `Popover` on desktop, a bottom-sheet `Drawer` on mobile (the platform's
29
+ * standard adaptive-surface pattern, matching `Menu`). Date-only (decision
30
+ * D6 in the mobile design-system plan) — time and range selection are future
31
+ * scope; recurrence UI stays plugin-side.
32
+ *
33
+ * Unlike `Menu`, the trigger is built in rather than supplied by the caller
34
+ * — a date picker is a form field (consistent with `Input`/`Select`), not an
35
+ * arbitrary action-menu trigger.
36
+ */
37
+ export function DatePicker({
38
+ value,
39
+ onChange,
40
+ minDate,
41
+ maxDate,
42
+ placeholder = 'Select date',
43
+ 'aria-label': ariaLabel,
44
+ disabled = false,
45
+ }: DatePickerProps) {
46
+ const [open, setOpen] = useState(false);
47
+ const isMobile = useIsMobile();
48
+
49
+ function handleSelect(date: Date) {
50
+ onChange(date);
51
+ setOpen(false);
52
+ }
53
+
54
+ const trigger = (
55
+ <button
56
+ type="button"
57
+ className={styles.trigger}
58
+ disabled={disabled}
59
+ aria-label={ariaLabel}
60
+ aria-haspopup="dialog"
61
+ aria-expanded={open}
62
+ onClick={() => setOpen((o) => !o)}
63
+ >
64
+ <span className={value ? styles.triggerValue : styles.triggerPlaceholder}>
65
+ {value ? formatDateShort(value) : placeholder}
66
+ </span>
67
+ <Icon name="calendar" size="sm" aria-hidden />
68
+ </button>
69
+ );
70
+
71
+ const calendar = (
72
+ <Calendar
73
+ value={value}
74
+ onChange={handleSelect}
75
+ minDate={minDate}
76
+ maxDate={maxDate}
77
+ aria-label={ariaLabel}
78
+ />
79
+ );
80
+
81
+ if (isMobile) {
82
+ return (
83
+ <>
84
+ {trigger}
85
+ <Drawer open={open} onClose={() => setOpen(false)} aria-label={ariaLabel}>
86
+ <div className={styles.drawerBody}>{calendar}</div>
87
+ </Drawer>
88
+ </>
89
+ );
90
+ }
91
+
92
+ return (
93
+ <Popover
94
+ trigger={trigger}
95
+ open={open}
96
+ onClose={() => setOpen(false)}
97
+ aria-label={ariaLabel}
98
+ width={POPOVER_WIDTH}
99
+ >
100
+ <div className={styles.popoverBody}>{calendar}</div>
101
+ </Popover>
102
+ );
103
+ }