@furystack/shades-common-components 13.4.0 → 13.5.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 (48) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/esm/components/alert.d.ts.map +1 -1
  3. package/esm/components/alert.js +7 -6
  4. package/esm/components/alert.js.map +1 -1
  5. package/esm/components/cache-view.spec.js +171 -170
  6. package/esm/components/cache-view.spec.js.map +1 -1
  7. package/esm/components/command-palette/command-palette-suggestion-list.js +1 -1
  8. package/esm/components/command-palette/command-palette-suggestion-list.js.map +1 -1
  9. package/esm/components/inputs/input.d.ts.map +1 -1
  10. package/esm/components/inputs/input.js +2 -0
  11. package/esm/components/inputs/input.js.map +1 -1
  12. package/esm/components/inputs/select.d.ts.map +1 -1
  13. package/esm/components/inputs/select.js +3 -1
  14. package/esm/components/inputs/select.js.map +1 -1
  15. package/esm/components/page-container/index.d.ts.map +1 -1
  16. package/esm/components/page-container/index.js +1 -1
  17. package/esm/components/page-container/index.js.map +1 -1
  18. package/esm/components/page-container/index.spec.js +5 -4
  19. package/esm/components/page-container/index.spec.js.map +1 -1
  20. package/esm/components/page-layout/index.d.ts +6 -0
  21. package/esm/components/page-layout/index.d.ts.map +1 -1
  22. package/esm/components/page-layout/index.js +28 -13
  23. package/esm/components/page-layout/index.js.map +1 -1
  24. package/esm/components/page-layout/index.spec.js +119 -0
  25. package/esm/components/page-layout/index.spec.js.map +1 -1
  26. package/esm/components/result.d.ts +4 -2
  27. package/esm/components/result.d.ts.map +1 -1
  28. package/esm/components/result.js +11 -10
  29. package/esm/components/result.js.map +1 -1
  30. package/esm/components/suggest/index.d.ts.map +1 -1
  31. package/esm/components/suggest/index.js +7 -3
  32. package/esm/components/suggest/index.js.map +1 -1
  33. package/esm/components/tree/tree.d.ts.map +1 -1
  34. package/esm/components/tree/tree.js +1 -0
  35. package/esm/components/tree/tree.js.map +1 -1
  36. package/package.json +6 -6
  37. package/src/components/alert.tsx +9 -6
  38. package/src/components/cache-view.spec.tsx +235 -219
  39. package/src/components/command-palette/command-palette-suggestion-list.tsx +1 -1
  40. package/src/components/inputs/input.tsx +2 -0
  41. package/src/components/inputs/select.tsx +3 -1
  42. package/src/components/page-container/index.spec.tsx +5 -4
  43. package/src/components/page-container/index.tsx +7 -1
  44. package/src/components/page-layout/index.spec.tsx +173 -0
  45. package/src/components/page-layout/index.tsx +35 -13
  46. package/src/components/result.tsx +17 -10
  47. package/src/components/suggest/index.tsx +18 -15
  48. package/src/components/tree/tree.tsx +1 -0
@@ -879,4 +879,177 @@ describe('PageLayout component', () => {
879
879
  })
880
880
  })
881
881
  })
