@mrintel/villain-ui 0.3.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3490 -1296
  3. package/dist/components/buttons/Button.svelte +27 -33
  4. package/dist/components/buttons/Button.svelte.d.ts +4 -1
  5. package/dist/components/buttons/ButtonGroup.svelte +17 -30
  6. package/dist/components/buttons/FloatingActionButton.svelte +20 -44
  7. package/dist/components/buttons/FloatingActionButton.svelte.d.ts +2 -1
  8. package/dist/components/buttons/IconButton.svelte +23 -53
  9. package/dist/components/buttons/IconButton.svelte.d.ts +2 -1
  10. package/dist/components/buttons/LinkButton.svelte +24 -37
  11. package/dist/components/buttons/LinkButton.svelte.d.ts +4 -1
  12. package/dist/components/buttons/buttonClasses.d.ts +5 -0
  13. package/dist/components/buttons/buttonClasses.js +8 -3
  14. package/dist/components/cards/Card.svelte +54 -46
  15. package/dist/components/cards/Card.svelte.d.ts +9 -2
  16. package/dist/components/cards/Container.svelte +17 -33
  17. package/dist/components/cards/Divider.svelte +36 -52
  18. package/dist/components/cards/Divider.svelte.d.ts +2 -0
  19. package/dist/components/cards/Grid.svelte +55 -44
  20. package/dist/components/cards/Panel.svelte +18 -32
  21. package/dist/components/cards/Panel.svelte.d.ts +2 -1
  22. package/dist/components/cards/SectionHeader.svelte +24 -38
  23. package/dist/components/cards/SectionHeader.svelte.d.ts +1 -0
  24. package/dist/components/data/Avatar.svelte +48 -67
  25. package/dist/components/data/Badge.svelte +45 -32
  26. package/dist/components/data/Badge.svelte.d.ts +7 -1
  27. package/dist/components/data/CalendarGrid.svelte +433 -0
  28. package/dist/components/data/CalendarGrid.svelte.d.ts +25 -0
  29. package/dist/components/data/CalendarGrid.types.d.ts +7 -0
  30. package/dist/components/data/CalendarGrid.types.js +1 -0
  31. package/dist/components/data/CodeBlock.svelte +119 -121
  32. package/dist/components/data/CodeBlock.svelte.d.ts +8 -0
  33. package/dist/components/data/List.svelte +87 -64
  34. package/dist/components/data/List.svelte.d.ts +7 -0
  35. package/dist/components/data/Pagination.svelte +121 -123
  36. package/dist/components/data/Pagination.svelte.d.ts +5 -0
  37. package/dist/components/data/Sparkline.svelte +117 -0
  38. package/dist/components/data/Sparkline.svelte.d.ts +43 -0
  39. package/dist/components/data/Stat.svelte +92 -103
  40. package/dist/components/data/Table.svelte +443 -76
  41. package/dist/components/data/Table.svelte.d.ts +23 -2
  42. package/dist/components/data/Table.types.d.ts +14 -0
  43. package/dist/components/data/Table.types.js +1 -0
  44. package/dist/components/data/Tag.svelte +51 -53
  45. package/dist/components/data/Tag.svelte.d.ts +5 -1
  46. package/dist/components/data/index.d.ts +4 -0
  47. package/dist/components/data/index.js +2 -0
  48. package/dist/components/forms/Checkbox.svelte +39 -51
  49. package/dist/components/forms/Checkbox.svelte.d.ts +3 -1
  50. package/dist/components/forms/DatePicker.svelte +61 -0
  51. package/dist/components/forms/DatePicker.svelte.d.ts +15 -0
  52. package/dist/components/forms/DateTimePicker.svelte +63 -0
  53. package/dist/components/forms/DateTimePicker.svelte.d.ts +16 -0
  54. package/dist/components/forms/FileUpload.svelte +136 -164
  55. package/dist/components/forms/FileUpload.svelte.d.ts +1 -0
  56. package/dist/components/forms/Input.svelte +284 -57
  57. package/dist/components/forms/Input.svelte.d.ts +10 -3
  58. package/dist/components/forms/InputGroup.svelte +7 -7
  59. package/dist/components/forms/RadioGroup.svelte +77 -87
  60. package/dist/components/forms/RadioGroup.svelte.d.ts +3 -1
  61. package/dist/components/forms/RangeSlider.svelte +90 -116
  62. package/dist/components/forms/Select.svelte +106 -71
  63. package/dist/components/forms/Select.svelte.d.ts +3 -1
  64. package/dist/components/forms/Step.svelte +25 -0
  65. package/dist/components/forms/Step.svelte.d.ts +12 -0
  66. package/dist/components/forms/StepContext.d.ts +3 -0
  67. package/dist/components/forms/StepContext.js +5 -0
  68. package/dist/components/forms/Stepper.types.d.ts +37 -0
  69. package/dist/components/forms/Stepper.types.js +1 -0
  70. package/dist/components/forms/StepperForm.svelte +183 -0
  71. package/dist/components/forms/StepperForm.svelte.d.ts +17 -0
  72. package/dist/components/forms/Switch.svelte +44 -56
  73. package/dist/components/forms/Switch.svelte.d.ts +3 -1
  74. package/dist/components/forms/Textarea.svelte +52 -57
  75. package/dist/components/forms/Textarea.svelte.d.ts +3 -1
  76. package/dist/components/forms/TimePicker.svelte +63 -0
  77. package/dist/components/forms/TimePicker.svelte.d.ts +16 -0
  78. package/dist/components/forms/formClasses.d.ts +3 -0
  79. package/dist/components/forms/formClasses.js +3 -0
  80. package/dist/components/forms/index.d.ts +6 -0
  81. package/dist/components/forms/index.js +5 -0
  82. package/dist/components/navigation/Breadcrumbs.svelte +56 -59
  83. package/dist/components/navigation/Breadcrumbs.svelte.d.ts +1 -0
  84. package/dist/components/navigation/ContextMenu.svelte +133 -83
  85. package/dist/components/navigation/ContextMenu.svelte.d.ts +8 -1
  86. package/dist/components/navigation/DropdownMenu.svelte +139 -80
  87. package/dist/components/navigation/DropdownMenu.svelte.d.ts +8 -1
  88. package/dist/components/navigation/Menu.svelte +72 -48
  89. package/dist/components/navigation/Navbar.svelte +111 -32
  90. package/dist/components/navigation/Navbar.svelte.d.ts +6 -0
  91. package/dist/components/navigation/Sidebar.svelte +236 -35
  92. package/dist/components/navigation/Sidebar.svelte.d.ts +2 -0
  93. package/dist/components/navigation/Stepper.svelte +204 -0
  94. package/dist/components/navigation/Stepper.svelte.d.ts +34 -0
  95. package/dist/components/navigation/Tabs.svelte +86 -54
  96. package/dist/components/navigation/Tabs.svelte.d.ts +5 -1
  97. package/dist/components/navigation/index.d.ts +1 -0
  98. package/dist/components/navigation/index.js +1 -0
  99. package/dist/components/overlays/Alert.svelte +81 -99
  100. package/dist/components/overlays/Alert.svelte.d.ts +5 -1
  101. package/dist/components/overlays/CommandPalette.svelte +182 -217
  102. package/dist/components/overlays/Drawer.svelte +158 -167
  103. package/dist/components/overlays/Drawer.svelte.d.ts +3 -1
  104. package/dist/components/overlays/Dropdown.svelte +62 -30
  105. package/dist/components/overlays/Dropdown.svelte.d.ts +2 -0
  106. package/dist/components/overlays/Modal.svelte +125 -130
  107. package/dist/components/overlays/Modal.svelte.d.ts +3 -1
  108. package/dist/components/overlays/Popover.svelte +106 -131
  109. package/dist/components/overlays/ProgressBar.svelte +29 -45
  110. package/dist/components/overlays/SkeletonLoader.svelte +66 -82
  111. package/dist/components/overlays/Spinner.svelte +33 -43
  112. package/dist/components/overlays/Toast.svelte +111 -140
  113. package/dist/components/overlays/Toast.svelte.d.ts +3 -0
  114. package/dist/components/overlays/Tooltip.svelte +94 -115
  115. package/dist/components/overlays/Tooltip.svelte.d.ts +3 -1
  116. package/dist/components/typography/Code.svelte +10 -14
  117. package/dist/components/typography/Heading.svelte +15 -22
  118. package/dist/components/typography/Heading.svelte.d.ts +1 -0
  119. package/dist/components/typography/Text.svelte +21 -24
  120. package/dist/components/typography/Text.svelte.d.ts +2 -1
  121. package/dist/components/utilities/Accordion.svelte +54 -67
  122. package/dist/components/utilities/Accordion.svelte.d.ts +4 -1
  123. package/dist/components/utilities/Carousel.svelte +124 -152
  124. package/dist/components/utilities/Collapse.svelte +46 -60
  125. package/dist/components/utilities/Hero.svelte +42 -0
  126. package/dist/components/utilities/Hero.svelte.d.ts +10 -0
  127. package/dist/components/utilities/Portal.svelte +47 -72
  128. package/dist/components/utilities/ScrollArea.svelte +33 -41
  129. package/dist/components/utilities/SystemConsole.svelte +310 -0
  130. package/dist/components/utilities/SystemConsole.svelte.d.ts +20 -0
  131. package/dist/components/utilities/SystemInterface.svelte +726 -0
  132. package/dist/components/utilities/SystemInterface.svelte.d.ts +19 -0
  133. package/dist/components/utilities/index.d.ts +4 -0
  134. package/dist/components/utilities/index.js +3 -0
  135. package/dist/components/utilities/utilities.types.d.ts +46 -0
  136. package/dist/components/utilities/utilities.types.js +4 -0
  137. package/dist/index.d.ts +57 -5
  138. package/dist/index.js +5 -5
  139. package/dist/theme.css +2889 -218
  140. package/package.json +83 -76
