@classic-homes/theme-svelte 0.1.52 → 0.1.54
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/__tests__/a11y/Alert.a11y.test.js +84 -0
- package/dist/__tests__/a11y/AlertDialog.a11y.test.js +126 -0
- package/dist/__tests__/a11y/Button.a11y.test.js +66 -0
- package/dist/__tests__/a11y/Card.a11y.test.js +77 -0
- package/dist/__tests__/a11y/Combobox.a11y.test.js +209 -0
- package/dist/__tests__/a11y/Dialog.a11y.test.js +103 -0
- package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +104 -0
- package/dist/__tests__/a11y/FileUpload.a11y.test.js +158 -0
- package/dist/__tests__/a11y/Icon.a11y.test.js +119 -0
- package/dist/__tests__/a11y/Input.a11y.test.js +96 -0
- package/dist/__tests__/a11y/LiveRegion.a11y.test.js +102 -0
- package/dist/__tests__/a11y/Switch.a11y.test.js +95 -0
- package/dist/__tests__/a11y/Tabs.a11y.test.js +97 -0
- package/dist/__tests__/a11y/Textarea.a11y.test.js +111 -0
- package/dist/__tests__/setup.js +6 -0
- package/dist/lib/components/Alert.svelte +1 -1
- package/dist/lib/components/AlertDialog.svelte +1 -1
- package/dist/lib/components/AlertDialog.svelte.d.ts +2 -2
- package/dist/lib/components/Badge.svelte +49 -12
- package/dist/lib/components/Badge.svelte.d.ts +9 -0
- package/dist/lib/components/Button.svelte +14 -2
- package/dist/lib/components/CardTitle.svelte +9 -3
- package/dist/lib/components/CardTitle.svelte.d.ts +2 -0
- package/dist/lib/components/Combobox.svelte +19 -2
- package/dist/lib/components/DataPanelHeader.svelte +4 -4
- package/dist/lib/components/DataPanelTab.svelte +1 -1
- package/dist/lib/components/DataTable.svelte +7 -7
- package/dist/lib/components/DataTable.svelte.d.ts +5 -5
- package/dist/lib/components/DateTimePicker.svelte +7 -4
- package/dist/lib/components/Dialog.svelte +6 -6
- package/dist/lib/components/DropdownMenu.svelte +14 -10
- package/dist/lib/components/FileUpload.svelte +5 -10
- package/dist/lib/components/FormField.svelte +7 -1
- package/dist/lib/components/HeaderSearch.svelte +9 -9
- package/dist/lib/components/Icon.svelte +5 -5
- package/dist/lib/components/Input.svelte +32 -0
- package/dist/lib/components/Input.svelte.d.ts +8 -0
- package/dist/lib/components/LiveRegion.svelte +61 -0
- package/dist/lib/components/LiveRegion.svelte.d.ts +18 -0
- package/dist/lib/components/LoadingOverlay.svelte +148 -0
- package/dist/lib/components/LoadingOverlay.svelte.d.ts +29 -0
- package/dist/lib/components/LoadingPage.svelte +86 -0
- package/dist/lib/components/LoadingPage.svelte.d.ts +20 -0
- package/dist/lib/components/LoadingSection.svelte +54 -0
- package/dist/lib/components/LoadingSection.svelte.d.ts +25 -0
- package/dist/lib/components/MultiSelect.svelte +23 -2
- package/dist/lib/components/NumberInput.svelte +4 -4
- package/dist/lib/components/OTPInput.svelte +14 -0
- package/dist/lib/components/PasswordInput.svelte +14 -1
- package/dist/lib/components/PasswordInput.svelte.d.ts +4 -0
- package/dist/lib/components/ProgressBar.svelte +91 -0
- package/dist/lib/components/ProgressBar.svelte.d.ts +18 -0
- package/dist/lib/components/RadioGroup.svelte +5 -5
- package/dist/lib/components/Select.svelte +13 -1
- package/dist/lib/components/Signature.svelte +5 -5
- package/dist/lib/components/Skeleton.svelte +13 -3
- package/dist/lib/components/Skeleton.svelte.d.ts +2 -0
- package/dist/lib/components/SkipLink.svelte +67 -0
- package/dist/lib/components/SkipLink.svelte.d.ts +25 -0
- package/dist/lib/components/Slider.svelte +6 -2
- package/dist/lib/components/Switch.svelte +8 -0
- package/dist/lib/components/Switch.svelte.d.ts +4 -0
- package/dist/lib/components/Tabs.svelte +15 -5
- package/dist/lib/components/Tabs.svelte.d.ts +6 -0
- package/dist/lib/components/Textarea.svelte +19 -0
- package/dist/lib/components/Textarea.svelte.d.ts +8 -0
- package/dist/lib/components/Toast.svelte +1 -1
- package/dist/lib/components/layout/AuthLayout.svelte +1 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +1 -1
- package/dist/lib/components/layout/Footer.svelte +1 -1
- package/dist/lib/components/layout/PublicLayout.svelte +2 -2
- package/dist/lib/components/layout/QuickLinks.svelte +3 -3
- package/dist/lib/components/layout/Sidebar.svelte +1 -1
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +1 -2
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +20 -21
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +2 -2
- package/dist/lib/components/notifications/NotificationFilters.svelte +1 -1
- package/dist/lib/components/notifications/NotificationItem.svelte +2 -1
- package/dist/lib/composables/index.d.ts +8 -0
- package/dist/lib/composables/index.js +9 -0
- package/dist/lib/composables/useAnnounce.svelte.d.ts +65 -0
- package/dist/lib/composables/useAnnounce.svelte.js +76 -0
- package/dist/lib/composables/useArrowNavigation.svelte.d.ts +42 -0
- package/dist/lib/composables/useArrowNavigation.svelte.js +138 -0
- package/dist/lib/composables/useClickOutside.svelte.d.ts +88 -0
- package/dist/lib/composables/useClickOutside.svelte.js +100 -0
- package/dist/lib/composables/useEscapeKey.svelte.d.ts +48 -0
- package/dist/lib/composables/useEscapeKey.svelte.js +50 -0
- package/dist/lib/composables/useFocusRef.svelte.d.ts +46 -0
- package/dist/lib/composables/useFocusRef.svelte.js +92 -0
- package/dist/lib/composables/useFocusTrap.svelte.d.ts +42 -0
- package/dist/lib/composables/useFocusTrap.svelte.js +136 -0
- package/dist/lib/composables/useReducedMotion.svelte.d.ts +47 -0
- package/dist/lib/composables/useReducedMotion.svelte.js +67 -0
- package/dist/lib/composables/useRovingTabindex.svelte.d.ts +54 -0
- package/dist/lib/composables/useRovingTabindex.svelte.js +128 -0
- package/dist/lib/index.d.ts +7 -1
- package/dist/lib/index.js +11 -1
- package/dist/lib/stores/theme.svelte.d.ts +1 -1
- package/dist/lib/utils.d.ts +4 -2
- package/dist/lib/utils.js +5 -11
- package/package.json +11 -5
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the useAnnounce composable
|
|
3
|
+
*/
|
|
4
|
+
export interface UseAnnounceOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Politeness level for the announcement.
|
|
7
|
+
* - 'polite': Waits for the user to be idle before announcing (default)
|
|
8
|
+
* - 'assertive': Interrupts the user immediately
|
|
9
|
+
*/
|
|
10
|
+
politeness?: 'polite' | 'assertive';
|
|
11
|
+
/**
|
|
12
|
+
* Delay in milliseconds before clearing the announcement.
|
|
13
|
+
* Set to 0 to never auto-clear. Default is 1000ms.
|
|
14
|
+
*/
|
|
15
|
+
clearDelay?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Return type for useAnnounce composable
|
|
19
|
+
*/
|
|
20
|
+
export interface UseAnnounceReturn {
|
|
21
|
+
/**
|
|
22
|
+
* Function to make an announcement to screen readers
|
|
23
|
+
*/
|
|
24
|
+
announce: (message: string, options?: UseAnnounceOptions) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Current announcement message (for rendering in a live region)
|
|
27
|
+
*/
|
|
28
|
+
readonly message: string;
|
|
29
|
+
/**
|
|
30
|
+
* Current politeness level
|
|
31
|
+
*/
|
|
32
|
+
readonly politeness: 'polite' | 'assertive';
|
|
33
|
+
/**
|
|
34
|
+
* Clear the current announcement
|
|
35
|
+
*/
|
|
36
|
+
clear: () => void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* useAnnounce - Composable for imperative screen reader announcements
|
|
40
|
+
*
|
|
41
|
+
* Creates reactive state that can be used with a live region to announce
|
|
42
|
+
* dynamic content to screen reader users. Useful for status updates,
|
|
43
|
+
* form validation, and other dynamic content changes.
|
|
44
|
+
*
|
|
45
|
+
* @see https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA22
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```svelte
|
|
49
|
+
* <script>
|
|
50
|
+
* import { useAnnounce } from '@classic-homes/theme-svelte';
|
|
51
|
+
* import LiveRegion from './LiveRegion.svelte';
|
|
52
|
+
*
|
|
53
|
+
* const { announce, message, politeness } = useAnnounce();
|
|
54
|
+
*
|
|
55
|
+
* async function handleSave() {
|
|
56
|
+
* await saveData();
|
|
57
|
+
* announce('Data saved successfully');
|
|
58
|
+
* }
|
|
59
|
+
* </script>
|
|
60
|
+
*
|
|
61
|
+
* <button onclick={handleSave}>Save</button>
|
|
62
|
+
* <LiveRegion message={message} politeness={politeness} />
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function useAnnounce(defaultOptions?: UseAnnounceOptions): UseAnnounceReturn;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAnnounce - Composable for imperative screen reader announcements
|
|
3
|
+
*
|
|
4
|
+
* Creates reactive state that can be used with a live region to announce
|
|
5
|
+
* dynamic content to screen reader users. Useful for status updates,
|
|
6
|
+
* form validation, and other dynamic content changes.
|
|
7
|
+
*
|
|
8
|
+
* @see https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA22
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```svelte
|
|
12
|
+
* <script>
|
|
13
|
+
* import { useAnnounce } from '@classic-homes/theme-svelte';
|
|
14
|
+
* import LiveRegion from './LiveRegion.svelte';
|
|
15
|
+
*
|
|
16
|
+
* const { announce, message, politeness } = useAnnounce();
|
|
17
|
+
*
|
|
18
|
+
* async function handleSave() {
|
|
19
|
+
* await saveData();
|
|
20
|
+
* announce('Data saved successfully');
|
|
21
|
+
* }
|
|
22
|
+
* </script>
|
|
23
|
+
*
|
|
24
|
+
* <button onclick={handleSave}>Save</button>
|
|
25
|
+
* <LiveRegion message={message} politeness={politeness} />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function useAnnounce(defaultOptions) {
|
|
29
|
+
let message = $state('');
|
|
30
|
+
let politeness = $state(defaultOptions?.politeness ?? 'polite');
|
|
31
|
+
let clearTimeout = null;
|
|
32
|
+
function clear() {
|
|
33
|
+
message = '';
|
|
34
|
+
if (clearTimeout) {
|
|
35
|
+
globalThis.clearTimeout(clearTimeout);
|
|
36
|
+
clearTimeout = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function announce(newMessage, options) {
|
|
40
|
+
// Clear any pending timeout
|
|
41
|
+
if (clearTimeout) {
|
|
42
|
+
globalThis.clearTimeout(clearTimeout);
|
|
43
|
+
}
|
|
44
|
+
// Update politeness if provided
|
|
45
|
+
if (options?.politeness) {
|
|
46
|
+
politeness = options.politeness;
|
|
47
|
+
}
|
|
48
|
+
// Set the new message
|
|
49
|
+
message = newMessage;
|
|
50
|
+
// Set up auto-clear if delay > 0
|
|
51
|
+
const delay = options?.clearDelay ?? defaultOptions?.clearDelay ?? 1000;
|
|
52
|
+
if (delay > 0) {
|
|
53
|
+
clearTimeout = globalThis.setTimeout(() => {
|
|
54
|
+
message = '';
|
|
55
|
+
}, delay);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Cleanup on destroy
|
|
59
|
+
$effect(() => {
|
|
60
|
+
return () => {
|
|
61
|
+
if (clearTimeout) {
|
|
62
|
+
globalThis.clearTimeout(clearTimeout);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
announce,
|
|
68
|
+
get message() {
|
|
69
|
+
return message;
|
|
70
|
+
},
|
|
71
|
+
get politeness() {
|
|
72
|
+
return politeness;
|
|
73
|
+
},
|
|
74
|
+
clear,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type ArrowNavigationOrientation = 'horizontal' | 'vertical' | 'both';
|
|
2
|
+
export interface UseArrowNavigationOptions {
|
|
3
|
+
/** Navigation orientation - determines which arrow keys work */
|
|
4
|
+
orientation?: ArrowNavigationOrientation;
|
|
5
|
+
/** Whether navigation wraps from end to start (and vice versa) */
|
|
6
|
+
loop?: boolean;
|
|
7
|
+
/** Selector for focusable items within the container */
|
|
8
|
+
itemSelector?: string;
|
|
9
|
+
/** Callback when focus changes */
|
|
10
|
+
onFocusChange?: (index: number, element: HTMLElement) => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Composable for arrow key navigation within a container of items.
|
|
14
|
+
*
|
|
15
|
+
* Implements keyboard navigation patterns for menus, listboxes, tabs,
|
|
16
|
+
* and other composite widgets per WAI-ARIA Authoring Practices.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```svelte
|
|
20
|
+
* <script>
|
|
21
|
+
* import { useArrowNavigation } from '@classic-homes/theme-svelte';
|
|
22
|
+
*
|
|
23
|
+
* const nav = useArrowNavigation({ orientation: 'vertical', loop: true });
|
|
24
|
+
* </script>
|
|
25
|
+
*
|
|
26
|
+
* <ul use:nav.action role="menu">
|
|
27
|
+
* {#each items as item, i}
|
|
28
|
+
* <li role="menuitem" tabindex={i === 0 ? 0 : -1}>
|
|
29
|
+
* {item.label}
|
|
30
|
+
* </li>
|
|
31
|
+
* {/each}
|
|
32
|
+
* </ul>
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function useArrowNavigation(options?: UseArrowNavigationOptions): {
|
|
36
|
+
action: (node: HTMLElement) => {
|
|
37
|
+
update(newOptions: UseArrowNavigationOptions): void;
|
|
38
|
+
destroy(): void;
|
|
39
|
+
};
|
|
40
|
+
focusItem: (index: number) => void;
|
|
41
|
+
readonly focusedIndex: number;
|
|
42
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const DEFAULT_SELECTOR = '[role="menuitem"], [role="option"], [role="tab"], button, a, [tabindex]:not([tabindex="-1"])';
|
|
2
|
+
/**
|
|
3
|
+
* Composable for arrow key navigation within a container of items.
|
|
4
|
+
*
|
|
5
|
+
* Implements keyboard navigation patterns for menus, listboxes, tabs,
|
|
6
|
+
* and other composite widgets per WAI-ARIA Authoring Practices.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```svelte
|
|
10
|
+
* <script>
|
|
11
|
+
* import { useArrowNavigation } from '@classic-homes/theme-svelte';
|
|
12
|
+
*
|
|
13
|
+
* const nav = useArrowNavigation({ orientation: 'vertical', loop: true });
|
|
14
|
+
* </script>
|
|
15
|
+
*
|
|
16
|
+
* <ul use:nav.action role="menu">
|
|
17
|
+
* {#each items as item, i}
|
|
18
|
+
* <li role="menuitem" tabindex={i === 0 ? 0 : -1}>
|
|
19
|
+
* {item.label}
|
|
20
|
+
* </li>
|
|
21
|
+
* {/each}
|
|
22
|
+
* </ul>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function useArrowNavigation(options = {}) {
|
|
26
|
+
let orientation = $state(options.orientation ?? 'vertical');
|
|
27
|
+
let loop = $state(options.loop ?? true);
|
|
28
|
+
let itemSelector = $state(options.itemSelector ?? DEFAULT_SELECTOR);
|
|
29
|
+
let onFocusChange = $state(options.onFocusChange);
|
|
30
|
+
let container = null;
|
|
31
|
+
let focusedIndex = $state(-1);
|
|
32
|
+
function getFocusableItems() {
|
|
33
|
+
if (!container)
|
|
34
|
+
return [];
|
|
35
|
+
return Array.from(container.querySelectorAll(itemSelector));
|
|
36
|
+
}
|
|
37
|
+
function focusItem(index) {
|
|
38
|
+
const items = getFocusableItems();
|
|
39
|
+
if (items.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
let targetIndex = index;
|
|
42
|
+
// Handle wrapping
|
|
43
|
+
if (loop) {
|
|
44
|
+
if (targetIndex < 0)
|
|
45
|
+
targetIndex = items.length - 1;
|
|
46
|
+
if (targetIndex >= items.length)
|
|
47
|
+
targetIndex = 0;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
targetIndex = Math.max(0, Math.min(targetIndex, items.length - 1));
|
|
51
|
+
}
|
|
52
|
+
const element = items[targetIndex];
|
|
53
|
+
if (element) {
|
|
54
|
+
element.focus();
|
|
55
|
+
focusedIndex = targetIndex;
|
|
56
|
+
onFocusChange?.(targetIndex, element);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function handleKeyDown(event) {
|
|
60
|
+
const items = getFocusableItems();
|
|
61
|
+
if (items.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
let handled = false;
|
|
64
|
+
switch (event.key) {
|
|
65
|
+
case 'ArrowUp':
|
|
66
|
+
if (orientation === 'vertical' || orientation === 'both') {
|
|
67
|
+
focusItem(focusedIndex - 1);
|
|
68
|
+
handled = true;
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
case 'ArrowDown':
|
|
72
|
+
if (orientation === 'vertical' || orientation === 'both') {
|
|
73
|
+
focusItem(focusedIndex + 1);
|
|
74
|
+
handled = true;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
case 'ArrowLeft':
|
|
78
|
+
if (orientation === 'horizontal' || orientation === 'both') {
|
|
79
|
+
focusItem(focusedIndex - 1);
|
|
80
|
+
handled = true;
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
case 'ArrowRight':
|
|
84
|
+
if (orientation === 'horizontal' || orientation === 'both') {
|
|
85
|
+
focusItem(focusedIndex + 1);
|
|
86
|
+
handled = true;
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
case 'Home':
|
|
90
|
+
focusItem(0);
|
|
91
|
+
handled = true;
|
|
92
|
+
break;
|
|
93
|
+
case 'End':
|
|
94
|
+
focusItem(items.length - 1);
|
|
95
|
+
handled = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (handled) {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
event.stopPropagation();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function handleFocus(event) {
|
|
104
|
+
const items = getFocusableItems();
|
|
105
|
+
const index = items.indexOf(event.target);
|
|
106
|
+
if (index !== -1) {
|
|
107
|
+
focusedIndex = index;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Svelte action to apply arrow navigation to a container
|
|
112
|
+
*/
|
|
113
|
+
function action(node) {
|
|
114
|
+
container = node;
|
|
115
|
+
node.addEventListener('keydown', handleKeyDown);
|
|
116
|
+
node.addEventListener('focusin', handleFocus);
|
|
117
|
+
return {
|
|
118
|
+
update(newOptions) {
|
|
119
|
+
orientation = newOptions.orientation ?? 'vertical';
|
|
120
|
+
loop = newOptions.loop ?? true;
|
|
121
|
+
itemSelector = newOptions.itemSelector ?? DEFAULT_SELECTOR;
|
|
122
|
+
onFocusChange = newOptions.onFocusChange;
|
|
123
|
+
},
|
|
124
|
+
destroy() {
|
|
125
|
+
node.removeEventListener('keydown', handleKeyDown);
|
|
126
|
+
node.removeEventListener('focusin', handleFocus);
|
|
127
|
+
container = null;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
action,
|
|
133
|
+
focusItem,
|
|
134
|
+
get focusedIndex() {
|
|
135
|
+
return focusedIndex;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the useClickOutside composable
|
|
3
|
+
*/
|
|
4
|
+
export interface UseClickOutsideOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Whether the click outside handler is enabled.
|
|
7
|
+
* Default: true
|
|
8
|
+
*/
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Event types to listen for.
|
|
12
|
+
* Default: ['mousedown', 'touchstart']
|
|
13
|
+
*/
|
|
14
|
+
events?: ('mousedown' | 'mouseup' | 'touchstart' | 'touchend')[];
|
|
15
|
+
/**
|
|
16
|
+
* Additional elements that should be considered as "inside" even if not descendants.
|
|
17
|
+
* Useful for portaled elements like tooltips or dropdowns.
|
|
18
|
+
*/
|
|
19
|
+
ignoreElements?: HTMLElement[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* useClickOutside - Composable for detecting clicks outside an element
|
|
23
|
+
*
|
|
24
|
+
* Useful for closing dropdowns, dialogs, and other overlay components
|
|
25
|
+
* when clicking outside of them.
|
|
26
|
+
*
|
|
27
|
+
* Accessibility note: This composable should be used in conjunction with
|
|
28
|
+
* keyboard escape handling for full accessibility. Users should be
|
|
29
|
+
* able to close overlays with both mouse clicks and the Escape key.
|
|
30
|
+
*
|
|
31
|
+
* @see useEscapeKey
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```svelte
|
|
35
|
+
* <script>
|
|
36
|
+
* import { useClickOutside, useEscapeKey } from '@classic-homes/theme-svelte';
|
|
37
|
+
*
|
|
38
|
+
* let { isOpen = $bindable() } = $props();
|
|
39
|
+
* let contentRef: HTMLDivElement;
|
|
40
|
+
*
|
|
41
|
+
* function close() {
|
|
42
|
+
* isOpen = false;
|
|
43
|
+
* }
|
|
44
|
+
*
|
|
45
|
+
* // Using $effect to set up handlers with the element reference
|
|
46
|
+
* $effect(() => {
|
|
47
|
+
* if (isOpen && contentRef) {
|
|
48
|
+
* return useClickOutside(contentRef, close);
|
|
49
|
+
* }
|
|
50
|
+
* });
|
|
51
|
+
*
|
|
52
|
+
* useEscapeKey(close, { enabled: isOpen });
|
|
53
|
+
* </script>
|
|
54
|
+
*
|
|
55
|
+
* {#if isOpen}
|
|
56
|
+
* <div bind:this={contentRef}>Dropdown content</div>
|
|
57
|
+
* {/if}
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @example Action-based usage
|
|
61
|
+
* ```svelte
|
|
62
|
+
* <script>
|
|
63
|
+
* import { clickOutsideAction } from '@classic-homes/theme-svelte';
|
|
64
|
+
*
|
|
65
|
+
* let isOpen = $state(true);
|
|
66
|
+
* </script>
|
|
67
|
+
*
|
|
68
|
+
* {#if isOpen}
|
|
69
|
+
* <div use:clickOutsideAction={() => isOpen = false}>
|
|
70
|
+
* Dropdown content
|
|
71
|
+
* </div>
|
|
72
|
+
* {/if}
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export declare function useClickOutside(element: HTMLElement, callback: (event: MouseEvent | TouchEvent) => void, options?: UseClickOutsideOptions): () => void;
|
|
76
|
+
/**
|
|
77
|
+
* Svelte action for click outside detection.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```svelte
|
|
81
|
+
* <div use:clickOutsideAction={() => isOpen = false}>
|
|
82
|
+
* Content
|
|
83
|
+
* </div>
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export declare function clickOutsideAction(node: HTMLElement, callback: (event: MouseEvent | TouchEvent) => void): {
|
|
87
|
+
destroy: () => void;
|
|
88
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useClickOutside - Composable for detecting clicks outside an element
|
|
3
|
+
*
|
|
4
|
+
* Useful for closing dropdowns, dialogs, and other overlay components
|
|
5
|
+
* when clicking outside of them.
|
|
6
|
+
*
|
|
7
|
+
* Accessibility note: This composable should be used in conjunction with
|
|
8
|
+
* keyboard escape handling for full accessibility. Users should be
|
|
9
|
+
* able to close overlays with both mouse clicks and the Escape key.
|
|
10
|
+
*
|
|
11
|
+
* @see useEscapeKey
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```svelte
|
|
15
|
+
* <script>
|
|
16
|
+
* import { useClickOutside, useEscapeKey } from '@classic-homes/theme-svelte';
|
|
17
|
+
*
|
|
18
|
+
* let { isOpen = $bindable() } = $props();
|
|
19
|
+
* let contentRef: HTMLDivElement;
|
|
20
|
+
*
|
|
21
|
+
* function close() {
|
|
22
|
+
* isOpen = false;
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* // Using $effect to set up handlers with the element reference
|
|
26
|
+
* $effect(() => {
|
|
27
|
+
* if (isOpen && contentRef) {
|
|
28
|
+
* return useClickOutside(contentRef, close);
|
|
29
|
+
* }
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* useEscapeKey(close, { enabled: isOpen });
|
|
33
|
+
* </script>
|
|
34
|
+
*
|
|
35
|
+
* {#if isOpen}
|
|
36
|
+
* <div bind:this={contentRef}>Dropdown content</div>
|
|
37
|
+
* {/if}
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @example Action-based usage
|
|
41
|
+
* ```svelte
|
|
42
|
+
* <script>
|
|
43
|
+
* import { clickOutsideAction } from '@classic-homes/theme-svelte';
|
|
44
|
+
*
|
|
45
|
+
* let isOpen = $state(true);
|
|
46
|
+
* </script>
|
|
47
|
+
*
|
|
48
|
+
* {#if isOpen}
|
|
49
|
+
* <div use:clickOutsideAction={() => isOpen = false}>
|
|
50
|
+
* Dropdown content
|
|
51
|
+
* </div>
|
|
52
|
+
* {/if}
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function useClickOutside(element, callback, options) {
|
|
56
|
+
const { enabled = true, events = ['mousedown', 'touchstart'], ignoreElements = [], } = options ?? {};
|
|
57
|
+
if (!enabled) {
|
|
58
|
+
return () => { };
|
|
59
|
+
}
|
|
60
|
+
function handleEvent(event) {
|
|
61
|
+
const target = event.target;
|
|
62
|
+
// Check if click is inside the main element
|
|
63
|
+
if (element.contains(target)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Check if click is inside any of the ignored elements
|
|
67
|
+
for (const ignoreEl of ignoreElements) {
|
|
68
|
+
if (ignoreEl?.contains(target)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
callback(event);
|
|
73
|
+
}
|
|
74
|
+
// Add event listeners
|
|
75
|
+
events.forEach((eventType) => {
|
|
76
|
+
document.addEventListener(eventType, handleEvent);
|
|
77
|
+
});
|
|
78
|
+
// Return cleanup function
|
|
79
|
+
return () => {
|
|
80
|
+
events.forEach((eventType) => {
|
|
81
|
+
document.removeEventListener(eventType, handleEvent);
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Svelte action for click outside detection.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```svelte
|
|
90
|
+
* <div use:clickOutsideAction={() => isOpen = false}>
|
|
91
|
+
* Content
|
|
92
|
+
* </div>
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function clickOutsideAction(node, callback) {
|
|
96
|
+
const cleanup = useClickOutside(node, callback);
|
|
97
|
+
return {
|
|
98
|
+
destroy: cleanup,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the useEscapeKey composable
|
|
3
|
+
*/
|
|
4
|
+
export interface UseEscapeKeyOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Whether the escape key handler is enabled.
|
|
7
|
+
* Useful for conditionally enabling based on component state (e.g., dialog open).
|
|
8
|
+
* Default: true
|
|
9
|
+
*/
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Whether to prevent default behavior when Escape is pressed.
|
|
13
|
+
* Default: true
|
|
14
|
+
*/
|
|
15
|
+
preventDefault?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Whether to stop propagation when Escape is pressed.
|
|
18
|
+
* Default: false
|
|
19
|
+
*/
|
|
20
|
+
stopPropagation?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* useEscapeKey - Composable for handling Escape key press
|
|
24
|
+
*
|
|
25
|
+
* Registers a global keydown listener for the Escape key.
|
|
26
|
+
* Useful for closing modals, dialogs, dropdowns, etc.
|
|
27
|
+
*
|
|
28
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```svelte
|
|
32
|
+
* <script>
|
|
33
|
+
* import { useEscapeKey } from '@classic-homes/theme-svelte';
|
|
34
|
+
*
|
|
35
|
+
* let { isOpen = $bindable(), onClose } = $props();
|
|
36
|
+
*
|
|
37
|
+
* useEscapeKey(() => {
|
|
38
|
+
* isOpen = false;
|
|
39
|
+
* onClose?.();
|
|
40
|
+
* }, { enabled: isOpen });
|
|
41
|
+
* </script>
|
|
42
|
+
*
|
|
43
|
+
* {#if isOpen}
|
|
44
|
+
* <div>Modal content</div>
|
|
45
|
+
* {/if}
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function useEscapeKey(callback: (event: KeyboardEvent) => void, options?: UseEscapeKeyOptions): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useEscapeKey - Composable for handling Escape key press
|
|
3
|
+
*
|
|
4
|
+
* Registers a global keydown listener for the Escape key.
|
|
5
|
+
* Useful for closing modals, dialogs, dropdowns, etc.
|
|
6
|
+
*
|
|
7
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```svelte
|
|
11
|
+
* <script>
|
|
12
|
+
* import { useEscapeKey } from '@classic-homes/theme-svelte';
|
|
13
|
+
*
|
|
14
|
+
* let { isOpen = $bindable(), onClose } = $props();
|
|
15
|
+
*
|
|
16
|
+
* useEscapeKey(() => {
|
|
17
|
+
* isOpen = false;
|
|
18
|
+
* onClose?.();
|
|
19
|
+
* }, { enabled: isOpen });
|
|
20
|
+
* </script>
|
|
21
|
+
*
|
|
22
|
+
* {#if isOpen}
|
|
23
|
+
* <div>Modal content</div>
|
|
24
|
+
* {/if}
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function useEscapeKey(callback, options) {
|
|
28
|
+
const enabled = $derived(options?.enabled ?? true);
|
|
29
|
+
const preventDefault = options?.preventDefault ?? true;
|
|
30
|
+
const stopPropagation = options?.stopPropagation ?? false;
|
|
31
|
+
$effect(() => {
|
|
32
|
+
if (!enabled)
|
|
33
|
+
return;
|
|
34
|
+
function handleKeyDown(event) {
|
|
35
|
+
if (event.key === 'Escape') {
|
|
36
|
+
if (preventDefault) {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
}
|
|
39
|
+
if (stopPropagation) {
|
|
40
|
+
event.stopPropagation();
|
|
41
|
+
}
|
|
42
|
+
callback(event);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
46
|
+
return () => {
|
|
47
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the useFocusRef composable
|
|
3
|
+
*/
|
|
4
|
+
export interface UseFocusRefOptions {
|
|
5
|
+
/** Whether to focus the element on mount */
|
|
6
|
+
focusOnMount?: boolean;
|
|
7
|
+
/** Whether to prevent scroll when focusing */
|
|
8
|
+
preventScroll?: boolean;
|
|
9
|
+
/** Delay in ms before focusing (useful for animations) */
|
|
10
|
+
delay?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Creates a focus action for use with Svelte's use: directive.
|
|
14
|
+
*
|
|
15
|
+
* Useful for focusing form fields, dialog close buttons, or other
|
|
16
|
+
* elements that should receive focus when they appear.
|
|
17
|
+
*
|
|
18
|
+
* @example Basic usage
|
|
19
|
+
* ```svelte
|
|
20
|
+
* <script>
|
|
21
|
+
* import { useFocusRef } from '@classic-homes/theme-svelte';
|
|
22
|
+
*
|
|
23
|
+
* const { action } = useFocusRef({ focusOnMount: true });
|
|
24
|
+
* </script>
|
|
25
|
+
*
|
|
26
|
+
* <input use:action type="search" />
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @example With delay (for animations)
|
|
30
|
+
* ```svelte
|
|
31
|
+
* <script>
|
|
32
|
+
* import { useFocusRef } from '@classic-homes/theme-svelte';
|
|
33
|
+
*
|
|
34
|
+
* const { action } = useFocusRef({ focusOnMount: true, delay: 200 });
|
|
35
|
+
* </script>
|
|
36
|
+
*
|
|
37
|
+
* <button use:action>Close</button>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare function useFocusRef(options?: UseFocusRefOptions): {
|
|
41
|
+
action: (node: HTMLElement) => {
|
|
42
|
+
update(newOptions: UseFocusRefOptions): void;
|
|
43
|
+
destroy(): void;
|
|
44
|
+
};
|
|
45
|
+
focus: () => void;
|
|
46
|
+
};
|