@furystack/shades-common-components 13.1.0 → 13.2.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 (142) hide show
  1. package/CHANGELOG.md +39 -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/css-variable-theme.d.ts +1 -1
  26. package/esm/services/css-variable-theme.d.ts.map +1 -1
  27. package/esm/services/css-variable-theme.js +5 -5
  28. package/esm/services/css-variable-theme.js.map +1 -1
  29. package/esm/services/css-variable-theme.spec.js +29 -1
  30. package/esm/services/css-variable-theme.spec.js.map +1 -1
  31. package/esm/services/layout-service.d.ts +8 -0
  32. package/esm/services/layout-service.d.ts.map +1 -1
  33. package/esm/services/layout-service.js +16 -0
  34. package/esm/services/layout-service.js.map +1 -1
  35. package/esm/services/layout-service.spec.js +55 -0
  36. package/esm/services/layout-service.spec.js.map +1 -1
  37. package/esm/services/theme-provider-service.d.ts +11 -10
  38. package/esm/services/theme-provider-service.d.ts.map +1 -1
  39. package/esm/services/theme-provider-service.js +3 -2
  40. package/esm/services/theme-provider-service.js.map +1 -1
  41. package/esm/services/theme-provider-service.spec.js +35 -1
  42. package/esm/services/theme-provider-service.spec.js.map +1 -1
  43. package/esm/themes/architect-theme.d.ts +1 -0
  44. package/esm/themes/architect-theme.d.ts.map +1 -1
  45. package/esm/themes/architect-theme.js +1 -0
  46. package/esm/themes/architect-theme.js.map +1 -1
  47. package/esm/themes/auditore-theme.d.ts +1 -0
  48. package/esm/themes/auditore-theme.d.ts.map +1 -1
  49. package/esm/themes/auditore-theme.js +1 -0
  50. package/esm/themes/auditore-theme.js.map +1 -1
  51. package/esm/themes/black-mesa-theme.d.ts +1 -0
  52. package/esm/themes/black-mesa-theme.d.ts.map +1 -1
  53. package/esm/themes/black-mesa-theme.js +1 -0
  54. package/esm/themes/black-mesa-theme.js.map +1 -1
  55. package/esm/themes/default-dark-theme.d.ts +1 -0
  56. package/esm/themes/default-dark-theme.d.ts.map +1 -1
  57. package/esm/themes/default-dark-theme.js +1 -0
  58. package/esm/themes/default-dark-theme.js.map +1 -1
  59. package/esm/themes/default-light-theme.d.ts +1 -0
  60. package/esm/themes/default-light-theme.d.ts.map +1 -1
  61. package/esm/themes/default-light-theme.js +1 -0
  62. package/esm/themes/default-light-theme.js.map +1 -1
  63. package/esm/themes/dragonborn-theme.d.ts +1 -0
  64. package/esm/themes/dragonborn-theme.d.ts.map +1 -1
  65. package/esm/themes/dragonborn-theme.js +1 -0
  66. package/esm/themes/dragonborn-theme.js.map +1 -1
  67. package/esm/themes/hawkins-theme.d.ts +1 -0
  68. package/esm/themes/hawkins-theme.d.ts.map +1 -1
  69. package/esm/themes/hawkins-theme.js +1 -0
  70. package/esm/themes/hawkins-theme.js.map +1 -1
  71. package/esm/themes/jedi-theme.d.ts +1 -0
  72. package/esm/themes/jedi-theme.d.ts.map +1 -1
  73. package/esm/themes/jedi-theme.js +1 -0
  74. package/esm/themes/jedi-theme.js.map +1 -1
  75. package/esm/themes/neon-runner-theme.d.ts +1 -0
  76. package/esm/themes/neon-runner-theme.d.ts.map +1 -1
  77. package/esm/themes/neon-runner-theme.js +1 -0
  78. package/esm/themes/neon-runner-theme.js.map +1 -1
  79. package/esm/themes/plumber-theme.d.ts +1 -0
  80. package/esm/themes/plumber-theme.d.ts.map +1 -1
  81. package/esm/themes/plumber-theme.js +1 -0
  82. package/esm/themes/plumber-theme.js.map +1 -1
  83. package/esm/themes/replicant-theme.d.ts +1 -0
  84. package/esm/themes/replicant-theme.d.ts.map +1 -1
  85. package/esm/themes/replicant-theme.js +1 -0
  86. package/esm/themes/replicant-theme.js.map +1 -1
  87. package/esm/themes/sandworm-theme.d.ts +1 -0
  88. package/esm/themes/sandworm-theme.d.ts.map +1 -1
  89. package/esm/themes/sandworm-theme.js +1 -0
  90. package/esm/themes/sandworm-theme.js.map +1 -1
  91. package/esm/themes/shadow-broker-theme.d.ts +1 -0
  92. package/esm/themes/shadow-broker-theme.d.ts.map +1 -1
  93. package/esm/themes/shadow-broker-theme.js +1 -0
  94. package/esm/themes/shadow-broker-theme.js.map +1 -1
  95. package/esm/themes/sith-theme.d.ts +1 -0
  96. package/esm/themes/sith-theme.d.ts.map +1 -1
  97. package/esm/themes/sith-theme.js +1 -0
  98. package/esm/themes/sith-theme.js.map +1 -1
  99. package/esm/themes/vault-dweller-theme.d.ts +1 -0
  100. package/esm/themes/vault-dweller-theme.d.ts.map +1 -1
  101. package/esm/themes/vault-dweller-theme.js +1 -0
  102. package/esm/themes/vault-dweller-theme.js.map +1 -1
  103. package/esm/themes/wild-hunt-theme.d.ts +1 -0
  104. package/esm/themes/wild-hunt-theme.d.ts.map +1 -1
  105. package/esm/themes/wild-hunt-theme.js +1 -0
  106. package/esm/themes/wild-hunt-theme.js.map +1 -1
  107. package/esm/themes/xenomorph-theme.d.ts +1 -0
  108. package/esm/themes/xenomorph-theme.d.ts.map +1 -1
  109. package/esm/themes/xenomorph-theme.js +1 -0
  110. package/esm/themes/xenomorph-theme.js.map +1 -1
  111. package/package.json +1 -1
  112. package/src/components/app-bar.tsx +12 -3
  113. package/src/components/avatar.tsx +20 -5
  114. package/src/components/command-palette/command-palette-input.spec.tsx +14 -156
  115. package/src/components/command-palette/command-palette-input.tsx +13 -45
  116. package/src/components/command-palette/index.tsx +4 -0
  117. package/src/components/drawer/index.spec.tsx +64 -0
  118. package/src/components/drawer/index.tsx +5 -0
  119. package/src/components/noty-list.tsx +1 -3
  120. package/src/services/css-variable-theme.spec.ts +43 -1
  121. package/src/services/css-variable-theme.ts +5 -5
  122. package/src/services/layout-service.spec.ts +74 -0
  123. package/src/services/layout-service.ts +18 -0
  124. package/src/services/theme-provider-service.spec.ts +49 -1
  125. package/src/services/theme-provider-service.ts +12 -11
  126. package/src/themes/architect-theme.ts +1 -0
  127. package/src/themes/auditore-theme.ts +1 -0
  128. package/src/themes/black-mesa-theme.ts +1 -0
  129. package/src/themes/default-dark-theme.ts +1 -0
  130. package/src/themes/default-light-theme.ts +1 -0
  131. package/src/themes/dragonborn-theme.ts +1 -0
  132. package/src/themes/hawkins-theme.ts +1 -0
  133. package/src/themes/jedi-theme.ts +1 -0
  134. package/src/themes/neon-runner-theme.ts +1 -0
  135. package/src/themes/plumber-theme.ts +1 -0
  136. package/src/themes/replicant-theme.ts +1 -0
  137. package/src/themes/sandworm-theme.ts +1 -0
  138. package/src/themes/shadow-broker-theme.ts +1 -0
  139. package/src/themes/sith-theme.ts +1 -0
  140. package/src/themes/vault-dweller-theme.ts +1 -0
  141. package/src/themes/wild-hunt-theme.ts +1 -0
  142. package/src/themes/xenomorph-theme.ts +1 -0
