@talelk/newui-templates 0.0.0-stage → 1.0.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.
- package/LICENSE +202 -0
- package/README.md +25 -2
- package/package.json +37 -3
- package/src/exports.test.ts +12 -0
- package/src/index.ts +1 -0
- package/src/templates/AppShell.test.tsx +74 -0
- package/src/templates/AppShell.tsx +241 -0
- package/src/templates/AuthLayout.test.tsx +20 -0
- package/src/templates/AuthLayout.tsx +42 -0
- package/src/templates/PaneLayout/PaneLayout.test.tsx +80 -0
- package/src/templates/PaneLayout/PaneLayout.tsx +226 -0
- package/src/templates/PaneLayout/index.ts +1 -0
- package/src/templates/index.ts +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @talelk/newui-templates
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Exactly three templates: one responsive workspace `AppShell`, a two-pane
|
|
4
|
+
`PaneLayout`, and a centered `AuthLayout`.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import { AppShell, PaneLayout } from '@talelk/newui-templates'
|
|
8
|
+
|
|
9
|
+
<AppShell
|
|
10
|
+
brand={<Brand />}
|
|
11
|
+
tenant={<TenantSwitcher />}
|
|
12
|
+
navigation={groups}
|
|
13
|
+
settings={<SettingsAction />}
|
|
14
|
+
account={<AccountMenu />}
|
|
15
|
+
title="Projects"
|
|
16
|
+
>
|
|
17
|
+
<ProjectsPage />
|
|
18
|
+
</AppShell>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`AppShell` owns the responsive behavior and the single sidebar. The app owns its
|
|
22
|
+
logo, tenant switcher, routes and account actions. There is no separate rail,
|
|
23
|
+
desktop shell, mobile shell or alternate app layout.
|
|
24
|
+
|
|
25
|
+
The package depends downward on `@talelk/newui-atoms`, `@talelk/newui-molecules` and
|
|
26
|
+
`@talelk/newui-organisms`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@talelk/newui-templates",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The three page templates: AppShell, PaneLayout and AuthLayout.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"solid": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"default": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@talelk/newui-atoms": "1.0.0",
|
|
18
|
+
"@talelk/newui-molecules": "1.0.0",
|
|
19
|
+
"@talelk/newui-organisms": "1.0.0"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"solid-js": "^1.9.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"solid-js": "^1.9.0"
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"license": "Apache-2.0",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/talelk/newui.git",
|
|
35
|
+
"directory": "packages/templates"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
39
|
+
}
|
|
6
40
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import * as templates from './index'
|
|
3
|
+
|
|
4
|
+
describe('@talelk/newui-templates', () => {
|
|
5
|
+
it('exports exactly the three supported page templates', () => {
|
|
6
|
+
expect(Object.keys(templates).sort()).toEqual([
|
|
7
|
+
'AppShell',
|
|
8
|
+
'AuthLayout',
|
|
9
|
+
'PaneLayout',
|
|
10
|
+
])
|
|
11
|
+
})
|
|
12
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './templates'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
2
|
+
import { SidebarItem } from '@talelk/newui-organisms'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { AppShell } from './AppShell'
|
|
6
|
+
|
|
7
|
+
describe('AppShell', () => {
|
|
8
|
+
it('uses one sidebar model for the mobile drawer, including tenant and bottom actions', async () => {
|
|
9
|
+
render(() => (
|
|
10
|
+
<AppShell
|
|
11
|
+
title="Projects"
|
|
12
|
+
brand={<span>Acme</span>}
|
|
13
|
+
brandCollapsed={<span>AC</span>}
|
|
14
|
+
tenant={<button type="button">Acme · Production</button>}
|
|
15
|
+
tenantCollapsed={
|
|
16
|
+
<button type="button" aria-label="Choose tenant">
|
|
17
|
+
AC
|
|
18
|
+
</button>
|
|
19
|
+
}
|
|
20
|
+
navigation={[
|
|
21
|
+
{
|
|
22
|
+
label: 'Workspace',
|
|
23
|
+
items: [
|
|
24
|
+
{ label: 'Projects', icon: 'lucide-folders', active: true },
|
|
25
|
+
{ label: 'People', icon: 'lucide-users' },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
label: 'Reports',
|
|
30
|
+
items: [
|
|
31
|
+
{ label: 'Revenue', icon: 'lucide-chart-no-axes-combined' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
]}
|
|
35
|
+
settings={
|
|
36
|
+
<SidebarItem label="Settings" icon="lucide-settings">
|
|
37
|
+
Settings
|
|
38
|
+
</SidebarItem>
|
|
39
|
+
}
|
|
40
|
+
account={
|
|
41
|
+
<SidebarItem label="Rhea Kapoor" icon="lucide-user-round">
|
|
42
|
+
Rhea Kapoor
|
|
43
|
+
</SidebarItem>
|
|
44
|
+
}
|
|
45
|
+
>
|
|
46
|
+
<p>Content</p>
|
|
47
|
+
</AppShell>
|
|
48
|
+
))
|
|
49
|
+
|
|
50
|
+
expect(
|
|
51
|
+
screen.getByRole('heading', { name: 'Projects', level: 1 }),
|
|
52
|
+
).toBeTruthy()
|
|
53
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
54
|
+
await userEvent.click(
|
|
55
|
+
screen.getByRole('button', { name: 'Open application navigation' }),
|
|
56
|
+
)
|
|
57
|
+
const drawer = screen.getByRole('dialog', {
|
|
58
|
+
name: 'Application navigation',
|
|
59
|
+
})
|
|
60
|
+
expect(drawer).toBeVisible()
|
|
61
|
+
expect(screen.getByText('Acme')).toBeTruthy()
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByRole('button', { name: 'Acme · Production' }),
|
|
64
|
+
).toBeTruthy()
|
|
65
|
+
expect(screen.getByText('Workspace')).toBeTruthy()
|
|
66
|
+
expect(screen.getByText('Reports')).toBeTruthy()
|
|
67
|
+
expect(screen.getByRole('button', { name: 'Settings' })).toBeTruthy()
|
|
68
|
+
expect(screen.getByRole('button', { name: 'Rhea Kapoor' })).toBeTruthy()
|
|
69
|
+
expect(document.querySelector('[data-slot="sidebar-rail"]')).toBeNull()
|
|
70
|
+
|
|
71
|
+
await userEvent.click(screen.getByRole('button', { name: 'People' }))
|
|
72
|
+
await waitFor(() => expect(drawer).not.toBeVisible())
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
createMediaQuery,
|
|
4
|
+
UIProvider,
|
|
5
|
+
useUI,
|
|
6
|
+
} from '@talelk/newui-atoms'
|
|
7
|
+
import { closeDialog, showDialog } from '@talelk/newui-atoms/internal'
|
|
8
|
+
import { type BreadcrumbItem, Breadcrumbs } from '@talelk/newui-molecules'
|
|
9
|
+
import { AppSidebar, type AppSidebarGroup } from '@talelk/newui-organisms'
|
|
10
|
+
import {
|
|
11
|
+
createEffect,
|
|
12
|
+
createSignal,
|
|
13
|
+
type JSX,
|
|
14
|
+
onCleanup,
|
|
15
|
+
onMount,
|
|
16
|
+
Show,
|
|
17
|
+
} from 'solid-js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The one application frame: a single, responsive, multi-section sidebar and
|
|
21
|
+
* the page it opens. There is no separate app rail and no second mobile shell.
|
|
22
|
+
*
|
|
23
|
+
* At desktop width the sidebar docks and can collapse to icons. On a narrow
|
|
24
|
+
* viewport the same sidebar becomes an expanded modal drawer; selection closes
|
|
25
|
+
* it and focus returns to the menu trigger. The brand, tenant picker, menu data,
|
|
26
|
+
* settings action and user menu are app-owned inputs, not an auth or tenancy
|
|
27
|
+
* backend hidden in the template.
|
|
28
|
+
*/
|
|
29
|
+
export interface AppShellProps {
|
|
30
|
+
/** Brand/logo at the top of the sidebar. */
|
|
31
|
+
brand: JSX.Element
|
|
32
|
+
/** Compact mark used when the sidebar is icon-only. Defaults to `brand`. */
|
|
33
|
+
brandCollapsed?: JSX.Element
|
|
34
|
+
/** Tenant/workspace switcher, usually a `Dropdown`. */
|
|
35
|
+
tenant?: JSX.Element
|
|
36
|
+
/** Compact tenant action when collapsed (usually the tenant logo). */
|
|
37
|
+
tenantCollapsed?: JSX.Element
|
|
38
|
+
/** Optional search/filter control, pinned above the scrolling menu. */
|
|
39
|
+
sidebarSearch?: JSX.Element
|
|
40
|
+
/** The labelled sections and icon rows of the one navigation sidebar. */
|
|
41
|
+
navigation?: AppSidebarGroup[]
|
|
42
|
+
/** Advanced navigation content, rendered inside the same sidebar. */
|
|
43
|
+
navigationContent?: JSX.Element | (() => JSX.Element)
|
|
44
|
+
/** Settings action pinned to the bottom of the sidebar. */
|
|
45
|
+
settings?: JSX.Element
|
|
46
|
+
/** User/account control pinned below settings. */
|
|
47
|
+
account?: JSX.Element
|
|
48
|
+
/** Compact account icon control, shown while collapsed. */
|
|
49
|
+
accountCollapsed?: JSX.Element
|
|
50
|
+
/** Controlled desktop icon-collapse state. Unset: shell owns it. */
|
|
51
|
+
collapsed?: boolean
|
|
52
|
+
onCollapsedChange?: (collapsed: boolean) => void
|
|
53
|
+
/** Accessible names of the sidebar and mobile drawer. */
|
|
54
|
+
navLabel?: string
|
|
55
|
+
/** Main page title, rendered as the document's h1. */
|
|
56
|
+
title: JSX.Element
|
|
57
|
+
description?: JSX.Element
|
|
58
|
+
breadcrumbs?: BreadcrumbItem[]
|
|
59
|
+
actions?: JSX.Element
|
|
60
|
+
/** Header actions shared by desktop and the mobile bar. */
|
|
61
|
+
headerActions?: JSX.Element
|
|
62
|
+
/** Title shown in the compact mobile bar. Defaults to `title`. */
|
|
63
|
+
mobileTitle?: JSX.Element
|
|
64
|
+
/** Content below the page heading. */
|
|
65
|
+
children: JSX.Element
|
|
66
|
+
/** Content below the page body, inside the page scroller. */
|
|
67
|
+
footer?: JSX.Element
|
|
68
|
+
class?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DESKTOP = '(min-width: 1024px)'
|
|
72
|
+
|
|
73
|
+
export function AppShell(props: AppShellProps): JSX.Element {
|
|
74
|
+
const desktop = createMediaQuery(DESKTOP)
|
|
75
|
+
const ui = useUI()
|
|
76
|
+
const [internalCollapsed, setInternalCollapsed] = createSignal(false)
|
|
77
|
+
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
|
78
|
+
const drawerPortal =
|
|
79
|
+
typeof document === 'undefined' ? undefined : document.createElement('div')
|
|
80
|
+
const collapsed = () => props.collapsed ?? internalCollapsed()
|
|
81
|
+
let drawer: HTMLDialogElement | undefined
|
|
82
|
+
let trigger: HTMLButtonElement | undefined
|
|
83
|
+
|
|
84
|
+
drawerPortal?.setAttribute('data-keep-drawer', '')
|
|
85
|
+
|
|
86
|
+
onMount(() => {
|
|
87
|
+
if (drawer && drawerPortal) drawer.append(drawerPortal)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const setCollapsed = (next: boolean) => {
|
|
91
|
+
if (props.collapsed === undefined) setInternalCollapsed(next)
|
|
92
|
+
props.onCollapsedChange?.(next)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const openDrawer = () => {
|
|
96
|
+
setDrawerOpen(true)
|
|
97
|
+
showDialog(drawer)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const closeDrawer = () => {
|
|
101
|
+
setDrawerOpen(false)
|
|
102
|
+
if (drawer?.open) closeDialog(drawer)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
createEffect(() => {
|
|
106
|
+
if (desktop() && drawerOpen()) closeDrawer()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
onCleanup(() => closeDialog(drawer))
|
|
110
|
+
|
|
111
|
+
const sidebarProps = () => ({
|
|
112
|
+
brand: props.brand,
|
|
113
|
+
brandCollapsed: props.brandCollapsed,
|
|
114
|
+
tenant: props.tenant,
|
|
115
|
+
tenantCollapsed: props.tenantCollapsed,
|
|
116
|
+
search: props.sidebarSearch,
|
|
117
|
+
groups: props.navigation,
|
|
118
|
+
children: props.navigationContent,
|
|
119
|
+
settings: props.settings,
|
|
120
|
+
account: props.account,
|
|
121
|
+
accountCollapsed: props.accountCollapsed,
|
|
122
|
+
ariaLabel: props.navLabel ?? 'Application navigation',
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const title = () => props.mobileTitle ?? props.title
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
data-slot="app-shell"
|
|
130
|
+
class={`flex h-dvh min-h-0 overflow-hidden bg-surface-gray-1 text-interface text-ink-gray-9 ${props.class ?? ''}`}
|
|
131
|
+
>
|
|
132
|
+
<Show when={desktop()}>
|
|
133
|
+
<div
|
|
134
|
+
data-slot="app-shell-sidebar"
|
|
135
|
+
class={`h-full shrink-0 overflow-hidden border-e border-outline-gray-1 bg-surface-sidebar transition-[width] duration-200 motion-reduce:transition-none ${collapsed() ? 'w-14' : 'w-64'}`}
|
|
136
|
+
>
|
|
137
|
+
<AppSidebar
|
|
138
|
+
{...sidebarProps()}
|
|
139
|
+
width="100%"
|
|
140
|
+
collapsedWidth="100%"
|
|
141
|
+
collapsed={collapsed()}
|
|
142
|
+
onCollapsedChange={setCollapsed}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</Show>
|
|
146
|
+
|
|
147
|
+
<div class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
|
148
|
+
<Show when={!desktop() || props.headerActions}>
|
|
149
|
+
<header
|
|
150
|
+
data-slot="app-shell-header"
|
|
151
|
+
class="flex min-h-12 shrink-0 items-center justify-between gap-control-gap border-b border-outline-gray-1 bg-surface-elevation-1 px-page-gutter-compact"
|
|
152
|
+
>
|
|
153
|
+
<div class="flex min-w-0 items-center gap-2">
|
|
154
|
+
<Show when={!desktop()}>
|
|
155
|
+
<button
|
|
156
|
+
ref={trigger}
|
|
157
|
+
type="button"
|
|
158
|
+
aria-label="Open application navigation"
|
|
159
|
+
aria-haspopup="dialog"
|
|
160
|
+
aria-expanded={drawerOpen()}
|
|
161
|
+
onClick={openDrawer}
|
|
162
|
+
class="grid size-9 shrink-0 place-items-center rounded-3 hover:bg-surface-gray-2 focus-visible:focus-ring"
|
|
163
|
+
>
|
|
164
|
+
<span aria-hidden="true" class="lucide-menu size-4" />
|
|
165
|
+
</button>
|
|
166
|
+
<span class="min-w-0 truncate font-medium">{title()}</span>
|
|
167
|
+
</Show>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
170
|
+
{props.headerActions}
|
|
171
|
+
</div>
|
|
172
|
+
</header>
|
|
173
|
+
</Show>
|
|
174
|
+
|
|
175
|
+
<main
|
|
176
|
+
id="app-shell-content"
|
|
177
|
+
data-slot="app-shell-content"
|
|
178
|
+
class="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
|
179
|
+
>
|
|
180
|
+
<a href="#app-shell-content" class="sr-only focus:not-sr-only">
|
|
181
|
+
Skip to workspace
|
|
182
|
+
</a>
|
|
183
|
+
<div class="mx-auto flex w-full max-w-[1440px] flex-col gap-section-gap p-page-gutter-compact pb-8 md:p-page-gutter">
|
|
184
|
+
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
185
|
+
<div class="min-w-0">
|
|
186
|
+
<Show when={props.breadcrumbs}>
|
|
187
|
+
{(items) => <Breadcrumbs items={items()} />}
|
|
188
|
+
</Show>
|
|
189
|
+
<h1 class="mt-1 text-heading">{props.title}</h1>
|
|
190
|
+
<Show when={props.description}>
|
|
191
|
+
<p class="mt-2 max-w-2xl text-ink-gray-7">
|
|
192
|
+
{props.description}
|
|
193
|
+
</p>
|
|
194
|
+
</Show>
|
|
195
|
+
</div>
|
|
196
|
+
<Show when={props.actions}>
|
|
197
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
198
|
+
{props.actions}
|
|
199
|
+
</div>
|
|
200
|
+
</Show>
|
|
201
|
+
</div>
|
|
202
|
+
{props.children}
|
|
203
|
+
<Show when={props.footer}>{props.footer}</Show>
|
|
204
|
+
</div>
|
|
205
|
+
</main>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<dialog
|
|
209
|
+
ref={drawer}
|
|
210
|
+
aria-label={props.navLabel ?? 'Application navigation'}
|
|
211
|
+
data-slot="app-shell-drawer"
|
|
212
|
+
onClose={() => {
|
|
213
|
+
setDrawerOpen(false)
|
|
214
|
+
trigger?.focus()
|
|
215
|
+
}}
|
|
216
|
+
onClick={(event) => {
|
|
217
|
+
if (event.target === drawer) return closeDrawer()
|
|
218
|
+
const target = event.target as Element
|
|
219
|
+
const control = target.closest('a, button, [data-navigates]')
|
|
220
|
+
if (control && !control.closest('[data-keep-drawer]')) closeDrawer()
|
|
221
|
+
}}
|
|
222
|
+
class="open:flex m-0 h-dvh max-h-none w-72 max-w-[calc(100vw-2rem)] flex-col overflow-y-auto border-e border-outline-gray-1 bg-surface-sidebar p-0 text-ink-gray-9 backdrop:bg-black-overlay-200"
|
|
223
|
+
>
|
|
224
|
+
<div class="flex shrink-0 justify-end border-b border-outline-gray-1 p-2">
|
|
225
|
+
<Button variant="ghost" size="sm" onClick={closeDrawer}>
|
|
226
|
+
Close navigation
|
|
227
|
+
</Button>
|
|
228
|
+
</div>
|
|
229
|
+
<UIProvider dir={ui?.dir()} portalTarget={drawerPortal}>
|
|
230
|
+
<AppSidebar
|
|
231
|
+
{...sidebarProps()}
|
|
232
|
+
width="100%"
|
|
233
|
+
collapsedWidth="100%"
|
|
234
|
+
collapsed={false}
|
|
235
|
+
collapsible={false}
|
|
236
|
+
/>
|
|
237
|
+
</UIProvider>
|
|
238
|
+
</dialog>
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { AuthLayout } from './AuthLayout'
|
|
4
|
+
|
|
5
|
+
describe('AuthLayout', () => {
|
|
6
|
+
it('centers public access content and allows an optional context panel', () => {
|
|
7
|
+
const { container } = render(() => (
|
|
8
|
+
<AuthLayout aside={<p>Welcome to Acme</p>} brand={<span>Acme</span>}>
|
|
9
|
+
<form aria-label="Sign in">
|
|
10
|
+
<button type="submit">Continue</button>
|
|
11
|
+
</form>
|
|
12
|
+
</AuthLayout>
|
|
13
|
+
))
|
|
14
|
+
|
|
15
|
+
expect(screen.getByText('Welcome to Acme')).toBeVisible()
|
|
16
|
+
expect(screen.getByText('Acme')).toBeVisible()
|
|
17
|
+
expect(screen.getByRole('form', { name: 'Sign in' })).toBeVisible()
|
|
18
|
+
expect(container.querySelectorAll('main')).toHaveLength(1)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type JSX, Show } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A focused frame for public access screens: sign-in, sign-up and recovery.
|
|
5
|
+
* The authentication card and the auth provider remain app-owned; this only
|
|
6
|
+
* supplies the responsive page canvas and brand slot.
|
|
7
|
+
*/
|
|
8
|
+
export interface AuthLayoutProps {
|
|
9
|
+
brand?: JSX.Element
|
|
10
|
+
/** Optional desktop-only context panel beside the auth form. */
|
|
11
|
+
aside?: JSX.Element
|
|
12
|
+
/** Optional top-edge actions, e.g. a theme switcher and a return link. */
|
|
13
|
+
header?: JSX.Element
|
|
14
|
+
children: JSX.Element
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function AuthLayout(props: AuthLayoutProps): JSX.Element {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
data-slot="auth-layout"
|
|
21
|
+
class="grid min-h-dvh bg-surface-gray-1 text-interface text-ink-gray-9"
|
|
22
|
+
classList={{ 'lg:grid-cols-2': Boolean(props.aside) }}
|
|
23
|
+
>
|
|
24
|
+
<Show when={props.aside}>
|
|
25
|
+
<aside class="hidden flex-col justify-between bg-surface-blue-8 p-6 text-ink-base lg:flex">
|
|
26
|
+
{props.aside}
|
|
27
|
+
</aside>
|
|
28
|
+
</Show>
|
|
29
|
+
<main class="relative grid min-h-dvh place-items-center p-4">
|
|
30
|
+
<Show when={props.header}>
|
|
31
|
+
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-3">
|
|
32
|
+
{props.header}
|
|
33
|
+
</div>
|
|
34
|
+
</Show>
|
|
35
|
+
<div class="flex w-full max-w-md flex-col items-center gap-4">
|
|
36
|
+
{props.brand}
|
|
37
|
+
{props.children}
|
|
38
|
+
</div>
|
|
39
|
+
</main>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { PaneLayout } from './PaneLayout'
|
|
5
|
+
|
|
6
|
+
function panes(props: Partial<Parameters<typeof PaneLayout>[0]> = {}) {
|
|
7
|
+
return render(() => (
|
|
8
|
+
<PaneLayout start={<p>Conversations</p>} end={<p>Message</p>} {...props} />
|
|
9
|
+
))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const handle = () =>
|
|
13
|
+
screen.getByRole('separator', { name: 'Resize panes' }) as HTMLElement
|
|
14
|
+
|
|
15
|
+
describe('PaneLayout', () => {
|
|
16
|
+
it('renders both panes and an adjustable separator', () => {
|
|
17
|
+
panes({ startSize: 40 })
|
|
18
|
+
expect(screen.getByText('Conversations')).toBeTruthy()
|
|
19
|
+
expect(screen.getByText('Message')).toBeTruthy()
|
|
20
|
+
expect(handle().getAttribute('aria-orientation')).toBe('vertical')
|
|
21
|
+
expect(handle().getAttribute('aria-valuenow')).toBe('40')
|
|
22
|
+
expect(handle().getAttribute('aria-valuemin')).toBe('20')
|
|
23
|
+
expect(handle().getAttribute('aria-valuemax')).toBe('70')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('moves with the keyboard, and jumps to the bounds', async () => {
|
|
27
|
+
panes({ startSize: 40 })
|
|
28
|
+
handle().focus()
|
|
29
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
30
|
+
expect(handle().getAttribute('aria-valuenow')).toBe('42')
|
|
31
|
+
await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}')
|
|
32
|
+
expect(handle().getAttribute('aria-valuenow')).toBe('32')
|
|
33
|
+
await userEvent.keyboard('{Home}')
|
|
34
|
+
expect(handle().getAttribute('aria-valuenow')).toBe('20')
|
|
35
|
+
await userEvent.keyboard('{End}')
|
|
36
|
+
expect(handle().getAttribute('aria-valuenow')).toBe('70')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('clamps the split to the minimum and maximum', async () => {
|
|
40
|
+
panes({ startSize: 40, minStartSize: 45, maxStartSize: 55 })
|
|
41
|
+
handle().focus()
|
|
42
|
+
await userEvent.keyboard('{Home}')
|
|
43
|
+
expect(handle().getAttribute('aria-valuenow')).toBe('45')
|
|
44
|
+
await userEvent.keyboard('{End}')
|
|
45
|
+
expect(handle().getAttribute('aria-valuenow')).toBe('55')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns to the default split on double-click', async () => {
|
|
49
|
+
panes({ startSize: 40 })
|
|
50
|
+
handle().focus()
|
|
51
|
+
await userEvent.keyboard('{End}')
|
|
52
|
+
expect(handle().getAttribute('aria-valuenow')).toBe('70')
|
|
53
|
+
await userEvent.dblClick(handle())
|
|
54
|
+
expect(handle().getAttribute('aria-valuenow')).toBe('40')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('shows one pane on a compact viewport, by `active`', () => {
|
|
58
|
+
const { unmount } = panes()
|
|
59
|
+
// Default: the list stays, the record is hidden until one is chosen.
|
|
60
|
+
expect(screen.getByText('Message').parentElement?.className).toContain(
|
|
61
|
+
'max-lg:hidden',
|
|
62
|
+
)
|
|
63
|
+
unmount()
|
|
64
|
+
|
|
65
|
+
panes({ active: 'end' })
|
|
66
|
+
expect(
|
|
67
|
+
screen.getByText('Conversations').parentElement?.className,
|
|
68
|
+
).toContain('max-lg:hidden')
|
|
69
|
+
expect(screen.getByText('Message').parentElement?.className).not.toContain(
|
|
70
|
+
'max-lg:hidden',
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('can be a fixed split with no control in the tab order', () => {
|
|
75
|
+
panes({ resizable: false, startSize: 30 })
|
|
76
|
+
expect(screen.queryByRole('separator')).toBeNull()
|
|
77
|
+
expect(screen.getByText('Conversations')).toBeTruthy()
|
|
78
|
+
expect(screen.getByText('Message')).toBeTruthy()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { cn } from '@talelk/newui-atoms'
|
|
2
|
+
import {
|
|
3
|
+
createEffect,
|
|
4
|
+
createSignal,
|
|
5
|
+
createUniqueId,
|
|
6
|
+
type JSX,
|
|
7
|
+
Show,
|
|
8
|
+
splitProps,
|
|
9
|
+
} from 'solid-js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Two panes side by side, with a draggable divider.
|
|
13
|
+
*
|
|
14
|
+
* This is the shape a workspace reaches for the moment it has a list and a
|
|
15
|
+
* record: mail's conversations beside the message, a ticket queue beside the
|
|
16
|
+
* ticket, a file tree beside the preview. Mail was hand-rolling it with a grid,
|
|
17
|
+
* a CSS variable and a *slider in the toolbar* — three pieces of plumbing per
|
|
18
|
+
* consumer, and a resize affordance in the wrong place.
|
|
19
|
+
*
|
|
20
|
+
* **The divider is the control.** `role="separator"` with a value: it drags with
|
|
21
|
+
* a pointer, moves with the arrow keys (Shift for a coarse step), jumps to the
|
|
22
|
+
* bounds with Home/End, and returns to the default split on double-click. Its
|
|
23
|
+
* value is the start pane's width as a percentage, so the split survives a
|
|
24
|
+
* resize of the window.
|
|
25
|
+
*
|
|
26
|
+
* **One pane on a narrow viewport.** Below `compactAt` the layout becomes a
|
|
27
|
+
* single column showing `active`; the app switches it when a record opens and
|
|
28
|
+
* renders its own back affordance. Nothing here hides content the reader cannot
|
|
29
|
+
* get back to.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type PaneBreakpoint = 'sm' | 'md' | 'lg'
|
|
33
|
+
|
|
34
|
+
/** Below the breakpoint one pane shows; above it, both. */
|
|
35
|
+
const HIDE_BELOW: Record<PaneBreakpoint, string> = {
|
|
36
|
+
sm: 'max-sm:hidden',
|
|
37
|
+
md: 'max-md:hidden',
|
|
38
|
+
lg: 'max-lg:hidden',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The divider is furniture: only the split breakpoint gets one. */
|
|
42
|
+
const DIVIDER_FROM: Record<PaneBreakpoint, string> = {
|
|
43
|
+
sm: 'hidden sm:block',
|
|
44
|
+
md: 'hidden md:block',
|
|
45
|
+
lg: 'hidden lg:block',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Two panes with a resize column. */
|
|
49
|
+
const SPLIT_FROM: Record<PaneBreakpoint, string> = {
|
|
50
|
+
sm: 'sm:grid-cols-[var(--pane-start,36%)_auto_minmax(0,1fr)]',
|
|
51
|
+
md: 'md:grid-cols-[var(--pane-start,36%)_auto_minmax(0,1fr)]',
|
|
52
|
+
lg: 'lg:grid-cols-[var(--pane-start,36%)_auto_minmax(0,1fr)]',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Two panes without one — the consumer owns the border. */
|
|
56
|
+
const FIXED_FROM: Record<PaneBreakpoint, string> = {
|
|
57
|
+
sm: 'sm:grid-cols-[var(--pane-start,36%)_minmax(0,1fr)]',
|
|
58
|
+
md: 'md:grid-cols-[var(--pane-start,36%)_minmax(0,1fr)]',
|
|
59
|
+
lg: 'lg:grid-cols-[var(--pane-start,36%)_minmax(0,1fr)]',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PaneLayoutProps {
|
|
63
|
+
/** The first pane: a list, a tree, a queue. */
|
|
64
|
+
start: JSX.Element
|
|
65
|
+
/** The second pane: the record the start pane selects. */
|
|
66
|
+
end: JSX.Element
|
|
67
|
+
/** Percent of the frame the start pane takes. Default `36`. */
|
|
68
|
+
startSize?: number
|
|
69
|
+
/** Narrowest the split may go, in percent. Default `20`. */
|
|
70
|
+
minStartSize?: number
|
|
71
|
+
/** Widest the split may go, in percent. Default `70`. */
|
|
72
|
+
maxStartSize?: number
|
|
73
|
+
/** Below this breakpoint only one pane shows. Default `lg`. */
|
|
74
|
+
compactAt?: PaneBreakpoint
|
|
75
|
+
/** Which pane a compact viewport shows. Default `start`. */
|
|
76
|
+
active?: 'start' | 'end'
|
|
77
|
+
/** Allow dragging the divider. Default `true`. */
|
|
78
|
+
resizable?: boolean
|
|
79
|
+
/** Accessible name of the resize control. Default `Resize panes`. */
|
|
80
|
+
resizeLabel?: string
|
|
81
|
+
class?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function PaneLayout(props: PaneLayoutProps): JSX.Element {
|
|
85
|
+
const [local] = splitProps(props, [
|
|
86
|
+
'start',
|
|
87
|
+
'end',
|
|
88
|
+
'startSize',
|
|
89
|
+
'minStartSize',
|
|
90
|
+
'maxStartSize',
|
|
91
|
+
'compactAt',
|
|
92
|
+
'active',
|
|
93
|
+
'resizable',
|
|
94
|
+
'resizeLabel',
|
|
95
|
+
'class',
|
|
96
|
+
])
|
|
97
|
+
const breakpoint = () => local.compactAt ?? 'lg'
|
|
98
|
+
const resizable = () => local.resizable !== false
|
|
99
|
+
const min = () => local.minStartSize ?? 20
|
|
100
|
+
const max = () => local.maxStartSize ?? 70
|
|
101
|
+
const clamp = (value: number) =>
|
|
102
|
+
Math.min(max(), Math.max(min(), Math.round(value * 10) / 10))
|
|
103
|
+
|
|
104
|
+
const [size, setSize] = createSignal<number | undefined>(undefined)
|
|
105
|
+
const split = () => clamp(size() ?? local.startSize ?? 36)
|
|
106
|
+
const startId = createUniqueId()
|
|
107
|
+
const label = () => local.resizeLabel ?? 'Resize panes'
|
|
108
|
+
|
|
109
|
+
let frame: HTMLDivElement | undefined
|
|
110
|
+
// The CSS variable rather than an inline width: the compact layout is a
|
|
111
|
+
// single `1fr` column, and an inline width would win over that rule.
|
|
112
|
+
createEffect(() => {
|
|
113
|
+
frame?.style.setProperty('--pane-start', `${split()}%`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const [dragging, setDragging] = createSignal(false)
|
|
117
|
+
let originX = 0
|
|
118
|
+
let originSize = 0
|
|
119
|
+
let frameWidth = 0
|
|
120
|
+
|
|
121
|
+
const beginDrag = (event: PointerEvent) => {
|
|
122
|
+
if (event.button !== 0) return
|
|
123
|
+
const element = event.currentTarget as HTMLElement
|
|
124
|
+
originX = event.clientX
|
|
125
|
+
originSize = split()
|
|
126
|
+
frameWidth = frame?.getBoundingClientRect().width ?? 0
|
|
127
|
+
if (frameWidth === 0) return
|
|
128
|
+
event.preventDefault()
|
|
129
|
+
element.setPointerCapture(event.pointerId)
|
|
130
|
+
setDragging(true)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const moveDrag = (event: PointerEvent) => {
|
|
134
|
+
if (!dragging()) return
|
|
135
|
+
const delta = ((event.clientX - originX) / frameWidth) * 100
|
|
136
|
+
setSize(clamp(originSize + delta))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const endDrag = (event: PointerEvent) => {
|
|
140
|
+
if (!dragging()) return
|
|
141
|
+
setDragging(false)
|
|
142
|
+
const element = event.currentTarget as HTMLElement
|
|
143
|
+
if (element.hasPointerCapture(event.pointerId))
|
|
144
|
+
element.releasePointerCapture(event.pointerId)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
148
|
+
const step = event.shiftKey ? 10 : 2
|
|
149
|
+
if (event.key === 'ArrowLeft') {
|
|
150
|
+
event.preventDefault()
|
|
151
|
+
setSize(clamp(split() - step))
|
|
152
|
+
} else if (event.key === 'ArrowRight') {
|
|
153
|
+
event.preventDefault()
|
|
154
|
+
setSize(clamp(split() + step))
|
|
155
|
+
} else if (event.key === 'Home') {
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
setSize(clamp(min()))
|
|
158
|
+
} else if (event.key === 'End') {
|
|
159
|
+
event.preventDefault()
|
|
160
|
+
setSize(clamp(max()))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div
|
|
166
|
+
ref={frame}
|
|
167
|
+
data-slot="pane-layout"
|
|
168
|
+
data-resizing={dragging() ? '' : undefined}
|
|
169
|
+
class={cn(
|
|
170
|
+
'grid min-h-0 min-w-0 flex-1',
|
|
171
|
+
resizable() ? SPLIT_FROM[breakpoint()] : FIXED_FROM[breakpoint()],
|
|
172
|
+
dragging() && 'cursor-col-resize select-none',
|
|
173
|
+
local.class,
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
<div
|
|
177
|
+
id={startId}
|
|
178
|
+
data-slot="pane-start"
|
|
179
|
+
class={cn(
|
|
180
|
+
'min-h-0 min-w-0',
|
|
181
|
+
local.active === 'end' && HIDE_BELOW[breakpoint()],
|
|
182
|
+
)}
|
|
183
|
+
>
|
|
184
|
+
{local.start}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<Show when={resizable()}>
|
|
188
|
+
<div
|
|
189
|
+
data-slot="pane-resizer"
|
|
190
|
+
class={cn(
|
|
191
|
+
'relative w-px shrink-0 bg-outline-gray-1',
|
|
192
|
+
DIVIDER_FROM[breakpoint()],
|
|
193
|
+
)}
|
|
194
|
+
>
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
role="separator"
|
|
198
|
+
aria-orientation="vertical"
|
|
199
|
+
aria-label={label()}
|
|
200
|
+
aria-controls={startId}
|
|
201
|
+
aria-valuenow={Math.round(split())}
|
|
202
|
+
aria-valuemin={min()}
|
|
203
|
+
aria-valuemax={max()}
|
|
204
|
+
class="absolute inset-y-0 -end-2 -start-2 z-raised cursor-col-resize rounded-3 focus-visible:focus-ring"
|
|
205
|
+
onPointerDown={beginDrag}
|
|
206
|
+
onPointerMove={moveDrag}
|
|
207
|
+
onPointerUp={endDrag}
|
|
208
|
+
onPointerCancel={endDrag}
|
|
209
|
+
onDblClick={() => setSize(undefined)}
|
|
210
|
+
onKeyDown={onKeyDown}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
</Show>
|
|
214
|
+
|
|
215
|
+
<div
|
|
216
|
+
data-slot="pane-end"
|
|
217
|
+
class={cn(
|
|
218
|
+
'min-h-0 min-w-0',
|
|
219
|
+
local.active !== 'end' && HIDE_BELOW[breakpoint()],
|
|
220
|
+
)}
|
|
221
|
+
>
|
|
222
|
+
{local.end}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './PaneLayout'
|