@furystack/shades-common-components 13.1.0 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/esm/components/app-bar.d.ts.map +1 -1
  3. package/esm/components/app-bar.js +12 -3
  4. package/esm/components/app-bar.js.map +1 -1
  5. package/esm/components/avatar.d.ts.map +1 -1
  6. package/esm/components/avatar.js +3 -5
  7. package/esm/components/avatar.js.map +1 -1
  8. package/esm/components/command-palette/command-palette-input.d.ts +1 -2
  9. package/esm/components/command-palette/command-palette-input.d.ts.map +1 -1
  10. package/esm/components/command-palette/command-palette-input.js +14 -36
  11. package/esm/components/command-palette/command-palette-input.js.map +1 -1
  12. package/esm/components/command-palette/command-palette-input.spec.js +14 -116
  13. package/esm/components/command-palette/command-palette-input.spec.js.map +1 -1
  14. package/esm/components/command-palette/index.d.ts.map +1 -1
  15. package/esm/components/command-palette/index.js +3 -0
  16. package/esm/components/command-palette/index.js.map +1 -1
  17. package/esm/components/drawer/index.d.ts.map +1 -1
  18. package/esm/components/drawer/index.js +4 -0
  19. package/esm/components/drawer/index.js.map +1 -1
  20. package/esm/components/drawer/index.spec.js +47 -0
  21. package/esm/components/drawer/index.spec.js.map +1 -1
  22. package/esm/components/noty-list.d.ts.map +1 -1
  23. package/esm/components/noty-list.js +1 -3
  24. package/esm/components/noty-list.js.map +1 -1
  25. package/esm/services/collection-service.d.ts +8 -4
  26. package/esm/services/collection-service.d.ts.map +1 -1
  27. package/esm/services/collection-service.js +12 -4
  28. package/esm/services/collection-service.js.map +1 -1
  29. package/esm/services/collection-service.spec.js +37 -0
  30. package/esm/services/collection-service.spec.js.map +1 -1
  31. package/esm/services/css-variable-theme.d.ts +1 -1
  32. package/esm/services/css-variable-theme.d.ts.map +1 -1
  33. package/esm/services/css-variable-theme.js +5 -5
  34. package/esm/services/css-variable-theme.js.map +1 -1
  35. package/esm/services/css-variable-theme.spec.js +29 -1
  36. package/esm/services/css-variable-theme.spec.js.map +1 -1
  37. package/esm/services/layout-service.d.ts +8 -0
  38. package/esm/services/layout-service.d.ts.map +1 -1
  39. package/esm/services/layout-service.js +16 -0
  40. package/esm/services/layout-service.js.map +1 -1
  41. package/esm/services/layout-service.spec.js +55 -0
  42. package/esm/services/layout-service.spec.js.map +1 -1
  43. package/esm/services/theme-provider-service.d.ts +11 -10
  44. package/esm/services/theme-provider-service.d.ts.map +1 -1
  45. package/esm/services/theme-provider-service.js +3 -2
  46. package/esm/services/theme-provider-service.js.map +1 -1
  47. package/esm/services/theme-provider-service.spec.js +35 -1
  48. package/esm/services/theme-provider-service.spec.js.map +1 -1
  49. package/esm/themes/architect-theme.d.ts +1 -0
  50. package/esm/themes/architect-theme.d.ts.map +1 -1
  51. package/esm/themes/architect-theme.js +1 -0
  52. package/esm/themes/architect-theme.js.map +1 -1
  53. package/esm/themes/auditore-theme.d.ts +1 -0
  54. package/esm/themes/auditore-theme.d.ts.map +1 -1
  55. package/esm/themes/auditore-theme.js +1 -0
  56. package/esm/themes/auditore-theme.js.map +1 -1
  57. package/esm/themes/black-mesa-theme.d.ts +1 -0
  58. package/esm/themes/black-mesa-theme.d.ts.map +1 -1
  59. package/esm/themes/black-mesa-theme.js +1 -0
  60. package/esm/themes/black-mesa-theme.js.map +1 -1
  61. package/esm/themes/default-dark-theme.d.ts +1 -0
  62. package/esm/themes/default-dark-theme.d.ts.map +1 -1
  63. package/esm/themes/default-dark-theme.js +1 -0
  64. package/esm/themes/default-dark-theme.js.map +1 -1
  65. package/esm/themes/default-light-theme.d.ts +1 -0
  66. package/esm/themes/default-light-theme.d.ts.map +1 -1
  67. package/esm/themes/default-light-theme.js +1 -0
  68. package/esm/themes/default-light-theme.js.map +1 -1
  69. package/esm/themes/dragonborn-theme.d.ts +1 -0
  70. package/esm/themes/dragonborn-theme.d.ts.map +1 -1
  71. package/esm/themes/dragonborn-theme.js +1 -0
  72. package/esm/themes/dragonborn-theme.js.map +1 -1
  73. package/esm/themes/hawkins-theme.d.ts +1 -0
  74. package/esm/themes/hawkins-theme.d.ts.map +1 -1
  75. package/esm/themes/hawkins-theme.js +1 -0
  76. package/esm/themes/hawkins-theme.js.map +1 -1
  77. package/esm/themes/jedi-theme.d.ts +1 -0
  78. package/esm/themes/jedi-theme.d.ts.map +1 -1
  79. package/esm/themes/jedi-theme.js +1 -0
  80. package/esm/themes/jedi-theme.js.map +1 -1
  81. package/esm/themes/neon-runner-theme.d.ts +1 -0
  82. package/esm/themes/neon-runner-theme.d.ts.map +1 -1
  83. package/esm/themes/neon-runner-theme.js +1 -0
  84. package/esm/themes/neon-runner-theme.js.map +1 -1
  85. package/esm/themes/plumber-theme.d.ts +1 -0
  86. package/esm/themes/plumber-theme.d.ts.map +1 -1
  87. package/esm/themes/plumber-theme.js +1 -0
  88. package/esm/themes/plumber-theme.js.map +1 -1
  89. package/esm/themes/replicant-theme.d.ts +1 -0
  90. package/esm/themes/replicant-theme.d.ts.map +1 -1
  91. package/esm/themes/replicant-theme.js +1 -0
  92. package/esm/themes/replicant-theme.js.map +1 -1
  93. package/esm/themes/sandworm-theme.d.ts +1 -0
  94. package/esm/themes/sandworm-theme.d.ts.map +1 -1
  95. package/esm/themes/sandworm-theme.js +1 -0
  96. package/esm/themes/sandworm-theme.js.map +1 -1
  97. package/esm/themes/shadow-broker-theme.d.ts +1 -0
  98. package/esm/themes/shadow-broker-theme.d.ts.map +1 -1
  99. package/esm/themes/shadow-broker-theme.js +1 -0
  100. package/esm/themes/shadow-broker-theme.js.map +1 -1
  101. package/esm/themes/sith-theme.d.ts +1 -0
  102. package/esm/themes/sith-theme.d.ts.map +1 -1
  103. package/esm/themes/sith-theme.js +1 -0
  104. package/esm/themes/sith-theme.js.map +1 -1
  105. package/esm/themes/vault-dweller-theme.d.ts +1 -0
  106. package/esm/themes/vault-dweller-theme.d.ts.map +1 -1
  107. package/esm/themes/vault-dweller-theme.js +1 -0
  108. package/esm/themes/vault-dweller-theme.js.map +1 -1
  109. package/esm/themes/wild-hunt-theme.d.ts +1 -0
  110. package/esm/themes/wild-hunt-theme.d.ts.map +1 -1
  111. package/esm/themes/wild-hunt-theme.js +1 -0
  112. package/esm/themes/wild-hunt-theme.js.map +1 -1
  113. package/esm/themes/xenomorph-theme.d.ts +1 -0
  114. package/esm/themes/xenomorph-theme.d.ts.map +1 -1
  115. package/esm/themes/xenomorph-theme.js +1 -0
  116. package/esm/themes/xenomorph-theme.js.map +1 -1
  117. package/package.json +6 -6
  118. package/src/components/app-bar.tsx +12 -3
  119. package/src/components/avatar.tsx +20 -5
  120. package/src/components/command-palette/command-palette-input.spec.tsx +14 -156
  121. package/src/components/command-palette/command-palette-input.tsx +13 -45
  122. package/src/components/command-palette/index.tsx +4 -0
  123. package/src/components/drawer/index.spec.tsx +64 -0
  124. package/src/components/drawer/index.tsx +5 -0
  125. package/src/components/noty-list.tsx +1 -3
  126. package/src/services/collection-service.spec.ts +46 -0
  127. package/src/services/collection-service.ts +23 -8
  128. package/src/services/css-variable-theme.spec.ts +43 -1
  129. package/src/services/css-variable-theme.ts +5 -5
  130. package/src/services/layout-service.spec.ts +74 -0
  131. package/src/services/layout-service.ts +18 -0
  132. package/src/services/theme-provider-service.spec.ts +49 -1
  133. package/src/services/theme-provider-service.ts +12 -11
  134. package/src/themes/architect-theme.ts +1 -0
  135. package/src/themes/auditore-theme.ts +1 -0
  136. package/src/themes/black-mesa-theme.ts +1 -0
  137. package/src/themes/default-dark-theme.ts +1 -0
  138. package/src/themes/default-light-theme.ts +1 -0
  139. package/src/themes/dragonborn-theme.ts +1 -0
  140. package/src/themes/hawkins-theme.ts +1 -0
  141. package/src/themes/jedi-theme.ts +1 -0
  142. package/src/themes/neon-runner-theme.ts +1 -0
  143. package/src/themes/plumber-theme.ts +1 -0
  144. package/src/themes/replicant-theme.ts +1 -0
  145. package/src/themes/sandworm-theme.ts +1 -0
  146. package/src/themes/shadow-broker-theme.ts +1 -0
  147. package/src/themes/sith-theme.ts +1 -0
  148. package/src/themes/vault-dweller-theme.ts +1 -0
  149. package/src/themes/wild-hunt-theme.ts +1 -0
  150. package/src/themes/xenomorph-theme.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@furystack/shades-common-components",