@@ -1,83 +1,133 @@
1
- <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
-
4
- interface Props {
5
- open?: boolean;
6
- x?: number;
7
- y?: number;
8
- trigger?: Snippet;
9
- children?: Snippet;
10
- }
11
-
12
- let {
13
- open = $bindable(false),
14
- x = $bindable(0),
15
- y = $bindable(0),
16
- trigger,
17
- children
18
- }: Props = $props();
19
-
20
- let menuElement = $state<HTMLDivElement>();
21
-
22
- function handleContextMenu(event: MouseEvent) {
23
- event.preventDefault();
24
- x = event.clientX;
25
- y = event.clientY;
26
- open = true;
27
-
28
- // Adjust position if menu would overflow viewport
29
- requestAnimationFrame(() => {
30
- if (menuElement) {
31
- const menuRect = menuElement.getBoundingClientRect();
32
- const viewportWidth = window.innerWidth;
33
- const viewportHeight = window.innerHeight;
34
-
35
- if (x + menuRect.width > viewportWidth) {
36
- x = viewportWidth - menuRect.width - 10;
37
- }
38
-
39
- if (y + menuRect.height > viewportHeight) {
40
- y = viewportHeight - menuRect.height - 10;
41
- }
42
- }
43
- });
44
- }
45
-
46
- function handleClickOutside(event: MouseEvent) {
47
- if (menuElement && !menuElement.contains(event.target as Node)) {
48
- open = false;
49
- }
50
- }
51
-
52
- function handleEscape(event: KeyboardEvent) {
53
- if (event.key === 'Escape') {
54
- open = false;
55
- }
56
- }
57
-
58
- $effect(() => {
59
- if (open) {
60
- document.addEventListener('click', handleClickOutside);
61
- document.addEventListener('keydown', handleEscape);
62
-
63
- return () => {
64
- document.removeEventListener('click', handleClickOutside);
65
- document.removeEventListener('keydown', handleEscape);
66
- };
67
- }
68
- });
69
- </script>
70
-
71
- <div oncontextmenu={handleContextMenu} role="presentation" class="contents">
72
- {@render trigger?.()}
73
- </div>
74
-
75
- {#if open}
76
- <div
77
- bind:this={menuElement}
78
- class="fixed z-50 glass-panel rounded-[var(--radius-lg)] shadow-[var(--shadow-deep)] min-w-[12rem] animate-[fade-in_0.15s_var(--ease-luxe)]"
79
- style="left: {x}px; top: {y}px;"
80
- >
81
- {@render children?.()}
82
- </div>
83
- {/if}
1
+ <script lang="ts">import { createId } from '../../lib/internal/id.js';
2
+ let { items, open = $bindable(false), x = $bindable(0), y = $bindable(0), trigger } = $props();
3
+ let menuElement = $state();
4
+ let selectedIndex = $state(0);
5
+ const menuId = createId('context-menu');
6
+ function handleContextMenu(event) {
7
+ event.preventDefault();
8
+ x = event.clientX;
9
+ y = event.clientY;
10
+ open = true;
11
+ selectedIndex = 0;
12
+ // Adjust position if menu would overflow viewport
13
+ requestAnimationFrame(() => {
14
+ if (menuElement) {
15
+ const menuRect = menuElement.getBoundingClientRect();
16
+ const viewportWidth = window.innerWidth;
17
+ const viewportHeight = window.innerHeight;
18
+ if (x + menuRect.width > viewportWidth) {
19
+ x = viewportWidth - menuRect.width - 10;
20
+ }
21
+ if (y + menuRect.height > viewportHeight) {
22
+ y = viewportHeight - menuRect.height - 10;
23
+ }
24
+ }
25
+ });
26
+ }
27
+ function handleItemClick(item) {
28
+ if (item.disabled)
29
+ return;
30
+ item.onclick?.();
31
+ open = false;
32
+ }
33
+ function handleMenuKeyDown(event) {
34
+ // Derive current index from focused element to stay in sync
35
+ const focusedElement = document.activeElement;
36
+ const dataIndex = focusedElement?.getAttribute('data-index');
37
+ if (dataIndex !== null) {
38
+ const currentFocusedIndex = parseInt(dataIndex || '0', 10);
39
+ if (!isNaN(currentFocusedIndex) && currentFocusedIndex >= 0 && currentFocusedIndex < items.length) {
40
+ selectedIndex = currentFocusedIndex;
41
+ }
42
+ }
43
+ const enabledItems = items.filter(item => !item.disabled);
44
+ const currentEnabledIndex = enabledItems.findIndex(item => item.id === items[selectedIndex]?.id);
45
+ let nextIndex = currentEnabledIndex;
46
+ switch (event.key) {
47
+ case 'ArrowDown':
48
+ event.preventDefault();
49
+ nextIndex = currentEnabledIndex < enabledItems.length - 1 ? currentEnabledIndex + 1 : 0;
50
+ selectedIndex = items.findIndex(item => item.id === enabledItems[nextIndex]?.id);
51
+ break;
52
+ case 'ArrowUp':
53
+ event.preventDefault();
54
+ nextIndex = currentEnabledIndex > 0 ? currentEnabledIndex - 1 : enabledItems.length - 1;
55
+ selectedIndex = items.findIndex(item => item.id === enabledItems[nextIndex]?.id);
56
+ break;
57
+ case 'Home':
58
+ event.preventDefault();
59
+ selectedIndex = items.findIndex(item => item.id === enabledItems[0]?.id);
60
+ break;
61
+ case 'End':
62
+ event.preventDefault();
63
+ selectedIndex = items.findIndex(item => item.id === enabledItems[enabledItems.length - 1]?.id);
64
+ break;
65
+ case 'Enter':
66
+ case ' ':
67
+ event.preventDefault();
68
+ if (items[selectedIndex] && !items[selectedIndex].disabled) {
69
+ handleItemClick(items[selectedIndex]);
70
+ }
71
+ break;
72
+ case 'Escape':
73
+ event.preventDefault();
74
+ open = false;
75
+ break;
76
+ }
77
+ }
78
+ function handleClickOutside(event) {
79
+ if (menuElement && !menuElement.contains(event.target)) {
80
+ open = false;
81
+ }
82
+ }
83
+ $effect(() => {
84
+ if (open) {
85
+ document.addEventListener('click', handleClickOutside);
86
+ requestAnimationFrame(() => {
87
+ const firstItem = menuElement?.querySelector('[role="menuitem"]');
88
+ if (firstItem) {
89
+ const dataIndex = firstItem.getAttribute('data-index');
90
+ if (dataIndex !== null) {
91
+ selectedIndex = parseInt(dataIndex, 10);
92
+ }
93
+ firstItem.focus();
94
+ }
95
+ });
96
+ return () => {
97
+ document.removeEventListener('click', handleClickOutside);
98
+ };
99
+ }
100
+ });
101
+ </script>
102
+
103
+ <div oncontextmenu={handleContextMenu} role="presentation" class="contents">
104
+ {@render trigger?.()}
105
+ </div>
106
+
107
+ {#if open}
108
+ <div
109
+ bind:this={menuElement}
110
+ id={menuId}
111
+ role="menu"
112
+ tabindex="-1"
113
+ onkeydown={handleMenuKeyDown}
114
+ class="fixed z-[var(--z-50)] glass-panel rounded-[var(--radius-lg)] shadow-[var(--shadow-deep)] min-w-[12rem] animate-[fade-in_0.15s_var(--ease-luxe)]"
115
+ style="left: {x}px; top: {y}px;"
116
+ >
117
+ {#each items as item, index}
118
+ <button
119
+ role="menuitem"
120
+ data-index={index}
121
+ tabindex={index === selectedIndex ? 0 : -1}
122
+ onclick={() => handleItemClick(item)}
123
+ disabled={item.disabled}
124
+ class="w-full text-left px-4 py-2 text-sm text-text hover:bg-base-3 transition-colors duration-200 flex items-center gap-2 {item.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
125
+ >
126
+ {#if item.icon}
127
+ {@render item.icon()}
128
+ {/if}
129
+ {item.label}
130
+ </button>
131
+ {/each}
132
+ </div>
133
+ {/if}
@@ -1,10 +1,17 @@
1
1
  import type { Snippet } from 'svelte';
2
+ interface MenuItem {
3
+ id: string;
4
+ label: string;
5
+ onclick?: () => void;
6
+ disabled?: boolean;
7
+ icon?: Snippet;
8
+ }
2
9
  interface Props {
10
+ items: MenuItem[];
3
11
  open?: boolean;
4
12
  x?: number;
5
13
  y?: number;
6
14
  trigger?: Snippet;
7
- children?: Snippet;
8
15
  }
9
16
  declare const ContextMenu: import("svelte").Component<Props, {}, "open" | "x" | "y">;
10
17
  type ContextMenu = ReturnType<typeof ContextMenu>;
@@ -1,80 +1,139 @@
1
- <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
-
4
- interface Props {
5
- open?: boolean;
6
- placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
7
- trigger?: Snippet;
8
- children?: Snippet;
9
- }
10
-
11
- let {
12
- open = $bindable(false),
13
- placement = 'bottom-start',
14
- trigger,
15
- children
16
- }: Props = $props();
17
-
18
- let menuElement: HTMLDivElement;
19
- let wrapperElement: HTMLDivElement;
20
-
21
- const placementClasses = {
22
- 'bottom-start': 'top-full left-0 mt-2',
23
- 'bottom-end': 'top-full right-0 mt-2',
24
- 'top-start': 'bottom-full left-0 mb-2',
25
- 'top-end': 'bottom-full right-0 mb-2'
26
- };
27
-
28
- function toggleMenu() {
29
- open = !open;
30
- }
31
-
32
- function handleClickOutside(event: MouseEvent) {
33
- if (menuElement && !wrapperElement.contains(event.target as Node)) {
34
- open = false;
35
- }
36
- }
37
-
38
- function handleEscape(event: KeyboardEvent) {
39
- if (event.key === 'Escape') {
40
- open = false;
41
- }
42
- }
43
-
44
- $effect(() => {
45
- if (open) {
46
- document.addEventListener('click', handleClickOutside);
47
- document.addEventListener('keydown', handleEscape);
48
-
49
- return () => {
50
- document.removeEventListener('click', handleClickOutside);
51
- document.removeEventListener('keydown', handleEscape);
52
- };
53
- }
54
- });
55
- </script>
56
-
57
- <div bind:this={wrapperElement} class="relative">
58
- <button
59
- type="button"
60
- onclick={toggleMenu}
61
- onkeydown={(e) => {
62
- if (e.key === 'Enter' || e.key === ' ') {
63
- e.preventDefault();
64
- toggleMenu();
65
- }
66
- }}
67
- class="bg-transparent border-none p-0 cursor-pointer"
68
- >
69
- {@render trigger?.()}
70
- </button>
71
-
72
- {#if open}
73
- <div
74
- bind:this={menuElement}
75
- class="absolute {placementClasses[placement]} z-50 glass-panel rounded-[var(--radius-lg)] shadow-[var(--shadow-deep)] min-w-[12rem] animate-[fade-up_0.2s_var(--ease-luxe)]"
76
- >
77
- {@render children?.()}
78
- </div>
79
- {/if}
80
- </div>
1
+ <script lang="ts">import { createId } from '../../lib/internal/id.js';
2
+ let { items, open = $bindable(false), placement = 'bottom-start', trigger } = $props();
3
+ let menuElement = $state();
4
+ let wrapperElement;
5
+ let selectedIndex = $state(0);
6
+ const menuId = createId('dropdown-menu');
7
+ const placementClasses = {
8
+ 'bottom-start': 'top-full left-0 mt-2',
9
+ 'bottom-end': 'top-full right-0 mt-2',
10
+ 'top-start': 'bottom-full left-0 mb-2',
11
+ 'top-end': 'bottom-full right-0 mb-2'
12
+ };
13
+ function toggleMenu() {
14
+ open = !open;
15
+ if (open) {
16
+ selectedIndex = 0;
17
+ }
18
+ }
19
+ function handleItemClick(item) {
20
+ if (item.disabled)
21
+ return;
22
+ item.onclick?.();
23
+ open = false;
24
+ }
25
+ function handleMenuKeyDown(event) {
26
+ // Derive current index from focused element to stay in sync
27
+ const focusedElement = document.activeElement;
28
+ const dataIndex = focusedElement?.getAttribute('data-index');
29
+ if (dataIndex !== null) {
30
+ const currentFocusedIndex = parseInt(dataIndex || '0', 10);
31
+ if (!isNaN(currentFocusedIndex) && currentFocusedIndex >= 0 && currentFocusedIndex < items.length) {
32
+ selectedIndex = currentFocusedIndex;
33
+ }
34
+ }
35
+ const enabledItems = items.filter(item => !item.disabled);
36
+ const currentEnabledIndex = enabledItems.findIndex(item => item.id === items[selectedIndex]?.id);
37
+ let nextIndex = currentEnabledIndex;
38
+ switch (event.key) {
39
+ case 'ArrowDown':
40
+ event.preventDefault();
41
+ nextIndex = currentEnabledIndex < enabledItems.length - 1 ? currentEnabledIndex + 1 : 0;
42
+ selectedIndex = items.findIndex(item => item.id === enabledItems[nextIndex]?.id);
43
+ break;
44
+ case 'ArrowUp':
45
+ event.preventDefault();
46
+ nextIndex = currentEnabledIndex > 0 ? currentEnabledIndex - 1 : enabledItems.length - 1;
47
+ selectedIndex = items.findIndex(item => item.id === enabledItems[nextIndex]?.id);
48
+ break;
49
+ case 'Home':
50
+ event.preventDefault();
51
+ selectedIndex = items.findIndex(item => item.id === enabledItems[0]?.id);
52
+ break;
53
+ case 'End':
54
+ event.preventDefault();
55
+ selectedIndex = items.findIndex(item => item.id === enabledItems[enabledItems.length - 1]?.id);
56
+ break;
57
+ case 'Enter':
58
+ case ' ':
59
+ event.preventDefault();
60
+ if (items[selectedIndex] && !items[selectedIndex].disabled) {
61
+ handleItemClick(items[selectedIndex]);
62
+ }
63
+ break;
64
+ case 'Escape':
65
+ event.preventDefault();
66
+ open = false;
67
+ break;
68
+ }
69
+ }
70
+ function handleClickOutside(event) {
71
+ if (menuElement && !wrapperElement.contains(event.target)) {
72
+ open = false;
73
+ }
74
+ }
75
+ $effect(() => {
76
+ if (open) {
77
+ document.addEventListener('click', handleClickOutside);
78
+ requestAnimationFrame(() => {
79
+ const firstItem = menuElement?.querySelector('[role="menuitem"]');
80
+ if (firstItem) {
81
+ const dataIndex = firstItem.getAttribute('data-index');
82
+ if (dataIndex !== null) {
83
+ selectedIndex = parseInt(dataIndex, 10);
84
+ }
85
+ firstItem.focus();
86
+ }
87
+ });
88
+ return () => {
89
+ document.removeEventListener('click', handleClickOutside);
90
+ };
91
+ }
92
+ });
93
+ </script>
94
+
95
+ <div bind:this={wrapperElement} class="relative">
96
+ <button
97
+ type="button"
98
+ onclick={toggleMenu}
99
+ onkeydown={(e) => {
100
+ if (e.key === 'Enter' || e.key === ' ') {
101
+ e.preventDefault();
102
+ toggleMenu();
103
+ }
104
+ }}
105
+ aria-haspopup="menu"
106
+ aria-expanded={open}
107
+ aria-controls={menuId}
108
+ class="bg-transparent border-none p-0 cursor-pointer"
109
+ >
110
+ {@render trigger?.()}
111
+ </button>
112
+
113
+ {#if open}
114
+ <div
115
+ bind:this={menuElement}
116
+ id={menuId}
117
+ role="menu"
118
+ tabindex="-1"
119
+ onkeydown={handleMenuKeyDown}
120
+ class="absolute {placementClasses[placement]} z-[var(--z-50)] glass-panel rounded-[var(--radius-lg)] shadow-[var(--shadow-deep)] min-w-[12rem] animate-[fade-up_0.2s_var(--ease-luxe)]"
121
+ >
122
+ {#each items as item, index}
123
+ <button
124
+ role="menuitem"
125
+ data-index={index}
126
+ tabindex={index === selectedIndex ? 0 : -1}
127
+ onclick={() => handleItemClick(item)}
128
+ disabled={item.disabled}
129
+ class="w-full text-left px-4 py-2 text-sm text-text hover:bg-base-3 transition-colors duration-200 flex items-center gap-2 {item.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
130
+ >
131
+ {#if item.icon}
132
+ {@render item.icon()}
133
+ {/if}
134
+ {item.label}
135
+ </button>
136
+ {/each}
137
+ </div>
138
+ {/if}
139
+ </div>
@@ -1,9 +1,16 @@
1
1
  import type { Snippet } from 'svelte';
2
+ interface MenuItem {
3
+ id: string;
4
+ label: string;
5
+ onclick?: () => void;
6
+ disabled?: boolean;
7
+ icon?: Snippet;
8
+ }
2
9
  interface Props {
10
+ items: MenuItem[];
3
11
  open?: boolean;
4
12
  placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
5
13
  trigger?: Snippet;
6
- children?: Snippet;
7
14
  }
8
15
  declare const DropdownMenu: import("svelte").Component<Props, {}, "open">;
9
16
  type DropdownMenu = ReturnType<typeof DropdownMenu>;
@@ -1,48 +1,72 @@
1
- <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
-
4
- interface MenuItem {
5
- id: string;
6
- label: string;
7
- icon?: Snippet;
8
- disabled?: boolean;
9
- onclick?: () => void;
10
- }
11
-
12
- interface Props {
13
- items?: MenuItem[];
14
- children?: Snippet;
15
- }
16
-
17
- let {
18
- items,
19
- children
20
- }: Props = $props();
21
-
22
- function handleItemClick(item: MenuItem) {
23
- if (!item.disabled && item.onclick) {
24
- item.onclick();
25
- }
26
- }
27
- </script>
28
-
29
- <div role="menu" class="glass-panel rounded-[var(--radius-lg)] p-2 shadow-[var(--shadow-deep)]">
30
- {#if children}
31
- {@render children()}
32
- {:else if items}
33
- {#each items as item}
34
- <button
35
- role="menuitem"
36
- aria-disabled={item.disabled}
37
- onclick={() => handleItemClick(item)}
38
- disabled={item.disabled}
39
- class="flex items-center gap-2 w-full px-3 py-2 rounded-[var(--radius-md)] text-[var(--color-text)] text-sm font-[var(--font-body)] hover:bg-[var(--color-base-3)] transition-all duration-200 ease-[var(--ease-luxe)] {item.disabled ? 'opacity-50 pointer-events-none' : 'cursor-pointer'}"
40
- >
41
- {#if item.icon}
42
- {@render item.icon()}
43
- {/if}
44
- <span>{item.label}</span>
45
- </button>
46
- {/each}
47
- {/if}
48
- </div>
1
+ <script lang="ts">let { items, children } = $props();
2
+ let selectedIndex = $state(0);
3
+ let menuContainer;
4
+ function handleItemClick(item) {
5
+ if (!item.disabled && item.onclick) {
6
+ item.onclick();
7
+ }
8
+ }
9
+ function handleMenuKeyDown(event) {
10
+ if (!items)
11
+ return;
12
+ const enabledItems = items.filter(item => !item.disabled);
13
+ const currentEnabledIndex = enabledItems.findIndex(item => item.id === items[selectedIndex]?.id);
14
+ let nextIndex = currentEnabledIndex;
15
+ switch (event.key) {
16
+ case 'ArrowDown':
17
+ event.preventDefault();
18
+ nextIndex = currentEnabledIndex < enabledItems.length - 1 ? currentEnabledIndex + 1 : 0;
19
+ selectedIndex = items.findIndex(item => item.id === enabledItems[nextIndex]?.id);
20
+ break;
21
+ case 'ArrowUp':
22
+ event.preventDefault();
23
+ nextIndex = currentEnabledIndex > 0 ? currentEnabledIndex - 1 : enabledItems.length - 1;
24
+ selectedIndex = items.findIndex(item => item.id === enabledItems[nextIndex]?.id);
25
+ break;
26
+ case 'Home':
27
+ event.preventDefault();
28
+ selectedIndex = items.findIndex(item => item.id === enabledItems[0]?.id);
29
+ break;
30
+ case 'End':
31
+ event.preventDefault();
32
+ selectedIndex = items.findIndex(item => item.id === enabledItems[enabledItems.length - 1]?.id);
33
+ break;
34
+ case 'Enter':
35
+ case ' ':
36
+ event.preventDefault();
37
+ if (items[selectedIndex] && !items[selectedIndex].disabled) {
38
+ handleItemClick(items[selectedIndex]);
39
+ }
40
+ break;
41
+ }
42
+ }
43
+ $effect(() => {
44
+ if (items && selectedIndex >= 0 && menuContainer) {
45
+ const selectedButton = menuContainer.querySelector(`[role="menuitem"][tabindex="0"]`);
46
+ selectedButton?.focus();
47
+ }
48
+ });
49
+ export {};
50
+ </script>
51
+
52
+ <div bind:this={menuContainer} role="menu" tabindex="-1" onkeydown={handleMenuKeyDown} class="glass-panel rounded-[var(--radius-lg)] p-3 shadow-[var(--shadow-deep)]">
53
+ {#if children}
54
+ {@render children()}
55
+ {:else if items}
56
+ {#each items as item, index}
57
+ <button
58
+ role="menuitem"
59
+ aria-disabled={item.disabled}
60
+ tabindex={index === selectedIndex ? 0 : -1}
61
+ onclick={() => handleItemClick(item)}
62
+ disabled={item.disabled}
63
+ class="flex items-center gap-2 w-full px-4 py-3 rounded-[var(--radius-md)] text-[var(--color-text)] text-sm font-[var(--font-body)] hover:bg-[var(--color-base-3)] hover-lift transition-all duration-200 ease-[var(--ease-luxe)] {item.disabled ? 'opacity-50 pointer-events-none' : 'cursor-pointer'}"
64
+ >
65
+ {#if item.icon}
66
+ {@render item.icon()}
67
+ {/if}
68
+ <span>{item.label}</span>
69
+ </button>
70
+ {/each}
71
+ {/if}
72
+ </div>