@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.
- 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 +109 -0
- 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 +73 -46
- package/dist/DragHandleRow.module.css +57 -0
- package/dist/Drawer.module.css +61 -0
- package/dist/FormField.module.css +5 -0
- package/dist/Icon.module.css +5 -0
- package/dist/Input.module.css +10 -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/Select.module.css +9 -0
- 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/Textarea.module.css +39 -0
- package/dist/Toast.module.css +7 -5
- package/dist/Toggle.module.css +1 -1
- package/dist/Tooltip.module.css +6 -1
- package/dist/index.d.ts +549 -20
- package/dist/index.js +1496 -193
- package/dist/tokens/primitives.css +19 -0
- package/dist/tokens/semantic.css +19 -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 +109 -0
- package/src/components/Checkbox/Checkbox.tsx +78 -0
- 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 +73 -46
- 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 +57 -0
- package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
- 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/FormField/FormField.module.css +5 -0
- package/src/components/FormField/FormField.tsx +36 -11
- package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
- 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/Input/Input.module.css +10 -0
- 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/Select/Select.module.css +9 -0
- package/src/components/Select/Select.tsx +10 -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/Textarea/Textarea.module.css +39 -0
- package/src/components/Textarea/Textarea.tsx +15 -0
- package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
- package/src/components/Toast/Toast.module.css +7 -5
- package/src/components/Toggle/Toggle.module.css +1 -1
- 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 +37 -2
- package/src/motion.ts +78 -0
- package/src/stories/Checkbox.stories.tsx +45 -0
- package/src/stories/DesignSystemOverview.stories.tsx +354 -8
- package/src/stories/DragHandleRow.stories.tsx +41 -0
- package/src/stories/FormField.stories.tsx +8 -8
- package/src/stories/InteractionHooks.stories.tsx +388 -0
- package/src/stories/MobilePatterns.stories.tsx +4 -4
- package/src/stories/Textarea.stories.tsx +51 -0
- package/src/stories/TokenGallery.stories.tsx +87 -1
- package/src/tokens/primitives.css +19 -0
- package/src/tokens/semantic.css +19 -0
- 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
|
+
}
|