882
+
883
+ describe('Contained Mode', () => {
884
+ it('should set data-contained attribute on host when contained is true', async () => {
885
+ await usingAsync(new Injector(), async (injector) => {
886
+ const rootElement = document.getElementById('root') as HTMLDivElement
887
+
888
+ initializeShadeRoot({
889
+ injector,
890
+ rootElement,
891
+ jsxElement: (
892
+ <PageLayout contained>
893
+ <div>Content</div>
894
+ </PageLayout>
895
+ ),
896
+ })
897
+
898
+ await flushUpdates()
899
+ const pageLayout = document.querySelector('shade-page-layout')
900
+ expect(pageLayout?.hasAttribute('data-contained')).toBe(true)
901
+ })
902
+ })
903
+
904
+ it('should not set data-contained attribute when contained is not set', async () => {
905
+ await usingAsync(new Injector(), async (injector) => {
906
+ const rootElement = document.getElementById('root') as HTMLDivElement
907
+
908
+ initializeShadeRoot({
909
+ injector,
910
+ rootElement,
911
+ jsxElement: (
912
+ <PageLayout>
913
+ <div>Content</div>
914
+ </PageLayout>
915
+ ),
916
+ })
917
+
918
+ await flushUpdates()
919
+ const pageLayout = document.querySelector('shade-page-layout')
920
+ expect(pageLayout?.hasAttribute('data-contained')).toBe(false)
921
+ })
922
+ })
923
+
924
+ it('should have absolute positioning when contained', async () => {
925
+ await usingAsync(new Injector(), async (injector) => {
926
+ const rootElement = document.getElementById('root') as HTMLDivElement
927
+
928
+ initializeShadeRoot({
929
+ injector,
930
+ rootElement,
931
+ jsxElement: (
932
+ <PageLayout contained>
933
+ <div>Content</div>
934
+ </PageLayout>
935
+ ),
936
+ })
937
+
938
+ await flushUpdates()
939
+ const pageLayout = document.querySelector('shade-page-layout') as HTMLElement
940
+ const computedStyle = window.getComputedStyle(pageLayout)
941
+ expect(computedStyle.position).toBe('absolute')
942
+ })
943
+ })
944
+
945
+ it('should work with AppBar and drawers in contained mode', async () => {
946
+ await usingAsync(new Injector(), async (injector) => {
947
+ const rootElement = document.getElementById('root') as HTMLDivElement
948
+
949
+ initializeShadeRoot({
950
+ injector,
951
+ rootElement,
952
+ jsxElement: (
953
+ <PageLayout
954
+ contained
955
+ appBar={{
956
+ variant: 'permanent',
957
+ component: <div>AppBar</div>,
958
+ }}
959
+ drawer={{
960
+ left: {
961
+ variant: 'collapsible',
962
+ component: <div>Left Drawer</div>,
963
+ },
964
+ }}
965
+ >
966
+ <div>Content</div>
967
+ </PageLayout>
968
+ ),
969
+ })
970
+
971
+ await flushUpdates()
972
+
973
+ const pageLayout = document.querySelector('shade-page-layout') as HTMLElement & { injector: Injector }
974
+ expect(pageLayout.hasAttribute('data-contained')).toBe(true)
975
+ expect(document.body.innerHTML).toContain('page-layout-appbar')
976
+ expect(document.body.innerHTML).toContain('page-layout-drawer-left')
977
+ expect(document.body.innerHTML).toContain('page-layout-content')
978
+
979
+ const layoutService = pageLayout.injector.getInstance(LayoutService)
980
+ expect(layoutService.drawerState.getValue().left?.open).toBe(true)
981
+ })
982
+ })
983
+
984
+ it('should support drawer toggle in contained mode', async () => {
985
+ await usingAsync(new Injector(), async (injector) => {
986
+ const rootElement = document.getElementById('root') as HTMLDivElement
987
+
988
+ initializeShadeRoot({
989
+ injector,
990
+ rootElement,
991
+ jsxElement: (
992
+ <PageLayout
993
+ contained
994
+ drawer={{
995
+ left: {
996
+ variant: 'collapsible',
997
+ component: <div>Left Drawer</div>,
998
+ },
999
+ }}
1000
+ >
1001
+ <div>Content</div>
1002
+ </PageLayout>
1003
+ ),
1004
+ })
1005
+
1006
+ await flushUpdates()
1007
+
1008
+ const pageLayout = document.querySelector('shade-page-layout') as HTMLElement & { injector: Injector }
1009
+ const layoutService = pageLayout.injector.getInstance(LayoutService)
1010
+
1011
+ expect(pageLayout.hasAttribute('data-drawer-left-closed')).toBe(false)
1012
+
1013
+ layoutService.setDrawerOpen('left', false)
1014
+ await flushUpdates()
1015
+
1016
+ expect(pageLayout.hasAttribute('data-drawer-left-closed')).toBe(true)
1017
+ })
1018
+ })
1019
+
1020
+ it('should support temporary drawer backdrop click in contained mode', async () => {
1021
+ await usingAsync(new Injector(), async (injector) => {
1022
+ const rootElement = document.getElementById('root') as HTMLDivElement
1023
+
1024
+ initializeShadeRoot({
1025
+ injector,
1026
+ rootElement,
1027
+ jsxElement: (
1028
+ <PageLayout
1029
+ contained
1030
+ drawer={{
1031
+ left: {
1032
+ variant: 'temporary',
1033
+ defaultOpen: true,
1034
+ component: <div>Temporary Drawer</div>,
1035
+ },
1036
+ }}
1037
+ >
1038
+ <div>Content</div>
1039
+ </PageLayout>
1040
+ ),
1041
+ })
1042
+
1043
+ await flushUpdates()
1044
+ const pageLayout = document.querySelector('shade-page-layout')
1045
+ expect(pageLayout?.hasAttribute('data-backdrop-visible')).toBe(true)
1046
+
1047
+ const backdrop = document.querySelector('.page-layout-drawer-backdrop') as HTMLElement
1048
+ backdrop.click()
1049
+ await flushUpdates()
1050
+
1051
+ expect(pageLayout?.hasAttribute('data-drawer-left-closed')).toBe(true)
1052
+ })
1053
+ })
1054
+ })
882
1055
  })