3
- "version": "13.1.0",
3
+ "version": "13.3.0",
4
4
  "description": "Common UI components for FuryStack Shades",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -48,11 +48,11 @@
48
48
  "vitest": "^4.0.18"
49
49
  },
50
50
  "dependencies": {
51
- "@furystack/cache": "^6.0.0",
52
- "@furystack/core": "^15.2.2",
53
- "@furystack/inject": "^12.0.30",
54
- "@furystack/shades": "^12.2.4",
55
- "@furystack/utils": "^8.1.10",
51
+ "@furystack/cache": "^6.1.0",
52
+ "@furystack/core": "^15.2.3",
53
+ "@furystack/inject": "^12.0.31",
54
+ "@furystack/shades": "^12.2.5",
55
+ "@furystack/utils": "^8.2.0",
56
56
  "path-to-regexp": "^8.3.0"
57
57
  },
58
58
  "engines": {
@@ -5,9 +5,6 @@ export const AppBar = Shade({
5
5
  shadowDomName: 'shade-app-bar',
6
6
  css: {
7
7
  width: '100%',
8
- background: `color-mix(in srgb, ${cssVariableTheme.background.paper} 85%, transparent)`,
9
- backgroundImage: cssVariableTheme.background.paperImage,
10
- backdropFilter: `blur(${cssVariableTheme.effects.blurLg})`,
11
8
  display: 'flex',
12
9
  justifyContent: 'flex-start',
13
10
  alignItems: 'center',
@@ -18,6 +15,18 @@ export const AppBar = Shade({
18
15
  zIndex: '1',
19
16
  fontFamily: cssVariableTheme.typography.fontFamily,
20
17
  color: cssVariableTheme.text.primary,
18
+ // backdrop-filter on the host would create a containing block for position:fixed
19
+ // descendants (per CSS spec), breaking Dropdown overlays inside the AppBar.
20
+ // Using a pseudo-element avoids this while preserving the visual effect.
21
+ '&::before': {
22
+ content: '""',
23
+ position: 'absolute',
24
+ inset: '0',
25
+ zIndex: '-1',
26
+ background: `color-mix(in srgb, ${cssVariableTheme.background.paper} 85%, transparent)`,
27
+ backgroundImage: cssVariableTheme.background.paperImage,
28
+ backdropFilter: `blur(${cssVariableTheme.effects.blurLg})`,
29
+ },
21
30
  '&[data-visible]': {
22
31
  opacity: '1',
23
32
  },
@@ -1,7 +1,6 @@
1
1
  import type { PartialElement } from '@furystack/shades'
2
2
  import { Shade, createComponent } from '@furystack/shades'
3
3
  import { cssVariableTheme } from '../services/css-variable-theme.js'
4
- import { Icon } from './icons/icon.js'
5
4
  import { user as userIcon } from './icons/icon-definitions.js'
6
5
 
7
6
  export type AvatarProps = { avatarUrl: string; fallback?: JSX.Element } & PartialElement<HTMLDivElement>
@@ -37,14 +36,13 @@ export const Avatar = Shade<AvatarProps>({
37
36
  display: 'flex',
38
37
  alignItems: 'center',
39
38
  justifyContent: 'center',
40
- width: '64px',
41
- height: '64px',
39
+ width: '100%',
40
+ height: '100%',
42
41
  borderRadius: cssVariableTheme.shape.borderRadius.full,
43
42
  background: `color-mix(in srgb, ${cssVariableTheme.palette.primary.main} 20%, transparent)`,
44
43
  backdropFilter: 'blur(10px)',
45
44
  textAlign: 'center',
46
45
  userSelect: 'none',
47
- fontSize: '48px',
48
46
  lineHeight: '1',
49
47
  },
50
48
  },
@@ -59,7 +57,24 @@ export const Avatar = Shade<AvatarProps>({
59
57
  if (hasError) {
60
58
  return (
61
59
  <div className="avatar-fallback-container">
62
- <div className="avatar-fallback-icon">{props.fallback || <Icon icon={userIcon} size={48} />}</div>
60
+ <div className="avatar-fallback-icon">
61
+ {props.fallback || (
62
+ <svg
63
+ width="100%"
64
+ height="100%"
65
+ viewBox={userIcon.viewBox ?? '0 0 24 24'}
66
+ fill="none"
67
+ stroke="currentColor"
68
+ stroke-width="2"
69
+ stroke-linecap="round"
70
+ stroke-linejoin="round"
71
+ >
72
+ {userIcon.paths.map((p) => (
73
+ <path d={p.d} />
74
+ ))}
75
+ </svg>
76
+ )}
77
+ </div>
63
78
  </div>
64
79
  )
65
80
  }
@@ -6,42 +6,14 @@ import { CommandPaletteInput } from './command-palette-input.js'
6
6
  import { CommandPaletteManager } from './command-palette-manager.js'
7
7
 
8
8
  describe('CommandPaletteInput', () => {
9
- let originalAnimate: typeof Element.prototype.animate
10
- let animateCalls: Array<{ keyframes: unknown; options: unknown }>
11
-
12
9
  beforeEach(() => {
13
10
  vi.useFakeTimers()
14
11
  document.body.innerHTML = '<div id="root"></div>'
15
- animateCalls = []
16
- originalAnimate = Element.prototype.animate
17
-
18
- Element.prototype.animate = vi.fn(
19
- (keyframes: Keyframe[] | PropertyIndexedKeyframes | null, options?: number | KeyframeAnimationOptions) => {
20
- animateCalls.push({ keyframes, options })
21
- const mockAnimation = {
22
- onfinish: null as ((event: AnimationPlaybackEvent) => void) | null,
23
- oncancel: null as ((event: AnimationPlaybackEvent) => void) | null,
24
- cancel: vi.fn(),
25
- play: vi.fn(),
26
- pause: vi.fn(),
27
- finish: vi.fn(),
28
- addEventListener: vi.fn(),
29
- removeEventListener: vi.fn(),
30
- }
31
-
32
- setTimeout(() => {
33
- mockAnimation.onfinish?.({} as AnimationPlaybackEvent)
34
- }, 10)
35
-
36
- return mockAnimation as unknown as Animation
37
- },
38
- ) as typeof Element.prototype.animate
39
12
  })
40
13
 
41
14
  afterEach(() => {
42
15
  vi.useRealTimers()
43
16
  document.body.innerHTML = ''
44
- Element.prototype.animate = originalAnimate
45
17
  vi.restoreAllMocks()
46
18
  })
47
19
 
@@ -89,7 +61,7 @@ describe('CommandPaletteInput', () => {
89
61
  })
90
62
  })
91
63
 
92
- it('should start with width 0% when closed', async () => {
64
+ it('should always have width 100%', async () => {
93
65
  await usingAsync(new Injector(), async (injector) => {
94
66
  await usingAsync(createManager(), async (manager) => {
95
67
  manager.isOpened.setValue(false)
@@ -104,15 +76,15 @@ describe('CommandPaletteInput', () => {
104
76
  await flushUpdates()
105
77
 
106
78
  const component = document.querySelector('shades-command-palette-input') as HTMLElement
107
- expect(component.hasAttribute('data-opened')).toBe(false)
79
+ const computedStyle = window.getComputedStyle(component)
80
+ expect(computedStyle.width).toBe('100%')
108
81
  })
109
82
  })
110
83
  })
111
84
 
112
- it('should have width 100% when opened', async () => {
85
+ it('should have overflow hidden style', async () => {
113
86
  await usingAsync(new Injector(), async (injector) => {
114
87
  await usingAsync(createManager(), async (manager) => {
115
- manager.isOpened.setValue(true)
116
88
  const rootElement = document.getElementById('root') as HTMLDivElement
117
89
 
118
90
  initializeShadeRoot({
@@ -124,73 +96,13 @@ describe('CommandPaletteInput', () => {
124
96
  await flushUpdates()
125
97
 
126
98
  const component = document.querySelector('shades-command-palette-input') as HTMLElement
127
- expect(component.hasAttribute('data-opened')).toBe(true)
128
- })
129
- })
130
- })
131
-
132
- it('should animate width when opening', async () => {
133
- await usingAsync(new Injector(), async (injector) => {
134
- await usingAsync(createManager(), async (manager) => {
135
- manager.isOpened.setValue(false)
136
- const rootElement = document.getElementById('root') as HTMLDivElement
137
-
138
- initializeShadeRoot({
139
- injector,
140
- rootElement,
141
- jsxElement: <CommandPaletteInput manager={manager} />,
142
- })
143
-
144
- await flushUpdates()
145
- animateCalls = []
146
-
147
- manager.isOpened.setValue(true)
148
- await flushUpdates()
149
-
150
- const widthAnimation = animateCalls.find(
151
- (call) =>
152
- Array.isArray(call.keyframes) &&
153
- call.keyframes.some((kf: Keyframe) => kf.width === '0%') &&
154
- call.keyframes.some((kf: Keyframe) => kf.width === '100%'),
155
- )
156
-
157
- expect(widthAnimation).toBeDefined()
158
- expect((widthAnimation?.options as KeyframeAnimationOptions)?.duration).toBe(300)
159
- })
160
- })
161
- })
162
-
163
- it('should animate width when closing', async () => {
164
- await usingAsync(new Injector(), async (injector) => {
165
- await usingAsync(createManager(), async (manager) => {
166
- manager.isOpened.setValue(true)
167
- const rootElement = document.getElementById('root') as HTMLDivElement
168
-
169
- initializeShadeRoot({
170
- injector,
171
- rootElement,
172
- jsxElement: <CommandPaletteInput manager={manager} />,
173
- })
174
-
175
- await flushUpdates()
176
- animateCalls = []
177
-
178
- manager.isOpened.setValue(false)
179
- await flushUpdates()
180
-
181
- const widthAnimation = animateCalls.find(
182
- (call) =>
183
- Array.isArray(call.keyframes) &&
184
- call.keyframes.some((kf: Keyframe) => kf.width === '100%') &&
185
- call.keyframes.some((kf: Keyframe) => kf.width === '0%'),
186
- )
187
-
188
- expect(widthAnimation).toBeDefined()
99
+ const computedStyle = window.getComputedStyle(component)
100
+ expect(computedStyle.overflow).toBe('hidden')
189
101
  })
190
102
  })
191
103
  })
192
104
 
193
- it('should clear input value when opening', async () => {
105
+ it('should focus input when opened', async () => {
194
106
  await usingAsync(new Injector(), async (injector) => {
195
107
  await usingAsync(createManager(), async (manager) => {
196
108
  manager.isOpened.setValue(false)
@@ -206,12 +118,12 @@ describe('CommandPaletteInput', () => {
206
118
 
207
119
  const component = document.querySelector('shades-command-palette-input') as HTMLElement
208
120
  const inputElement = component?.querySelector('input') as HTMLInputElement
209
- inputElement.value = 'some text'
121
+ const focusSpy = vi.spyOn(inputElement, 'focus')
210
122
 
211
123
  manager.isOpened.setValue(true)
212
124
  await flushUpdates()
213
125
 
214
- expect(inputElement.value).toBe('')
126
+ expect(focusSpy).toHaveBeenCalled()
215
127
  })
216
128
  })
217
129
  })
@@ -236,35 +148,13 @@ describe('CommandPaletteInput', () => {
236
148
 
237
149
  manager.isOpened.setValue(false)
238
150
  await flushUpdates()
239
- await vi.advanceTimersByTimeAsync(20)
240
- await flushUpdates()
241
151
 
242
152
  expect(inputElement.value).toBe('')
243
153
  })
244
154
  })
245
155
  })
246
156
 
247
- it('should have overflow hidden style', async () => {
248
- await usingAsync(new Injector(), async (injector) => {
249
- await usingAsync(createManager(), async (manager) => {
250
- const rootElement = document.getElementById('root') as HTMLDivElement
251
-
252
- initializeShadeRoot({
253
- injector,
254
- rootElement,
255
- jsxElement: <CommandPaletteInput manager={manager} />,
256
- })
257
-
258
- await flushUpdates()
259
-
260
- const component = document.querySelector('shades-command-palette-input') as HTMLElement
261
- const computedStyle = window.getComputedStyle(component)
262
- expect(computedStyle.overflow).toBe('hidden')
263
- })
264
- })
265
- })
266
-
267
- it('should use cubic-bezier easing for animations', async () => {
157
+ it('should preserve input value when opening', async () => {
268
158
  await usingAsync(new Injector(), async (injector) => {
269
159
  await usingAsync(createManager(), async (manager) => {
270
160
  manager.isOpened.setValue(false)
@@ -277,47 +167,15 @@ describe('CommandPaletteInput', () => {
277
167
  })
278
168
 
279
169
  await flushUpdates()
280
- animateCalls = []
281
170
 
282
- manager.isOpened.setValue(true)
283
- await flushUpdates()
284
-
285
- const widthAnimation = animateCalls.find(
286
- (call) => Array.isArray(call.keyframes) && call.keyframes.some((kf: Keyframe) => 'width' in kf),
287
- )
288
-
289
- expect(widthAnimation).toBeDefined()
290
- expect((widthAnimation?.options as KeyframeAnimationOptions)?.easing).toBe(
291
- 'cubic-bezier(0.595, 0.425, 0.415, 0.845)',
292
- )
293
- })
294
- })
295
- })
296
-
297
- it('should fill animation forwards', async () => {
298
- await usingAsync(new Injector(), async (injector) => {
299
- await usingAsync(createManager(), async (manager) => {
300
- manager.isOpened.setValue(false)
301
- const rootElement = document.getElementById('root') as HTMLDivElement
302
-
303
- initializeShadeRoot({
304
- injector,
305
- rootElement,
306
- jsxElement: <CommandPaletteInput manager={manager} />,
307
- })
308
-
309
- await flushUpdates()
310
- animateCalls = []
171
+ const component = document.querySelector('shades-command-palette-input') as HTMLElement
172
+ const inputElement = component?.querySelector('input') as HTMLInputElement
173
+ inputElement.value = 'some text'
311
174
 
312
175
  manager.isOpened.setValue(true)
313
176
  await flushUpdates()
314
177
 
315
- const widthAnimation = animateCalls.find(
316
- (call) => Array.isArray(call.keyframes) && call.keyframes.some((kf: Keyframe) => 'width' in kf),
317
- )
318
-
319
- expect(widthAnimation).toBeDefined()
320
- expect((widthAnimation?.options as KeyframeAnimationOptions)?.fill).toBe('forwards')
178
+ expect(inputElement.value).toBe('some text')
321
179
  })
322
180
  })
323
181
  })
@@ -1,45 +1,13 @@
1
- import type { RefObject } from '@furystack/shades'
2
1
  import { Shade, createComponent } from '@furystack/shades'
3
2
  import { cssVariableTheme } from '../../services/css-variable-theme.js'
4
- import { promisifyAnimation } from '../../utils/promisify-animation.js'
5
3
  import type { CommandPaletteManager } from './command-palette-manager.js'
6
4
 
7
- const animateOpenState = async (
8
- wrapperRef: RefObject<HTMLDivElement>,
9
- inputRef: RefObject<HTMLInputElement>,
10
- isOpened: boolean,
11
- ) => {
12
- const wrapper = wrapperRef.current
13
- const input = inputRef.current
14
- if (wrapper && input) {
15
- if (isOpened) {
16
- input.value = ''
17
- await promisifyAnimation(wrapper, [{ width: '0%' }, { width: '100%' }], {
18
- duration: 300,
19
- fill: 'forwards',
20
- easing: 'cubic-bezier(0.595, 0.425, 0.415, 0.845)',
21
- })
22
- input.focus()
23
- } else {
24
- await promisifyAnimation(wrapper, [{ width: '100%' }, { width: '0%' }], {
25
- duration: 300,
26
- fill: 'forwards',
27
- easing: 'cubic-bezier(0.595, 0.425, 0.415, 0.845)',
28
- })
29
- input.value = ''
30
- }
31
- }
32
- }
33
-
34
5
  export const CommandPaletteInput = Shade<{ manager: CommandPaletteManager }>({
35
6
  shadowDomName: 'shades-command-palette-input',
36
7
  css: {
37
- width: '0%',
8
+ width: '100%',
38
9
  fontFamily: cssVariableTheme.typography.fontFamily,
39
10
  overflow: 'hidden',
40
- '&[data-opened]': {
41
- width: '100%',
42
- },
43
11
  '& input': {
44
12
  color: cssVariableTheme.text.primary,
45
13
  outline: 'none',
@@ -53,20 +21,20 @@ export const CommandPaletteInput = Shade<{ manager: CommandPaletteManager }>({
53
21
  letterSpacing: '0.01em',
54
22
  },
55
23
  },
56
- render: ({ props, useObservable, useRef, useHostProps }) => {
57
- const { manager } = props
58
- const wrapperRef = useRef<HTMLDivElement>('wrapper')
24
+ render: ({ props, useObservable, useRef }) => {
59
25
  const inputRef = useRef<HTMLInputElement>('input')
60
-
61
- const [isCurrentlyOpened] = useObservable('isOpened', manager.isOpened, {
62
- onChange: (newValue) => void animateOpenState(wrapperRef, inputRef, newValue),
26
+ useObservable('isOpened', props.manager.isOpened, {
27
+ onChange: (isOpened) => {
28
+ if (inputRef.current) {
29
+ if (isOpened) {
30
+ inputRef.current.focus()
31
+ } else {
32
+ inputRef.current.value = ''
33
+ }
34
+ }
35
+ },
63
36
  })
64
- useHostProps({ ...(isCurrentlyOpened ? { 'data-opened': '' } : {}) })
65
37
 
66
- return (
67
- <div ref={wrapperRef} style={{ width: isCurrentlyOpened ? '100%' : '0%', overflow: 'hidden' }}>
68
- <input ref={inputRef} autofocus placeholder="Type to search commands..." />
69
- </div>
70
- )
38
+ return <input ref={inputRef} autofocus placeholder="Type to search commands..." />
71
39
  },
72
40
  })
@@ -85,6 +85,10 @@ export const CommandPalette = Shade<CommandPaletteProps>({
85
85
  )
86
86
  }
87
87
 
88
+ if (!manager.isOpened.getValue()) {
89
+ manager.isOpened.setValue(true)
90
+ }
91
+
88
92
  void manager.getSuggestion({ injector, term: (ev.target as HTMLInputElement).value })
89
93
  }}
90
94
  >
@@ -718,6 +718,70 @@ describe('Drawer component', () => {
718
718
  })
719
719
  })
720
720
 
721
+ describe('cleanup on disposal', () => {
722
+ it('should call removeDrawer on LayoutService when the component is removed from DOM', async () => {
723
+ await usingAsync(new Injector(), async (injector) => {
724
+ const layoutService = new LayoutService(createMockElement())
725
+ injector.setExplicitInstance(layoutService, LayoutService)
726
+ const rootElement = document.getElementById('root') as HTMLDivElement
727
+
728
+ initializeShadeRoot({
729
+ injector,
730
+ rootElement,
731
+ jsxElement: (
732
+ <Drawer position="left" variant="collapsible">
733
+ <div>Drawer</div>
734
+ </Drawer>
735
+ ),
736
+ })
737
+
738
+ await flushUpdates()
739
+ expect(layoutService.drawerState.getValue().left).toBeDefined()
740
+
741
+ const removeDrawerSpy = vi.spyOn(layoutService, 'removeDrawer')
742
+ const drawer = document.querySelector('shade-drawer') as HTMLElement
743
+ drawer.remove()
744
+ await flushUpdates()
745
+ await new Promise((resolve) => setTimeout(resolve, 0))
746
+
747
+ expect(removeDrawerSpy).toHaveBeenCalledWith('left')
748
+ })
749
+ })
750
+
751
+ it('should only clean up its own drawer position on disposal', async () => {
752
+ await usingAsync(new Injector(), async (injector) => {
753
+ const layoutService = new LayoutService(createMockElement())
754
+ injector.setExplicitInstance(layoutService, LayoutService)
755
+ const rootElement = document.getElementById('root') as HTMLDivElement
756
+
757
+ layoutService.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' })
758
+
759
+ initializeShadeRoot({
760
+ injector,
761
+ rootElement,
762
+ jsxElement: (
763
+ <Drawer position="right" variant="temporary">
764
+ <div>Right Drawer</div>
765
+ </Drawer>
766
+ ),
767
+ })
768
+
769
+ await flushUpdates()
770
+ expect(layoutService.drawerState.getValue().right).toBeDefined()
771
+ expect(layoutService.drawerState.getValue().left).toBeDefined()
772
+
773
+ const removeDrawerSpy = vi.spyOn(layoutService, 'removeDrawer')
774
+ const drawer = document.querySelector('shade-drawer') as HTMLElement
775
+ drawer.remove()
776
+ await flushUpdates()
777
+ await new Promise((resolve) => setTimeout(resolve, 0))
778
+
779
+ expect(removeDrawerSpy).toHaveBeenCalledWith('right')
780
+ expect(removeDrawerSpy).not.toHaveBeenCalledWith('left')
781
+ })
782
+ })
783
+ })
784
+
721
785
  describe('preserving user interactions', () => {
722
786
  it('should not reset drawer state if already initialized', async () => {
723
787
  await usingAsync(new Injector(), async (injector) => {
@@ -143,6 +143,11 @@ export const Drawer = Shade<DrawerProps>({
143
143
  layoutService.setDrawerWidth(position, width)
144
144
  }
145
145
 
146
+ // Clean up drawer state from LayoutService when this component is disposed
147
+ useDisposable('drawer-cleanup', () => ({
148
+ [Symbol.dispose]: () => layoutService.removeDrawer(position),
149
+ }))
150
+
146
151
  // Subscribe to drawer state
147
152
  const [drawerState] = useObservable('drawerState', layoutService.drawerState)
148
153
  const isOpen = drawerState[position]?.open ?? false
@@ -1,6 +1,5 @@
1
1
  import { createComponent, Shade } from '@furystack/shades'
2
2
  import { cssVariableTheme } from '../services/css-variable-theme.js'
3
- import { getTextColor } from '../services/get-text-color.js'
4
3
  import type { NotyModel } from '../services/noty-service.js'
5
4
  import { NotyService } from '../services/noty-service.js'
6
5
  import { ThemeProviderService } from '../services/theme-provider-service.js'
@@ -97,7 +96,6 @@ export const NotyComponent = Shade<{ model: NotyModel; onDismiss: () => void }>(
97
96
 
98
97
  const themeProvider = injector.getInstance(ThemeProviderService)
99
98
  const colors = themeProvider.theme.palette[props.model.type]
100
- const textColor = getTextColor(colors.main)
101
99
 
102
100
  const removeSelf = async () => {
103
101
  const hostEl = wrapperRef.current?.closest('shade-noty') as HTMLElement | null
@@ -123,7 +121,7 @@ export const NotyComponent = Shade<{ model: NotyModel; onDismiss: () => void }>(
123
121
 
124
122
  useHostProps({
125
123
  'data-noty-type': props.model.type,
126
- style: { '--noty-bg': colors.main, '--noty-text': textColor },
124
+ style: { '--noty-bg': colors.main, '--noty-text': colors.mainContrast },
127
125
  })
128
126
 
129
127
  return (
@@ -517,4 +517,50 @@ describe('CollectionService', () => {
517
517
  })
518
518
  })
519
519
  })
520
+
521
+ describe('EventHub integration', () => {
522
+ it('Should allow subscribing to onRowClick via EventHub', () => {
523
+ const testEntries = createTestEntries()
524
+ const handler = vi.fn()
525
+
526
+ using(new CollectionService<TestEntry>(), (service) => {
527
+ service.addListener('onRowClick', handler)
528
+ service.data.setValue({ count: 3, entries: testEntries })
529
+ service.handleRowClick(testEntries[1], createMouseEvent())
530
+
531
+ expect(handler).toHaveBeenCalledTimes(1)
532
+ expect(handler).toHaveBeenCalledWith(testEntries[1])
533
+ })
534
+ })
535
+
536
+ it('Should allow subscribing to onRowDoubleClick via EventHub', () => {
537
+ const testEntries = createTestEntries()
538
+ const handler = vi.fn()
539
+
540
+ using(new CollectionService<TestEntry>(), (service) => {
541
+ service.addListener('onRowDoubleClick', handler)
542
+ service.data.setValue({ count: 3, entries: testEntries })
543
+ service.handleRowDoubleClick(testEntries[2])
544
+
545
+ expect(handler).toHaveBeenCalledTimes(1)
546
+ expect(handler).toHaveBeenCalledWith(testEntries[2])
547
+ })
548
+ })
549
+
550
+ it('Should support multiple subscribers for the same event', () => {
551
+ const testEntries = createTestEntries()
552
+ const handler1 = vi.fn()
553
+ const handler2 = vi.fn()
554
+
555
+ using(new CollectionService<TestEntry>(), (service) => {
556
+ service.addListener('onRowClick', handler1)
557
+ service.addListener('onRowClick', handler2)
558
+ service.data.setValue({ count: 3, entries: testEntries })
559
+ service.handleRowClick(testEntries[0], createMouseEvent())
560
+
561
+ expect(handler1).toHaveBeenCalledTimes(1)
562
+ expect(handler2).toHaveBeenCalledTimes(1)
563
+ })
564
+ })
565
+ })
520
566
  })
@@ -1,4 +1,4 @@
1
- import { ObservableValue } from '@furystack/utils'
1
+ import { EventHub, ObservableValue, type ListenerErrorPayload } from '@furystack/utils'
2
2
 
3
3
  export interface CollectionData<T> {
4
4
  entries: T[]
@@ -12,25 +12,32 @@ export interface CollectionServiceOptions<T> {
12
12
  searchField?: keyof T
13
13
  /**
14
14
  * @param entry The clicked entry
15
- * optional callback for row clicks
15
+ * @deprecated Use `subscribe('onRowClick', ...)` instead
16
16
  */
17
17
  onRowClick?: (entry: T) => void
18
18
 
19
19
  /**
20
- * Optional callback for row double clicks
21
20
  * @param entry The clicked entry
21
+ * @deprecated Use `subscribe('onRowDoubleClick', ...)` instead
22
22
  */
23
-
24
23
  onRowDoubleClick?: (entry: T) => void
25
24
  }
26
25
 
27
- export class CollectionService<T> implements Disposable {
26
+ export class CollectionService<T>
27
+ extends EventHub<{
28
+ onRowClick: T
29
+ onRowDoubleClick: T
30
+ onListenerError: ListenerErrorPayload
31
+ }>
32
+ implements Disposable
33
+ {
28
34
  public [Symbol.dispose]() {
29
35
  this.data[Symbol.dispose]()
30
36
  this.selection[Symbol.dispose]()
31
37
  this.searchTerm[Symbol.dispose]()
32
38
  this.hasFocus[Symbol.dispose]()
33
39
  this.focusedEntry[Symbol.dispose]()
40
+ super[Symbol.dispose]()
34
41
  }
35
42
 
36
43
  public isSelected = (entry: T) => this.selection.getValue().includes(entry)
@@ -143,7 +150,7 @@ export class CollectionService<T> implements Disposable {
143
150
  }
144
151
 
145
152
  public handleRowClick(entry: T, ev: MouseEvent) {
146
- this.options.onRowClick?.(entry)
153
+ this.emit('onRowClick', entry)
147
154
  const currentSelectionValue = this.selection.getValue()
148
155
  const lastFocused = this.focusedEntry.getValue()
149
156
  if (ev.ctrlKey) {
@@ -171,9 +178,17 @@ export class CollectionService<T> implements Disposable {
171
178
  this.focusedEntry.setValue(entry)
172
179
  }
173
180
 
174
- constructor(private options: CollectionServiceOptions<T> = {}) {}
181
+ constructor(private options: CollectionServiceOptions<T> = {}) {
182
+ super()
183
+ if (options.onRowClick) {
184
+ this.addListener('onRowClick', options.onRowClick)
185
+ }
186
+ if (options.onRowDoubleClick) {
187
+ this.addListener('onRowDoubleClick', options.onRowDoubleClick)
188
+ }
189
+ }
175
190
 
176
191
  public handleRowDoubleClick(entry: T) {
177
- this.options.onRowDoubleClick?.(entry)
192
+ this.emit('onRowDoubleClick', entry)
178
193
  }
179
194
  }