@sovereignfs/ui 0.41.0 → 0.54.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/Accordion.module.css +3 -0
- package/dist/Alert.module.css +62 -0
- package/dist/AspectRatio.module.css +15 -0
- package/dist/Badge.module.css +75 -9
- package/dist/Breadcrumb.module.css +49 -0
- package/dist/Button.module.css +12 -3
- package/dist/ButtonGroup.module.css +29 -0
- package/dist/Card.module.css +6 -0
- package/dist/Checkbox.module.css +3 -1
- package/dist/Collapsible.module.css +70 -0
- package/dist/Combobox.module.css +122 -0
- package/dist/Command.module.css +80 -0
- package/dist/ConfirmDialog.module.css +5 -1
- package/dist/DataTable.module.css +28 -0
- package/dist/DatePicker.module.css +6 -2
- package/dist/Dialog.module.css +3 -0
- package/dist/HoverCard.module.css +3 -0
- package/dist/Input.module.css +6 -1
- package/dist/Item.module.css +70 -0
- package/dist/Kbd.module.css +14 -0
- package/dist/Label.module.css +11 -0
- package/dist/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +49 -0
- package/dist/Marker.module.css +33 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/MobileAppsDrawer.module.css +81 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +63 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/PageContainer.module.css +20 -0
- package/dist/Pagination.module.css +98 -0
- package/dist/Progress.module.css +20 -0
- package/dist/RadioGroup.module.css +88 -0
- package/dist/Resizable.module.css +70 -0
- package/dist/ScrollArea.module.css +20 -0
- package/dist/Select.module.css +7 -3
- package/dist/Slider.module.css +95 -0
- package/dist/SwipableMobileCarousel.module.css +55 -0
- package/dist/SwipableMobileCarouselDots.module.css +49 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/Table.module.css +45 -0
- package/dist/Tabs.module.css +6 -1
- package/dist/Toggle.module.css +6 -1
- package/dist/Typography.module.css +47 -0
- package/dist/index.d.ts +1268 -8
- package/dist/index.js +3046 -975
- package/dist/tokens/primitives.css +47 -8
- package/package.json +13 -9
- package/src/components/Accordion/Accordion.module.css +3 -0
- package/src/components/Accordion/Accordion.stories.tsx +110 -0
- package/src/components/Accordion/Accordion.tsx +53 -0
- package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
- package/src/components/Alert/Alert.module.css +62 -0
- package/src/components/Alert/Alert.stories.tsx +96 -0
- package/src/components/Alert/Alert.tsx +58 -0
- package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
- package/src/components/AspectRatio/AspectRatio.module.css +15 -0
- package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
- package/src/components/AspectRatio/AspectRatio.tsx +21 -0
- package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
- package/src/components/Badge/Badge.module.css +75 -9
- package/src/components/Badge/Badge.stories.tsx +109 -1
- package/src/components/Badge/Badge.tsx +31 -9
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
- package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
- package/src/components/Button/Button.module.css +12 -3
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Button/__tests__/Button.test.tsx +10 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
- package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
- package/src/components/Card/Card.module.css +6 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -1
- package/src/components/Collapsible/Collapsible.module.css +70 -0
- package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
- package/src/components/Collapsible/Collapsible.tsx +66 -0
- package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
- package/src/components/Combobox/Combobox.module.css +122 -0
- package/src/components/Combobox/Combobox.stories.tsx +79 -0
- package/src/components/Combobox/Combobox.tsx +186 -0
- package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
- package/src/components/Command/Command.module.css +80 -0
- package/src/components/Command/Command.stories.tsx +104 -0
- package/src/components/Command/Command.tsx +168 -0
- package/src/components/Command/__tests__/Command.test.tsx +162 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
- package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
- package/src/components/ContextMenu/ContextMenu.tsx +100 -0
- package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
- package/src/components/DataTable/DataTable.module.css +28 -0
- package/src/components/DataTable/DataTable.stories.tsx +74 -0
- package/src/components/DataTable/DataTable.tsx +154 -0
- package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
- package/src/components/DatePicker/DatePicker.module.css +6 -2
- package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
- package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
- package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
- package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/Dialog/Dialog.tsx +8 -59
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +18 -56
- package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
- package/src/components/HoverCard/HoverCard.module.css +3 -0
- package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
- package/src/components/HoverCard/HoverCard.tsx +111 -0
- package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
- package/src/components/Icon/icons/circle-check.tsx +21 -0
- package/src/components/Icon/icons/circle-x.tsx +22 -0
- package/src/components/Icon/icons/index.ts +16 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -0
- package/src/components/Input/Input.module.css +6 -1
- package/src/components/Item/Item.module.css +70 -0
- package/src/components/Item/Item.stories.tsx +62 -0
- package/src/components/Item/Item.tsx +54 -0
- package/src/components/Item/__tests__/Item.test.tsx +39 -0
- package/src/components/Kbd/Kbd.module.css +14 -0
- package/src/components/Kbd/Kbd.stories.tsx +32 -0
- package/src/components/Kbd/Kbd.tsx +12 -0
- package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
- package/src/components/Label/Label.module.css +11 -0
- package/src/components/Label/Label.stories.tsx +40 -0
- package/src/components/Label/Label.tsx +24 -0
- package/src/components/Label/__tests__/Label.test.tsx +32 -0
- package/src/components/LegalLinks/LegalLinks.module.css +24 -0
- package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
- package/src/components/LegalLinks/LegalLinks.tsx +40 -0
- package/src/components/Markdown/Markdown.module.css +49 -0
- package/src/components/Markdown/Markdown.stories.tsx +30 -0
- package/src/components/Markdown/Markdown.tsx +168 -0
- package/src/components/Marker/Marker.module.css +33 -0
- package/src/components/Marker/Marker.stories.tsx +39 -0
- package/src/components/Marker/Marker.tsx +34 -0
- package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
- package/src/components/Menu/Menu.module.css +8 -1
- package/src/components/Menu/Menu.tsx +30 -8
- package/src/components/Menubar/Menubar.stories.tsx +52 -0
- package/src/components/Menubar/Menubar.tsx +42 -0
- package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
- package/src/components/Message/Message.module.css +99 -0
- package/src/components/Message/Message.stories.tsx +79 -0
- package/src/components/Message/Message.tsx +41 -0
- package/src/components/Message/__tests__/Message.test.tsx +46 -0
- package/src/components/MessageScroller/MessageScroller.module.css +54 -0
- package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
- package/src/components/MessageScroller/MessageScroller.tsx +72 -0
- package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
- package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
- package/src/components/MobileFooter/MobileFooter.module.css +73 -0
- package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
- package/src/components/MobileFooter/MobileFooter.tsx +109 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
- package/src/components/MobileHeader/MobileHeader.module.css +63 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +77 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
- package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
- package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
- package/src/components/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/PageContainer/PageContainer.module.css +20 -0
- package/src/components/PageContainer/PageContainer.tsx +31 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
- package/src/components/PageHeader/PageHeader.tsx +16 -2
- package/src/components/Pagination/Pagination.module.css +98 -0
- package/src/components/Pagination/Pagination.stories.tsx +49 -0
- package/src/components/Pagination/Pagination.tsx +87 -0
- package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
- package/src/components/Progress/Progress.module.css +20 -0
- package/src/components/Progress/Progress.stories.tsx +40 -0
- package/src/components/Progress/Progress.tsx +35 -0
- package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
- package/src/components/RadioGroup/RadioGroup.module.css +88 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
- package/src/components/RadioGroup/RadioGroup.tsx +99 -0
- package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
- package/src/components/Resizable/Resizable.module.css +70 -0
- package/src/components/Resizable/Resizable.stories.tsx +82 -0
- package/src/components/Resizable/Resizable.tsx +313 -0
- package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
- package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/ScrollArea/ScrollArea.module.css +20 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
- package/src/components/ScrollArea/ScrollArea.tsx +27 -0
- package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
- package/src/components/Select/Select.module.css +7 -3
- package/src/components/Sheet/Sheet.tsx +9 -51
- package/src/components/Slider/Slider.module.css +95 -0
- package/src/components/Slider/Slider.stories.tsx +64 -0
- package/src/components/Slider/Slider.tsx +77 -0
- package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
- package/src/components/SwipableMobileCarousel/context.ts +13 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
- package/src/components/Table/Table.module.css +45 -0
- package/src/components/Table/Table.stories.tsx +125 -0
- package/src/components/Table/Table.tsx +51 -0
- package/src/components/Table/__tests__/Table.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +6 -1
- package/src/components/Toggle/Toggle.module.css +6 -1
- package/src/components/Typography/Typography.module.css +47 -0
- package/src/components/Typography/Typography.stories.tsx +62 -0
- package/src/components/Typography/Typography.tsx +30 -0
- package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
- package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
- package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
- package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
- package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/index.ts +18 -0
- package/src/hooks/useCarouselRouteSync.ts +92 -0
- package/src/hooks/useIsOffline.ts +41 -0
- package/src/hooks/useOfflineTileState.ts +45 -0
- package/src/hooks/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +79 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/index.ts +141 -3
- package/src/overlay-shell.ts +94 -0
- package/src/stories/DesignSystemOverview.stories.tsx +936 -3
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/MobilePatterns.stories.tsx +255 -4
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +37 -0
- package/src/stories/PageHeader.stories.tsx +11 -0
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
- package/src/stories/TokenGallery.stories.tsx +177 -16
- package/src/tokens/primitives.css +47 -8
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cloneElement, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
5
|
+
import { Popover } from '../Popover/Popover';
|
|
6
|
+
import styles from './HoverCard.module.css';
|
|
7
|
+
|
|
8
|
+
export interface HoverCardProps {
|
|
9
|
+
/** The element that opens the card — cloned with hover/focus handlers
|
|
10
|
+
* (desktop) or a tap handler (touch). */
|
|
11
|
+
trigger: ReactElement;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
align?: 'left' | 'right';
|
|
14
|
+
width?: number | 'trigger';
|
|
15
|
+
'aria-label': string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const OPEN_DELAY_MS = 400;
|
|
19
|
+
const CLOSE_DELAY_MS = 150;
|
|
20
|
+
|
|
21
|
+
/** Whether the primary pointer can hover. Distinct from viewport-width-based
|
|
22
|
+
* mobile detection: a touchscreen laptop with a mouse as its primary
|
|
23
|
+
* pointer should still get real hover behavior, and a large-viewport tablet
|
|
24
|
+
* with only touch should not — viewport width doesn't capture that, `(hover:
|
|
25
|
+
* hover)` does, matching the pointer-based gating this design system already
|
|
26
|
+
* uses in CSS (e.g. Button/Checkbox's touch-target expansion). Defaults to
|
|
27
|
+
* `true`: this only changes which event handlers get attached, not the
|
|
28
|
+
* initial DOM shape, so there's no hydration-mismatch risk to guard against
|
|
29
|
+
* the way `useIsMobile` does. */
|
|
30
|
+
function useHoverCapable(): boolean {
|
|
31
|
+
const [hoverCapable, setHoverCapable] = useState(true);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const mql = window.matchMedia('(hover: hover)');
|
|
35
|
+
setHoverCapable(mql.matches);
|
|
36
|
+
function handleChange(e: MediaQueryListEvent) {
|
|
37
|
+
setHoverCapable(e.matches);
|
|
38
|
+
}
|
|
39
|
+
mql.addEventListener('change', handleChange);
|
|
40
|
+
return () => mql.removeEventListener('change', handleChange);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
return hoverCapable;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* HoverCard — hover-triggered popover on desktop, tap-to-toggle on touch.
|
|
48
|
+
*
|
|
49
|
+
* Built on `Popover` rather than reimplementing positioning/collision
|
|
50
|
+
* detection: this only adds the hover-intent open/close timing (avoids
|
|
51
|
+
* flicker on an accidental mouse pass-over, and gives the pointer time to
|
|
52
|
+
* travel from the trigger to the card itself across the gap between them)
|
|
53
|
+
* and the touch fallback. Also opens on keyboard focus and closes on blur —
|
|
54
|
+
* a hover-only trigger is a WCAG failure, since keyboard users can't hover.
|
|
55
|
+
*/
|
|
56
|
+
export function HoverCard({
|
|
57
|
+
trigger,
|
|
58
|
+
children,
|
|
59
|
+
align,
|
|
60
|
+
width,
|
|
61
|
+
'aria-label': ariaLabel,
|
|
62
|
+
}: HoverCardProps) {
|
|
63
|
+
const [open, setOpen] = useState(false);
|
|
64
|
+
const hoverCapable = useHoverCapable();
|
|
65
|
+
const openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
66
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
67
|
+
|
|
68
|
+
function scheduleOpen() {
|
|
69
|
+
clearTimeout(closeTimer.current);
|
|
70
|
+
openTimer.current = setTimeout(() => setOpen(true), OPEN_DELAY_MS);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scheduleClose() {
|
|
74
|
+
clearTimeout(openTimer.current);
|
|
75
|
+
closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cancelScheduledClose() {
|
|
79
|
+
clearTimeout(closeTimer.current);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const triggerHandlers = hoverCapable
|
|
83
|
+
? {
|
|
84
|
+
onMouseEnter: scheduleOpen,
|
|
85
|
+
onMouseLeave: scheduleClose,
|
|
86
|
+
onFocus: () => setOpen(true),
|
|
87
|
+
onBlur: scheduleClose,
|
|
88
|
+
}
|
|
89
|
+
: { onClick: () => setOpen((o) => !o) };
|
|
90
|
+
|
|
91
|
+
const wrappedTrigger = cloneElement(trigger, triggerHandlers);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
className={styles.root}
|
|
96
|
+
onMouseEnter={hoverCapable ? cancelScheduledClose : undefined}
|
|
97
|
+
onMouseLeave={hoverCapable ? scheduleClose : undefined}
|
|
98
|
+
>
|
|
99
|
+
<Popover
|
|
100
|
+
trigger={wrappedTrigger}
|
|
101
|
+
open={open}
|
|
102
|
+
onClose={() => setOpen(false)}
|
|
103
|
+
align={align}
|
|
104
|
+
width={width}
|
|
105
|
+
aria-label={ariaLabel}
|
|
106
|
+
>
|
|
107
|
+
{children}
|
|
108
|
+
</Popover>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { HoverCard } from '../HoverCard';
|
|
5
|
+
|
|
6
|
+
let matches = true;
|
|
7
|
+
|
|
8
|
+
function installMatchMedia() {
|
|
9
|
+
vi.stubGlobal(
|
|
10
|
+
'matchMedia',
|
|
11
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
12
|
+
matches,
|
|
13
|
+
media: query,
|
|
14
|
+
addEventListener: vi.fn(),
|
|
15
|
+
removeEventListener: vi.fn(),
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
dispatchEvent: vi.fn(),
|
|
19
|
+
})),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('HoverCard', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
matches = true; // hover-capable by default
|
|
26
|
+
installMatchMedia();
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.runOnlyPendingTimers();
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
cleanup();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not open immediately on mouse enter (hover-intent delay)', () => {
|
|
38
|
+
render(
|
|
39
|
+
<HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
|
|
40
|
+
Card content
|
|
41
|
+
</HoverCard>,
|
|
42
|
+
);
|
|
43
|
+
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Profile' }));
|
|
44
|
+
expect(screen.queryByText('Card content')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('opens after the hover-intent delay elapses', () => {
|
|
48
|
+
render(
|
|
49
|
+
<HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
|
|
50
|
+
Card content
|
|
51
|
+
</HoverCard>,
|
|
52
|
+
);
|
|
53
|
+
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Profile' }));
|
|
54
|
+
act(() => {
|
|
55
|
+
vi.advanceTimersByTime(500);
|
|
56
|
+
});
|
|
57
|
+
expect(screen.getByText('Card content')).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('cancels the open timer if the pointer leaves before the delay elapses', () => {
|
|
61
|
+
render(
|
|
62
|
+
<HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
|
|
63
|
+
Card content
|
|
64
|
+
</HoverCard>,
|
|
65
|
+
);
|
|
66
|
+
const trigger = screen.getByRole('button', { name: 'Profile' });
|
|
67
|
+
fireEvent.mouseEnter(trigger);
|
|
68
|
+
fireEvent.mouseLeave(trigger);
|
|
69
|
+
act(() => {
|
|
70
|
+
vi.advanceTimersByTime(1000);
|
|
71
|
+
});
|
|
72
|
+
expect(screen.queryByText('Card content')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('opens immediately on keyboard focus, for keyboard accessibility', () => {
|
|
76
|
+
render(
|
|
77
|
+
<HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
|
|
78
|
+
Card content
|
|
79
|
+
</HoverCard>,
|
|
80
|
+
);
|
|
81
|
+
fireEvent.focus(screen.getByRole('button', { name: 'Profile' }));
|
|
82
|
+
expect(screen.getByText('Card content')).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('falls back to tap-to-toggle when the pointer cannot hover', () => {
|
|
86
|
+
matches = false;
|
|
87
|
+
installMatchMedia();
|
|
88
|
+
render(
|
|
89
|
+
<HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
|
|
90
|
+
Card content
|
|
91
|
+
</HoverCard>,
|
|
92
|
+
);
|
|
93
|
+
const trigger = screen.getByRole('button', { name: 'Profile' });
|
|
94
|
+
fireEvent.click(trigger);
|
|
95
|
+
expect(screen.getByText('Card content')).toBeDefined();
|
|
96
|
+
fireEvent.click(trigger);
|
|
97
|
+
expect(screen.queryByText('Card content')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
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 CircleCheckIcon(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="10" />
|
|
18
|
+
<path d="m9 12 2 2 4-4" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -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 CircleXIcon(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="10" />
|
|
18
|
+
<path d="m15 9-6 6" />
|
|
19
|
+
<path d="m9 9 6 6" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -3,10 +3,12 @@ import type { ReactElement, SVGProps } from 'react';
|
|
|
3
3
|
import { HouseIcon } from './house';
|
|
4
4
|
import { SettingsIcon } from './settings';
|
|
5
5
|
import { LogOutIcon } from './log-out';
|
|
6
|
+
import { SmartphoneIcon } from './smartphone';
|
|
6
7
|
import { ChevronRightIcon } from './chevron-right';
|
|
7
8
|
import { ChevronLeftIcon } from './chevron-left';
|
|
8
9
|
import { ChevronDownIcon } from './chevron-down';
|
|
9
10
|
import { ChevronUpIcon } from './chevron-up';
|
|
11
|
+
import { MenuIcon } from './menu';
|
|
10
12
|
import { XIcon } from './x';
|
|
11
13
|
import { CheckIcon } from './check';
|
|
12
14
|
import { PlusIcon } from './plus';
|
|
@@ -26,6 +28,8 @@ import { PackageIcon } from './package';
|
|
|
26
28
|
import { Grid2x2Icon } from './grid-2x2';
|
|
27
29
|
import { InfoIcon } from './info';
|
|
28
30
|
import { AlertTriangleIcon } from './alert-triangle';
|
|
31
|
+
import { CircleCheckIcon } from './circle-check';
|
|
32
|
+
import { CircleXIcon } from './circle-x';
|
|
29
33
|
import { CalendarIcon } from './calendar';
|
|
30
34
|
import { SlidersHorizontalIcon } from './sliders-horizontal';
|
|
31
35
|
import { EllipsisVerticalIcon } from './ellipsis-vertical';
|
|
@@ -57,10 +61,12 @@ export type IconName =
|
|
|
57
61
|
| 'house'
|
|
58
62
|
| 'settings'
|
|
59
63
|
| 'log-out'
|
|
64
|
+
| 'smartphone'
|
|
60
65
|
| 'chevron-right'
|
|
61
66
|
| 'chevron-left'
|
|
62
67
|
| 'chevron-down'
|
|
63
68
|
| 'chevron-up'
|
|
69
|
+
| 'menu'
|
|
64
70
|
| 'x'
|
|
65
71
|
| 'check'
|
|
66
72
|
| 'plus'
|
|
@@ -80,6 +86,8 @@ export type IconName =
|
|
|
80
86
|
| 'grid-2x2'
|
|
81
87
|
| 'info'
|
|
82
88
|
| 'alert-triangle'
|
|
89
|
+
| 'circle-check'
|
|
90
|
+
| 'circle-x'
|
|
83
91
|
| 'calendar'
|
|
84
92
|
| 'sliders-horizontal'
|
|
85
93
|
| 'ellipsis-vertical'
|
|
@@ -113,10 +121,12 @@ export const ICONS: Record<IconName, IconComponent> = {
|
|
|
113
121
|
house: HouseIcon,
|
|
114
122
|
settings: SettingsIcon,
|
|
115
123
|
'log-out': LogOutIcon,
|
|
124
|
+
smartphone: SmartphoneIcon,
|
|
116
125
|
'chevron-right': ChevronRightIcon,
|
|
117
126
|
'chevron-left': ChevronLeftIcon,
|
|
118
127
|
'chevron-down': ChevronDownIcon,
|
|
119
128
|
'chevron-up': ChevronUpIcon,
|
|
129
|
+
menu: MenuIcon,
|
|
120
130
|
x: XIcon,
|
|
121
131
|
check: CheckIcon,
|
|
122
132
|
plus: PlusIcon,
|
|
@@ -136,6 +146,8 @@ export const ICONS: Record<IconName, IconComponent> = {
|
|
|
136
146
|
'grid-2x2': Grid2x2Icon,
|
|
137
147
|
info: InfoIcon,
|
|
138
148
|
'alert-triangle': AlertTriangleIcon,
|
|
149
|
+
'circle-check': CircleCheckIcon,
|
|
150
|
+
'circle-x': CircleXIcon,
|
|
139
151
|
calendar: CalendarIcon,
|
|
140
152
|
'sliders-horizontal': SlidersHorizontalIcon,
|
|
141
153
|
'ellipsis-vertical': EllipsisVerticalIcon,
|
|
@@ -167,10 +179,12 @@ export const ICONS: Record<IconName, IconComponent> = {
|
|
|
167
179
|
export { HouseIcon } from './house';
|
|
168
180
|
export { SettingsIcon } from './settings';
|
|
169
181
|
export { LogOutIcon } from './log-out';
|
|
182
|
+
export { SmartphoneIcon } from './smartphone';
|
|
170
183
|
export { ChevronRightIcon } from './chevron-right';
|
|
171
184
|
export { ChevronLeftIcon } from './chevron-left';
|
|
172
185
|
export { ChevronDownIcon } from './chevron-down';
|
|
173
186
|
export { ChevronUpIcon } from './chevron-up';
|
|
187
|
+
export { MenuIcon } from './menu';
|
|
174
188
|
export { XIcon } from './x';
|
|
175
189
|
export { CheckIcon } from './check';
|
|
176
190
|
export { PlusIcon } from './plus';
|
|
@@ -190,6 +204,8 @@ export { PackageIcon } from './package';
|
|
|
190
204
|
export { Grid2x2Icon } from './grid-2x2';
|
|
191
205
|
export { InfoIcon } from './info';
|
|
192
206
|
export { AlertTriangleIcon } from './alert-triangle';
|
|
207
|
+
export { CircleCheckIcon } from './circle-check';
|
|
208
|
+
export { CircleXIcon } from './circle-x';
|
|
193
209
|
export { CalendarIcon } from './calendar';
|
|
194
210
|
export { SlidersHorizontalIcon } from './sliders-horizontal';
|
|
195
211
|
export { EllipsisVerticalIcon } from './ellipsis-vertical';
|
|
@@ -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 MenuIcon(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="M4 5h16" />
|
|
18
|
+
<path d="M4 12h16" />
|
|
19
|
+
<path d="M4 19h16" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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 SmartphoneIcon(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
|
+
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
|
18
|
+
<path d="M12 18h.01" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
.input {
|
|
2
2
|
display: block;
|
|
3
3
|
width: 100%;
|
|
4
|
-
|
|
4
|
+
/* 36px rendered height. Explicit height + border-box (rather than
|
|
5
|
+
deriving height from padding and line-height) gives an exact,
|
|
6
|
+
browser-independent target instead of an approximation. */
|
|
7
|
+
height: 36px;
|
|
8
|
+
box-sizing: border-box;
|
|
9
|
+
padding: 4px var(--sv-space-3);
|
|
5
10
|
font-family: var(--sv-font-family);
|
|
6
11
|
font-size: var(--sv-font-size-sm);
|
|
7
12
|
line-height: 1.5;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--sv-space-3);
|
|
5
|
+
width: 100%;
|
|
6
|
+
padding: var(--sv-space-3);
|
|
7
|
+
border: none;
|
|
8
|
+
border-radius: var(--sv-radius-md);
|
|
9
|
+
background: transparent;
|
|
10
|
+
text-align: left;
|
|
11
|
+
font-family: var(--sv-font-family);
|
|
12
|
+
color: inherit;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.interactive {
|
|
16
|
+
cursor: pointer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (hover: hover) {
|
|
20
|
+
.interactive:not(:disabled):hover {
|
|
21
|
+
background: var(--sv-color-surface-sunken);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.interactive:focus-visible {
|
|
26
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
27
|
+
outline-offset: -2px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.interactive:disabled {
|
|
31
|
+
opacity: 0.5;
|
|
32
|
+
cursor: not-allowed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@media (pointer: coarse) {
|
|
36
|
+
.root {
|
|
37
|
+
min-height: var(--sv-touch-target-min, 44px);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.leading,
|
|
42
|
+
.trailing {
|
|
43
|
+
flex-shrink: 0;
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.content {
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-direction: column;
|
|
51
|
+
min-width: 0;
|
|
52
|
+
flex: 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.title {
|
|
56
|
+
font-size: var(--sv-font-size-sm);
|
|
57
|
+
font-weight: var(--sv-font-weight-medium);
|
|
58
|
+
color: var(--sv-color-text-primary);
|
|
59
|
+
overflow: hidden;
|
|
60
|
+
text-overflow: ellipsis;
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.description {
|
|
65
|
+
font-size: var(--sv-font-size-caption);
|
|
66
|
+
color: var(--sv-color-text-muted);
|
|
67
|
+
overflow: hidden;
|
|
68
|
+
text-overflow: ellipsis;
|
|
69
|
+
white-space: nowrap;
|
|
70
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Item } from './Item';
|
|
3
|
+
import { Icon } from '../Icon/Icon';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/Item',
|
|
7
|
+
component: Item,
|
|
8
|
+
parameters: { layout: 'padded' },
|
|
9
|
+
args: {
|
|
10
|
+
title: 'Notifications',
|
|
11
|
+
},
|
|
12
|
+
} satisfies Meta<typeof Item>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const Default: Story = {};
|
|
20
|
+
|
|
21
|
+
export const WithDescription: Story = {
|
|
22
|
+
args: { description: 'Manage email and push alerts' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const Interactive: Story = {
|
|
26
|
+
args: {
|
|
27
|
+
description: 'Manage email and push alerts',
|
|
28
|
+
leading: <Icon name="bell" aria-hidden />,
|
|
29
|
+
trailing: <Icon name="chevron-right" size="sm" aria-hidden />,
|
|
30
|
+
onClick: () => {},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const Disabled: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
description: 'Requires admin role',
|
|
37
|
+
onClick: () => {},
|
|
38
|
+
disabled: true,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const List: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
45
|
+
<Item
|
|
46
|
+
title="Notifications"
|
|
47
|
+
description="Manage email and push alerts"
|
|
48
|
+
leading={<Icon name="bell" aria-hidden />}
|
|
49
|
+
trailing={<Icon name="chevron-right" size="sm" aria-hidden />}
|
|
50
|
+
onClick={() => {}}
|
|
51
|
+
/>
|
|
52
|
+
<Item
|
|
53
|
+
title="Privacy"
|
|
54
|
+
description="Control what others can see"
|
|
55
|
+
leading={<Icon name="shield" aria-hidden />}
|
|
56
|
+
trailing={<Icon name="chevron-right" size="sm" aria-hidden />}
|
|
57
|
+
onClick={() => {}}
|
|
58
|
+
/>
|
|
59
|
+
<Item title="Sign out" leading={<Icon name="log-out" aria-hidden />} onClick={() => {}} />
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import styles from './Item.module.css';
|
|
3
|
+
|
|
4
|
+
export interface ItemProps {
|
|
5
|
+
leading?: ReactNode;
|
|
6
|
+
title: ReactNode;
|
|
7
|
+
description?: ReactNode;
|
|
8
|
+
trailing?: ReactNode;
|
|
9
|
+
/** Renders as a button when given; a plain row otherwise. */
|
|
10
|
+
onClick?: () => void;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Item — generic row primitive: leading slot, title + optional description,
|
|
17
|
+
* trailing slot. The building block for settings rows, list rows, and
|
|
18
|
+
* menu-adjacent rows that don't fit `Menu`'s own item shape.
|
|
19
|
+
*/
|
|
20
|
+
export function Item({
|
|
21
|
+
leading,
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
trailing,
|
|
25
|
+
onClick,
|
|
26
|
+
disabled,
|
|
27
|
+
className,
|
|
28
|
+
}: ItemProps) {
|
|
29
|
+
const interactive = Boolean(onClick);
|
|
30
|
+
const classes = [styles.root, interactive ? styles.interactive : '', className]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(' ');
|
|
33
|
+
|
|
34
|
+
const content = (
|
|
35
|
+
<>
|
|
36
|
+
{leading && <span className={styles.leading}>{leading}</span>}
|
|
37
|
+
<span className={styles.content}>
|
|
38
|
+
<span className={styles.title}>{title}</span>
|
|
39
|
+
{description && <span className={styles.description}>{description}</span>}
|
|
40
|
+
</span>
|
|
41
|
+
{trailing && <span className={styles.trailing}>{trailing}</span>}
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (interactive) {
|
|
46
|
+
return (
|
|
47
|
+
<button type="button" onClick={onClick} disabled={disabled} className={classes}>
|
|
48
|
+
{content}
|
|
49
|
+
</button>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return <div className={classes}>{content}</div>;
|
|
54
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { Item } from '../Item';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Item', () => {
|
|
9
|
+
it('renders as a plain row when no onClick is given', () => {
|
|
10
|
+
render(<Item title="Notifications" description="Manage email and push alerts" />);
|
|
11
|
+
expect(screen.queryByRole('button')).toBeNull();
|
|
12
|
+
expect(screen.getByText('Notifications')).toBeDefined();
|
|
13
|
+
expect(screen.getByText('Manage email and push alerts')).toBeDefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('renders as a button and fires onClick when given', () => {
|
|
17
|
+
const onClick = vi.fn();
|
|
18
|
+
render(<Item title="Notifications" onClick={onClick} />);
|
|
19
|
+
fireEvent.click(screen.getByRole('button', { name: 'Notifications' }));
|
|
20
|
+
expect(onClick).toHaveBeenCalled();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders leading and trailing slots', () => {
|
|
24
|
+
render(
|
|
25
|
+
<Item
|
|
26
|
+
title="Notifications"
|
|
27
|
+
leading={<span data-testid="leading">icon</span>}
|
|
28
|
+
trailing={<span data-testid="trailing">chevron</span>}
|
|
29
|
+
/>,
|
|
30
|
+
);
|
|
31
|
+
expect(screen.getByTestId('leading')).toBeDefined();
|
|
32
|
+
expect(screen.getByTestId('trailing')).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('disables the button when disabled is set', () => {
|
|
36
|
+
render(<Item title="Notifications" onClick={() => {}} disabled />);
|
|
37
|
+
expect(screen.getByRole('button')).toHaveProperty('disabled', true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.kbd {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
justify-content: center;
|
|
5
|
+
min-width: 1.5em;
|
|
6
|
+
padding: var(--sv-space-1) var(--sv-space-2);
|
|
7
|
+
border: 1px solid var(--sv-color-border-strong);
|
|
8
|
+
border-radius: var(--sv-radius-sm);
|
|
9
|
+
background: var(--sv-color-surface-sunken);
|
|
10
|
+
color: var(--sv-color-text-primary);
|
|
11
|
+
font-family: var(--sv-font-family-mono);
|
|
12
|
+
font-size: var(--sv-font-size-xs);
|
|
13
|
+
line-height: 1;
|
|
14
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Kbd } from './Kbd';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Kbd',
|
|
6
|
+
component: Kbd,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
args: { children: '⌘K' },
|
|
9
|
+
} satisfies Meta<typeof Kbd>;
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const Default: Story = {};
|
|
17
|
+
|
|
18
|
+
export const Letter: Story = {
|
|
19
|
+
args: { children: 'S' },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const Sequence: Story = {
|
|
23
|
+
render: () => (
|
|
24
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
25
|
+
<Kbd>Ctrl</Kbd>
|
|
26
|
+
<span>+</span>
|
|
27
|
+
<Kbd>Shift</Kbd>
|
|
28
|
+
<span>+</span>
|
|
29
|
+
<Kbd>P</Kbd>
|
|
30
|
+
</span>
|
|
31
|
+
),
|
|
32
|
+
};
|