@@ -46,6 +46,12 @@ export type PageLayoutProps = {
46
46
  topGap?: string
47
47
  /** Gap between the drawers and the content area (CSS value). Default: '0px' */
48
48
  sideGap?: string
49
+ /**
50
+ * When true, uses `position: absolute` instead of `position: fixed` so the
51
+ * layout fills its nearest positioned ancestor rather than the viewport.
52
+ * This enables nesting PageLayout instances (e.g. in a showcase grid).
53
+ */
54
+ contained?: boolean
49
55
  }
50
56
 
51
57
  const DEFAULT_APPBAR_HEIGHT = '48px'
@@ -102,8 +108,8 @@ export const PageLayout = Shade<PageLayoutProps>({
102
108
  margin: '0',
103
109
  },
104
110
 
105
- // AppBar container
106
- '& .page-layout-appbar': {
111
+ // AppBar container (> * > scopes to the wrapper div to prevent bleeding into nested PageLayouts)
112
+ '& > * > .page-layout-appbar': {
107
113
  position: 'fixed',
108
114
  top: '0',
109
115
  left: '0',
@@ -114,18 +120,18 @@ export const PageLayout = Shade<PageLayoutProps>({
114
120
  },
115
121
 
116
122
  // Auto-hide AppBar styles (controlled via host data attributes)
117
- '&[data-appbar-auto-hide] .page-layout-appbar': {
123
+ '&[data-appbar-auto-hide] > * > .page-layout-appbar': {
118
124
  top: 'calc(-1 * var(--layout-appbar-height, 48px))',
119
125
  },
120
- '&[data-appbar-auto-hide] .page-layout-appbar:hover': {
126
+ '&[data-appbar-auto-hide] > * > .page-layout-appbar:hover': {
121
127
  top: '0',
122
128
  },
123
- '&[data-appbar-auto-hide][data-appbar-visible] .page-layout-appbar': {
129
+ '&[data-appbar-auto-hide][data-appbar-visible] > * > .page-layout-appbar': {
124
130
  top: '0',
125
131
  },
126
132
 
127
133
  // Drawer containers - use CSS transitions
128
- '& .page-layout-drawer': {
134
+ '& > * > .page-layout-drawer': {
129
135
  position: 'fixed',
130
136
  top: 'var(--layout-appbar-height, 48px)',
131
137
  bottom: '0',
@@ -135,13 +141,13 @@ export const PageLayout = Shade<PageLayoutProps>({
135
141
  backgroundImage: cssVariableTheme.background.paperImage,
136
142
  transition: `transform ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.easeInOut}`,
137
143
  },
138
- '& .page-layout-drawer-left': {
144
+ '& > * > .page-layout-drawer-left': {
139
145
  left: '0',
140
146
  width: 'var(--layout-drawer-left-configured-width, 240px)',
141
147
  borderRight: `1px solid ${cssVariableTheme.divider}`,
142
148
  transform: 'translateX(0)',
143
149
  },
144
- '& .page-layout-drawer-right': {
150
+ '& > * > .page-layout-drawer-right': {
145
151
  right: '0',
146
152
  width: 'var(--layout-drawer-right-configured-width, 240px)',
147
153
  borderLeft: `1px solid ${cssVariableTheme.divider}`,
@@ -149,17 +155,17 @@ export const PageLayout = Shade<PageLayoutProps>({
149
155
  },
150
156
 
151
157
  // Drawer closed states (controlled via host data attributes)
152
- '&[data-drawer-left-closed] .page-layout-drawer-left': {
158
+ '&[data-drawer-left-closed] > * > .page-layout-drawer-left': {
153
159
  transform: 'translateX(-100%)',
154
160
  pointerEvents: 'none',
155
161
  },
156
- '&[data-drawer-right-closed] .page-layout-drawer-right': {
162
+ '&[data-drawer-right-closed] > * > .page-layout-drawer-right': {
157
163
  transform: 'translateX(100%)',
158
164
  pointerEvents: 'none',
159
165
  },
160
166
 
161
167
  // Temporary drawer backdrop
162
- '& .page-layout-drawer-backdrop': {
168
+ '& > * > .page-layout-drawer-backdrop': {
163
169
  position: 'fixed',
164
170
  top: '0',
165
171
  left: '0',
@@ -171,13 +177,28 @@ export const PageLayout = Shade<PageLayoutProps>({
171
177
  pointerEvents: 'none',
172
178
  transition: `opacity ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.easeInOut}`,
173
179
  },
174
- '&[data-backdrop-visible] .page-layout-drawer-backdrop': {
180
+ '&[data-backdrop-visible] > * > .page-layout-drawer-backdrop': {
175
181
  opacity: '1',
176
182
  pointerEvents: 'auto',
177
183
  },
178
184
 
185
+ // Contained mode - use absolute positioning instead of fixed so the layout
186
+ // fills its nearest positioned ancestor rather than the viewport
187
+ '&[data-contained]': {
188
+ position: 'absolute',
189
+ },
190
+ '&[data-contained] > * > .page-layout-appbar': {
191
+ position: 'absolute',
192
+ },
193
+ '&[data-contained] > * > .page-layout-drawer': {
194
+ position: 'absolute',
195
+ },
196
+ '&[data-contained] > * > .page-layout-drawer-backdrop': {
197
+ position: 'absolute',
198
+ },
199
+
179
200
  // Content area - uses CSS variables for positioning
180
- '& .page-layout-content': {
201
+ '& > * > .page-layout-content': {
181
202
  position: 'absolute',
182
203
  top: '0',
183
204
  bottom: '0',
@@ -327,6 +348,7 @@ export const PageLayout = Shade<PageLayoutProps>({
327
348
  const rightContentMargin = layoutService.getContentMarginForPosition('right')
328
349
 
329
350
  useHostProps({
351
+ ...(props.contained ? { 'data-contained': '' } : {}),
330
352
  ...(!isLeftOpen ? { 'data-drawer-left-closed': '' } : {}),
331
353
  ...(!isRightOpen ? { 'data-drawer-right-closed': '' } : {}),
332
354
  ...(props.appBar?.variant === 'auto-hide' ? { 'data-appbar-auto-hide': '' } : {}),
@@ -40,16 +40,19 @@ const statusColorMap: Record<ResultStatus, string> = {
40
40
  '500': paletteMainColors.error.main,
41
41
  }
42
42
 
43
- const defaultIcons: Record<ResultStatus, JSX.Element> = {
44
- success: (<Icon icon={checkCircle} size={64} />) as unknown as JSX.Element,
45
- error: (<Icon icon={errorCircle} size={64} />) as unknown as JSX.Element,
46
- warning: (<Icon icon={warningIcon} size={64} />) as unknown as JSX.Element,
47
- info: (<Icon icon={infoIcon} size={64} />) as unknown as JSX.Element,
48
- '403': (<Icon icon={forbidden} size={64} />) as unknown as JSX.Element,
49
- '404': (<Icon icon={searchOff} size={64} />) as unknown as JSX.Element,
50
- '500': (<Icon icon={serverError} size={64} />) as unknown as JSX.Element,
43
+ const defaultIconDefs: Record<ResultStatus, typeof checkCircle> = {
44
+ success: checkCircle,
45
+ error: errorCircle,
46
+ warning: warningIcon,
47
+ info: infoIcon,
48
+ '403': forbidden,
49
+ '404': searchOff,
50
+ '500': serverError,
51
51
  }
52
52
 
53
+ const getDefaultIcon = (status: ResultStatus): JSX.Element =>
54
+ (<Icon icon={defaultIconDefs[status]} size={64} />) as unknown as JSX.Element
55
+
53
56
  const defaultTitles: Record<ResultStatus, string> = {
54
57
  success: 'Success',
55
58
  error: 'Error',
@@ -114,7 +117,7 @@ export const Result = Shade<ResultProps>({
114
117
  render: ({ props, children, useHostProps }) => {
115
118
  const { status, title, subtitle, icon, style } = props
116
119
 
117
- const displayIcon = icon ?? defaultIcons[status]
120
+ const displayIcon = icon ?? getDefaultIcon(status)
118
121
  const statusColor = statusColorMap[status]
119
122
 
120
123
  useHostProps({
@@ -146,4 +149,8 @@ export const Result = Shade<ResultProps>({
146
149
  },
147
150
  })
148
151
 
149
- export { defaultIcons as resultDefaultIcons, defaultTitles as resultDefaultTitles }
152
+ export {
153
+ getDefaultIcon as resultGetDefaultIcon,
154
+ defaultIconDefs as resultDefaultIconDefs,
155
+ defaultTitles as resultDefaultTitles,
156
+ }
@@ -83,21 +83,23 @@ export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX
83
83
  useHostProps({
84
84
  'data-opened': isOpened ? '' : undefined,
85
85
  })
86
- manager.isLoading.subscribe((isLoading) => {
87
- const loader = loaderRef.current
88
- if (!loader) return
89
- if (isLoading) {
90
- void promisifyAnimation(loader, [{ opacity: 0 }, { opacity: 1 }], {
91
- duration: 100,
92
- fill: 'forwards',
93
- })
94
- } else {
95
- void promisifyAnimation(loader, [{ opacity: 1 }, { opacity: 0 }], {
96
- duration: 100,
97
- fill: 'forwards',
98
- })
99
- }
100
- })
86
+ useDisposable('isLoadingSubscription', () =>
87
+ manager.isLoading.subscribe((isLoading) => {
88
+ const loader = loaderRef.current
89
+ if (!loader) return
90
+ if (isLoading) {
91
+ void promisifyAnimation(loader, [{ opacity: 0 }, { opacity: 1 }], {
92
+ duration: 100,
93
+ fill: 'forwards',
94
+ })
95
+ } else {
96
+ void promisifyAnimation(loader, [{ opacity: 1 }, { opacity: 0 }], {
97
+ duration: 100,
98
+ fill: 'forwards',
99
+ })
100
+ }
101
+ }),
102
+ )
101
103
  useDisposable('onSelectSuggestion', () =>
102
104
  manager.subscribe('onSelectSuggestion', props.onSelectSuggestion as (entry: unknown) => void),
103
105
  )
@@ -133,6 +135,7 @@ export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX
133
135
  <div className="post-controls">
134
136
  <span ref={loaderRef} style={{ display: 'inline-flex' }}>
135
137
  <Loader
138
+ // eslint-disable-next-line furystack/no-direct-get-value-in-render -- Initial opacity only; animated transitions handled by isLoadingSubscription via DOM
136
139
  style={{ width: '20px', height: '20px', opacity: manager.isLoading.getValue() ? '1' : '0' }}
137
140
  delay={0}
138
141
  borderWidth={4}
@@ -89,6 +89,7 @@ export const Tree: <T>(props: TreeProps<T>, children: ChildrenList) => JSX.Eleme
89
89
 
90
90
  const [flattenedNodes] = useObservable('flattenedNodes', props.treeService.flattenedNodes)
91
91
 
92
+ // eslint-disable-next-line furystack/require-use-observable-for-render -- Used as persistent ref, not reactive state; read and written synchronously in same render cycle
92
93
  const previousItemsRef = useDisposable('previousTreeItems', () => new ObservableValue<Set<unknown>>(new Set()))
93
94
  const previousItems = previousItemsRef.getValue()
94
95
  const currentItems = new Set<unknown>(flattenedNodes.map((n) => n.item))