@@ -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 (
@@ -67,7 +67,7 @@ describe('css-variable-theme', () => {
67
67
  expect(cssVariableTheme.typography.fontWeight.bold).toBe('var(--shades-theme-typography-font-weight-bold)')
68
68
  expect(cssVariableTheme.typography.lineHeight.tight).toBe('var(--shades-theme-typography-line-height-tight)')
69
69
  expect(cssVariableTheme.typography.lineHeight.normal).toBe('var(--shades-theme-typography-line-height-normal)')
70
- expect(cssVariableTheme.typography.textShadow).toBe('var(--shades-theme-typography-text-shadow, none)')
70
+ expect(cssVariableTheme.typography.textShadow).toBe('var(--shades-theme-typography-text-shadow)')
71
71
  })
72
72
 
73
73
  it('should have transition properties with CSS variable references', () => {
@@ -446,6 +446,48 @@ describe('css-variable-theme', () => {
446
446
  expect(root.style.getPropertyValue('--shades-theme-spacing-md')).toBe('16px')
447
447
  expect(root.style.getPropertyValue('--shades-theme-spacing-xl')).toBe('32px')
448
448
  })
449
+
450
+ it('should set CSS variables on a custom root element instead of :root', () => {
451
+ const customRoot = document.createElement('div')
452
+ document.body.appendChild(customRoot)
453
+
454
+ useThemeCssVariables(
455
+ {
456
+ text: { primary: '#ff0000' },
457
+ divider: '#00ff00',
458
+ },
459
+ customRoot,
460
+ )
461
+
462
+ expect(customRoot.style.getPropertyValue('--shades-theme-text-primary')).toBe('#ff0000')
463
+ expect(customRoot.style.getPropertyValue('--shades-theme-divider')).toBe('#00ff00')
464
+ expect(root.style.getPropertyValue('--shades-theme-text-primary')).toBe('')
465
+
466
+ customRoot.remove()
467
+ })
468
+
469
+ it('should scope nested palette variables to custom root element', () => {
470
+ const customRoot = document.createElement('div')
471
+ document.body.appendChild(customRoot)
472
+
473
+ useThemeCssVariables(
474
+ {
475
+ palette: {
476
+ primary: {
477
+ main: '#1976d2',
478
+ mainContrast: '#ffffff',
479
+ },
480
+ },
481
+ },
482
+ customRoot,
483
+ )
484
+
485
+ expect(customRoot.style.getPropertyValue('--shades-theme-palette-primary-main')).toBe('#1976d2')
486
+ expect(customRoot.style.getPropertyValue('--shades-theme-palette-primary-main-contrast')).toBe('#ffffff')
487
+ expect(root.style.getPropertyValue('--shades-theme-palette-primary-main')).toBe('')
488
+
489
+ customRoot.remove()
490
+ })
449
491
  })
450
492
 
451
493
  describe('buildTransition', () => {
@@ -17,7 +17,7 @@ export const cssVariableTheme = {
17
17
  background: {
18
18
  default: 'var(--shades-theme-background-default)',
19
19
  paper: 'var(--shades-theme-background-paper)',
20
- paperImage: 'var(--shades-theme-background-paper-image, none)',
20
+ paperImage: 'var(--shades-theme-background-paper-image)',
21
21
  },
22
22
  palette: {
23
23
  primary: {
@@ -87,7 +87,7 @@ export const cssVariableTheme = {
87
87
  lg: 'var(--shades-theme-shape-border-radius-lg)',
88
88
  full: 'var(--shades-theme-shape-border-radius-full)',
89
89
  },
90
- borderWidth: 'var(--shades-theme-shape-border-width, 0px)',
90
+ borderWidth: 'var(--shades-theme-shape-border-width)',
91
91
  },
92
92
  shadows: {
93
93
  none: 'var(--shades-theme-shadows-none)',
@@ -127,7 +127,7 @@ export const cssVariableTheme = {
127
127
  wider: 'var(--shades-theme-typography-letter-spacing-wider)',
128
128
  widest: 'var(--shades-theme-typography-letter-spacing-widest)',
129
129
  },
130
- textShadow: 'var(--shades-theme-typography-text-shadow, none)',
130
+ textShadow: 'var(--shades-theme-typography-text-shadow)',
131
131
  },
132
132
  transitions: {
133
133
  duration: {
@@ -222,8 +222,8 @@ const assignValue = <T extends object>(
222
222
  }
223
223
  })
224
224
  }
225
- export const useThemeCssVariables = (theme: DeepPartial<Theme>) => {
226
- const root = document.querySelector(':root') as HTMLElement
225
+ export const useThemeCssVariables = (theme: DeepPartial<Theme>, root?: HTMLElement) => {
226
+ root ??= document.querySelector(':root') as HTMLElement
227
227
  assignValue(cssVariableTheme, theme, root)
228
228
 
229
229
  if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) {
@@ -221,6 +221,15 @@ describe('LayoutService', () => {
221
221
  })
222
222
  })
223
223
 
224
+ it('should overwrite existing drawer config', () => {
225
+ using(new LayoutService(mockElement as unknown as HTMLElement), (service) => {
226
+ service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' })
227
+ service.initDrawer('left', { open: false, width: '300px', variant: 'permanent' })
228
+
229
+ expect(service.drawerState.getValue().left).toEqual({ open: false, width: '300px', variant: 'permanent' })
230
+ })
231
+ })
232
+
224
233
  it('should initialize drawer with all variant types', () => {
225
234
  using(new LayoutService(mockElement as unknown as HTMLElement), (service) => {
226
235
  service.initDrawer('left', { open: true, width: '240px', variant: 'permanent' })
@@ -234,6 +243,71 @@ describe('LayoutService', () => {
234
243
  })
235
244
  })
236
245
  })
246
+
247
+ describe('removeDrawer', () => {
248
+ it('should remove left drawer state', () => {
249
+ using(new LayoutService(mockElement as unknown as HTMLElement), (service) => {
250
+ service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' })
251
+
252
+ service.removeDrawer('left')
253
+
254
+ expect(service.drawerState.getValue().left).toBeUndefined()
255
+ })
256
+ })
257
+
258
+ it('should remove right drawer state', () => {
259
+ using(new LayoutService(mockElement as unknown as HTMLElement), (service) => {
260
+ service.initDrawer('right', { open: true, width: '200px', variant: 'temporary' })
261
+
262
+ service.removeDrawer('right')
263
+
264
+ expect(service.drawerState.getValue().right).toBeUndefined()
265
+ })
266
+ })
267
+
268
+ it('should not affect the other drawer when removing one', () => {
269
+ using(new LayoutService(mockElement as unknown as HTMLElement), (service) => {
270
+ service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' })
271
+ service.initDrawer('right', { open: true, width: '200px', variant: 'temporary' })
272
+
273
+ service.removeDrawer('left')
274
+
275
+ expect(service.drawerState.getValue().left).toBeUndefined()
276
+ expect(service.drawerState.getValue().right).toEqual({ open: true, width: '200px', variant: 'temporary' })
277
+ })
278
+ })
279
+
280
+ it('should be a no-op if the drawer does not exist', () => {
281
+ using(new LayoutService(mockElement as unknown as HTMLElement), (service) => {
282
+ const stateBefore = service.drawerState.getValue()
283
+
284
+ service.removeDrawer('left')
285
+
286
+ expect(service.drawerState.getValue()).toEqual(stateBefore)
287
+ })
288
+ })
289
+
290
+ it('should be a no-op if the service is already disposed', () => {
291
+ const service = new LayoutService(mockElement as unknown as HTMLElement)
292
+ service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' })
293
+ service[Symbol.dispose]()
294
+
295
+ expect(() => service.removeDrawer('left')).not.toThrow()
296
+ })
297
+
298
+ it('should reset CSS variables after removing a drawer', () => {
299
+ using(new LayoutService(mockElement as unknown as HTMLElement), (service) => {
300
+ service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' })
301
+ mockSetProperty.mockClear()
302
+
303
+ service.removeDrawer('left')
304
+
305
+ expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-width', '0px')
306
+ expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-configured-width', '0px')
307
+ expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-left', '0px')
308
+ })
309
+ })
310
+ })
237
311
  })
238
312
 
239
313
  describe('CSS Variables', () => {
@@ -237,6 +237,24 @@ export class LayoutService implements Disposable {
237
237
  })
238
238
  }
239
239
 
240
+ /**
241
+ * Removes a drawer from the state, clearing its configuration.
242
+ * Use this when a Drawer component is unmounted to prevent stale state
243
+ * from affecting content margins.
244
+ *
245
+ * @param position - Which drawer to remove ('left' or 'right')
246
+ */
247
+ public removeDrawer(position: 'left' | 'right'): void {
248
+ if (this.drawerState.isDisposed) return
249
+
250
+ const currentState = this.drawerState.getValue()
251
+
252
+ if (currentState[position]) {
253
+ const { [position]: _, ...rest } = currentState
254
+ this.drawerState.setValue(rest)
255
+ }
256
+ }
257
+
240
258
  /**
241
259
  * Sets the top gap spacing between AppBar and content.
242
260
  *