@electerm/electerm-react 5.5.9 → 5.5.15
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/client/common/download.jsx +12 -0
- package/client/common/resolve.js +20 -14
- package/client/components/ai/ai-history-item.jsx +1 -1
- package/client/components/ai/ai-history.jsx +1 -0
- package/client/components/ai/ai-history.styl +23 -0
- package/client/components/bookmark-form/common/color-picker.styl +3 -0
- package/client/components/common/remote-pointer.js +49 -0
- package/client/components/common/remote-session-shell.jsx +137 -0
- package/client/components/common/remote-session-shell.styl +80 -0
- package/client/components/common/responsive-tabs.jsx +89 -0
- package/client/components/common/responsive-tabs.styl +34 -0
- package/client/components/rdp/rdp-session.jsx +94 -124
- package/client/components/rdp/rdp.styl +13 -36
- package/client/components/session/session.styl +0 -16
- package/client/components/setting-panel/setting-modal.jsx +8 -3
- package/client/components/sftp/sftp-entry.jsx +26 -0
- package/client/components/spice/spice-session.jsx +69 -72
- package/client/components/spice/spice.styl +10 -19
- package/client/components/theme/theme-form.jsx +18 -21
- package/client/components/theme/theme-form.styl +8 -10
- package/client/components/vnc/vnc-session.jsx +72 -104
- package/client/components/vnc/vnc.styl +25 -16
- package/client/store/setting.js +4 -0
- package/package.json +1 -1
|
@@ -7,6 +7,18 @@ import { chooseSaveDirectory } from './choose-save-folder'
|
|
|
7
7
|
import { DownloadOutlined } from '@ant-design/icons'
|
|
8
8
|
|
|
9
9
|
export default async function download (filename, text) {
|
|
10
|
+
// iOS WKWebView: sandbox targets are invisible to the Files app and
|
|
11
|
+
// blob-anchor downloads are a no-op — save to Documents (visible in
|
|
12
|
+
// Files) via the native bridge instead (see
|
|
13
|
+
// web-components/native-file-save.js).
|
|
14
|
+
if (window.et && window.et.isWebApp && window.et.saveTextNative) {
|
|
15
|
+
try {
|
|
16
|
+
await window.et.saveTextNative(filename, text)
|
|
17
|
+
return
|
|
18
|
+
} catch (e) {
|
|
19
|
+
console.log('native save failed, falling back to sandbox save:', e)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
10
22
|
const opts = window.et.isWebApp
|
|
11
23
|
? { filename, content: text }
|
|
12
24
|
: undefined
|
package/client/common/resolve.js
CHANGED
|
@@ -13,24 +13,30 @@ export const isWslDistroRoot = (path) => {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export default function resolve (basePath, nameOrDot) {
|
|
16
|
+
// Callers can hand this a value that is not a path yet (a session path that
|
|
17
|
+
// is still loading, a missing tab option, ...). Reading `.includes` off it
|
|
18
|
+
// used to throw and take the whole renderer down with a white screen, so
|
|
19
|
+
// anything that is not a string is treated as an empty path here.
|
|
20
|
+
const base = typeof basePath === 'string' ? basePath : ''
|
|
21
|
+
const name = typeof nameOrDot === 'string' ? nameOrDot : ''
|
|
16
22
|
const hasWinDrive = (path) => /^[a-zA-Z]:/.test(path)
|
|
17
|
-
const isWin =
|
|
23
|
+
const isWin = base.includes('\\') || name.includes('\\') || hasWinDrive(base) || hasWinDrive(name)
|
|
18
24
|
const sep = isWin ? '\\' : '/'
|
|
19
|
-
if (/^[a-zA-Z]:/.test(
|
|
20
|
-
return
|
|
25
|
+
if (/^[a-zA-Z]:/.test(name)) {
|
|
26
|
+
return name.replace(/^\//, '').replace(/\//g, sep)
|
|
21
27
|
}
|
|
22
|
-
if (
|
|
23
|
-
return
|
|
28
|
+
if (name.startsWith('/')) {
|
|
29
|
+
return name.replace(/\\/g, sep)
|
|
24
30
|
}
|
|
25
|
-
if (
|
|
26
|
-
return
|
|
31
|
+
if (name.startsWith('\\\\')) {
|
|
32
|
+
return name
|
|
27
33
|
}
|
|
28
|
-
if (
|
|
29
|
-
if (isWslDistroRoot(
|
|
34
|
+
if (name === '..') {
|
|
35
|
+
if (isWslDistroRoot(base)) {
|
|
30
36
|
return '/'
|
|
31
37
|
}
|
|
32
|
-
const baseEndsWithSep =
|
|
33
|
-
const parts =
|
|
38
|
+
const baseEndsWithSep = base.endsWith(sep)
|
|
39
|
+
const parts = base.split(sep)
|
|
34
40
|
if (parts.length > 1) {
|
|
35
41
|
parts.pop()
|
|
36
42
|
if (isWin && parts.length === 1) {
|
|
@@ -40,10 +46,10 @@ export default function resolve (basePath, nameOrDot) {
|
|
|
40
46
|
}
|
|
41
47
|
return '/'
|
|
42
48
|
}
|
|
43
|
-
if (isWslDistroRoot(
|
|
44
|
-
return
|
|
49
|
+
if (isWslDistroRoot(base) && !base.endsWith(sep)) {
|
|
50
|
+
return base + sep + name
|
|
45
51
|
}
|
|
46
|
-
const result =
|
|
52
|
+
const result = base.endsWith(sep) ? base + name : base + sep + name
|
|
47
53
|
return isWin && result.length === 3 && result.endsWith(':\\') ? '/' : result
|
|
48
54
|
}
|
|
49
55
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// AI history items (ai-history.jsx / ai-history-item.jsx)
|
|
2
|
+
//
|
|
3
|
+
// Each entry is an antd Tag: nowrap, no max-width, and the text is a user
|
|
4
|
+
// written description up to 50 chars long. Space renders an inline-flex box,
|
|
5
|
+
// which shrink-to-fits its widest *unwrappable* child — so one long entry
|
|
6
|
+
// pushes the whole panel into horizontal scrolling instead of using the width
|
|
7
|
+
// it already has. The label gets its own ellipsis box so the tag can shrink to
|
|
8
|
+
// the available width while the close icon stays reachable.
|
|
9
|
+
.ai-bookmark-history
|
|
10
|
+
.ant-space
|
|
11
|
+
display flex
|
|
12
|
+
.ant-space-item
|
|
13
|
+
min-width 0
|
|
14
|
+
.ant-tag
|
|
15
|
+
max-width 100%
|
|
16
|
+
|
|
17
|
+
.ai-history-label
|
|
18
|
+
display inline-block
|
|
19
|
+
// leave room for the tag close icon
|
|
20
|
+
max-width calc(100% - 16px)
|
|
21
|
+
overflow hidden
|
|
22
|
+
text-overflow ellipsis
|
|
23
|
+
vertical-align middle
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
width 92px
|
|
11
11
|
.color-picker-box
|
|
12
12
|
width 420px
|
|
13
|
+
// the popover is portaled to the body, a 420px box simply falls off a phone
|
|
14
|
+
// screen (measured: the swatch list and the antd picker still fit at 280px)
|
|
15
|
+
max-width calc(100vw - 40px)
|
|
13
16
|
|
|
14
17
|
.color-picker-defaults
|
|
15
18
|
width 200px
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pointer coordinate mapping for remote desktop surfaces.
|
|
3
|
+
*
|
|
4
|
+
* The remote surface is rendered with `object-fit: contain`, so the element
|
|
5
|
+
* box and the drawn surface rarely match: one axis gets letterbox margins.
|
|
6
|
+
* Deriving the scale from the element box alone therefore sends the pointer
|
|
7
|
+
* to the wrong place (the classic "only the top-left region reacts" bug), and
|
|
8
|
+
* each session used to re-implement that math. Shared here instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function clamp (v, max) {
|
|
12
|
+
return Math.max(0, Math.min(v, max))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* visible remote surface rect inside a `object-fit: contain` element
|
|
17
|
+
*/
|
|
18
|
+
export function containRect (el, sourceWidth, sourceHeight) {
|
|
19
|
+
if (!el || !sourceWidth || !sourceHeight) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
const rect = el.getBoundingClientRect()
|
|
23
|
+
if (!rect.width || !rect.height) {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
const scale = Math.min(
|
|
27
|
+
rect.width / sourceWidth,
|
|
28
|
+
rect.height / sourceHeight
|
|
29
|
+
)
|
|
30
|
+
return {
|
|
31
|
+
scale,
|
|
32
|
+
left: rect.left + (rect.width - sourceWidth * scale) / 2,
|
|
33
|
+
top: rect.top + (rect.height - sourceHeight * scale) / 2
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* pointer event -> remote framebuffer pixel, clamped to the surface
|
|
39
|
+
*/
|
|
40
|
+
export function eventToRemotePos (e, el, sourceWidth, sourceHeight) {
|
|
41
|
+
const rect = containRect(el, sourceWidth, sourceHeight)
|
|
42
|
+
if (!rect || !rect.scale) {
|
|
43
|
+
return { x: 0, y: 0 }
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
x: clamp(Math.round((e.clientX - rect.left) / rect.scale), sourceWidth - 1),
|
|
47
|
+
y: clamp(Math.round((e.clientY - rect.top) / rect.scale), sourceHeight - 1)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared shell for remote desktop sessions (RDP / VNC / SPICE).
|
|
3
|
+
*
|
|
4
|
+
* Layout is a plain flex column:
|
|
5
|
+
*
|
|
6
|
+
* [ control bar ] auto height, wraps when the window is narrow
|
|
7
|
+
* [ viewport ] flex: 1, holds the remote surface
|
|
8
|
+
*
|
|
9
|
+
* The control bar owns a real height instead of being positioned over the
|
|
10
|
+
* surface, so the old "- 10 / - 80" guesses are gone and the surface can
|
|
11
|
+
* never be covered by the bar.
|
|
12
|
+
*
|
|
13
|
+
* The viewport is measured with a ResizeObserver rather than derived from
|
|
14
|
+
* the layout props, because in fullscreen the session is re-parented to the
|
|
15
|
+
* whole window by term-fullscreen.styl and those props are stale. Measured
|
|
16
|
+
* size is what lets the remote surface scale up to fill the screen.
|
|
17
|
+
*/
|
|
18
|
+
import { PureComponent, createRef } from 'react'
|
|
19
|
+
import { Spin } from 'antd'
|
|
20
|
+
import classnames from 'classnames'
|
|
21
|
+
import './remote-session-shell.styl'
|
|
22
|
+
|
|
23
|
+
export default class RemoteSessionShell extends PureComponent {
|
|
24
|
+
constructor (props) {
|
|
25
|
+
super(props)
|
|
26
|
+
this.viewRef = createRef()
|
|
27
|
+
this.state = {
|
|
28
|
+
width: 0,
|
|
29
|
+
height: 0
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
componentDidMount () {
|
|
34
|
+
this.observeViewport()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
componentDidUpdate () {
|
|
38
|
+
// the viewport node is stable, this only re-observes if React swapped it
|
|
39
|
+
this.observeViewport()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
componentWillUnmount () {
|
|
43
|
+
this.unobserveViewport()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
observeViewport = () => {
|
|
47
|
+
const el = this.viewRef.current
|
|
48
|
+
if (!el || this.viewEl === el) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
this.unobserveViewport()
|
|
52
|
+
this.viewEl = el
|
|
53
|
+
this.observer = new ResizeObserver(this.measureViewport)
|
|
54
|
+
this.observer.observe(el)
|
|
55
|
+
this.measureViewport()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
unobserveViewport = () => {
|
|
59
|
+
if (this.observer) {
|
|
60
|
+
this.observer.disconnect()
|
|
61
|
+
this.observer = null
|
|
62
|
+
}
|
|
63
|
+
this.viewEl = null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
measureViewport = () => {
|
|
67
|
+
const el = this.viewRef.current
|
|
68
|
+
if (!el) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const { clientWidth: width, clientHeight: height } = el
|
|
72
|
+
const { width: prevWidth, height: prevHeight } = this.state
|
|
73
|
+
if (width === prevWidth && height === prevHeight) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
this.setState({
|
|
77
|
+
width,
|
|
78
|
+
height
|
|
79
|
+
})
|
|
80
|
+
this.props.onViewportResize?.(width, height)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// measured viewport box, 0 until the first layout pass
|
|
84
|
+
getViewportSize = () => {
|
|
85
|
+
const { width, height } = this.state
|
|
86
|
+
return {
|
|
87
|
+
width,
|
|
88
|
+
height
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
render () {
|
|
93
|
+
const {
|
|
94
|
+
loading,
|
|
95
|
+
fit,
|
|
96
|
+
wrapClassName,
|
|
97
|
+
controlLeft,
|
|
98
|
+
controlRight,
|
|
99
|
+
floatControl,
|
|
100
|
+
overlay,
|
|
101
|
+
children
|
|
102
|
+
} = this.props
|
|
103
|
+
const cls = classnames(
|
|
104
|
+
'remote-session-wrap',
|
|
105
|
+
'session-v-wrap',
|
|
106
|
+
wrapClassName,
|
|
107
|
+
{
|
|
108
|
+
fit: !!fit
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
return (
|
|
112
|
+
<Spin
|
|
113
|
+
spinning={!!loading}
|
|
114
|
+
className='remote-session-spin'
|
|
115
|
+
>
|
|
116
|
+
<div className={cls}>
|
|
117
|
+
<div className='remote-session-control session-v-info'>
|
|
118
|
+
<div className='remote-session-control-left'>
|
|
119
|
+
{controlLeft}
|
|
120
|
+
</div>
|
|
121
|
+
<div className='remote-session-control-right'>
|
|
122
|
+
{controlRight}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div
|
|
126
|
+
className='remote-session-viewport'
|
|
127
|
+
ref={this.viewRef}
|
|
128
|
+
>
|
|
129
|
+
{children}
|
|
130
|
+
</div>
|
|
131
|
+
{overlay}
|
|
132
|
+
{floatControl}
|
|
133
|
+
</div>
|
|
134
|
+
</Spin>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// shared remote session (rdp/vnc/spice) shell, see remote-session-shell.jsx
|
|
2
|
+
|
|
3
|
+
.remote-session-wrap
|
|
4
|
+
display flex
|
|
5
|
+
flex-direction column
|
|
6
|
+
width 100%
|
|
7
|
+
height 100%
|
|
8
|
+
min-height 0
|
|
9
|
+
background var(--main)
|
|
10
|
+
|
|
11
|
+
// antd Spin root carries the className, both levels must pass the height on
|
|
12
|
+
// or the session inside has nothing to resolve `height: 100%` against
|
|
13
|
+
.remote-session-spin
|
|
14
|
+
height 100%
|
|
15
|
+
> .ant-spin-container
|
|
16
|
+
height 100%
|
|
17
|
+
|
|
18
|
+
// control bar: one row while there is room, wraps to more rows when the
|
|
19
|
+
// window is narrow, so no action icon ever gets pushed out of reach
|
|
20
|
+
.remote-session-control
|
|
21
|
+
position relative
|
|
22
|
+
flex 0 0 auto
|
|
23
|
+
display flex
|
|
24
|
+
flex-wrap wrap
|
|
25
|
+
align-items center
|
|
26
|
+
justify-content space-between
|
|
27
|
+
row-gap 4px
|
|
28
|
+
padding 4px 8px
|
|
29
|
+
z-index 300
|
|
30
|
+
|
|
31
|
+
.remote-session-control-left
|
|
32
|
+
.remote-session-control-right
|
|
33
|
+
display flex
|
|
34
|
+
flex-wrap wrap
|
|
35
|
+
align-items center
|
|
36
|
+
row-gap 4px
|
|
37
|
+
min-width 0
|
|
38
|
+
|
|
39
|
+
.remote-session-control-right
|
|
40
|
+
margin-left auto
|
|
41
|
+
|
|
42
|
+
.remote-session-viewport
|
|
43
|
+
position relative
|
|
44
|
+
flex 1 1 auto
|
|
45
|
+
display flex
|
|
46
|
+
min-width 0
|
|
47
|
+
min-height 0
|
|
48
|
+
overflow auto
|
|
49
|
+
// scrolled when fit is off, i.e. the remote surface at its own size
|
|
50
|
+
&::-webkit-scrollbar
|
|
51
|
+
width 16px
|
|
52
|
+
height 16px
|
|
53
|
+
background var(--main-darker)
|
|
54
|
+
&::-webkit-scrollbar-track
|
|
55
|
+
background var(--main-darker)
|
|
56
|
+
box-shadow inset 0 0 5px var(--main-darker)
|
|
57
|
+
&::-webkit-scrollbar-thumb
|
|
58
|
+
background var(--primary)
|
|
59
|
+
border-radius 0
|
|
60
|
+
&::-webkit-scrollbar-corner
|
|
61
|
+
background var(--main-darker)
|
|
62
|
+
> *
|
|
63
|
+
flex 0 0 auto
|
|
64
|
+
margin auto
|
|
65
|
+
|
|
66
|
+
// fit: scale the remote surface to the viewport, keeping the source aspect
|
|
67
|
+
// ratio, so at most one axis keeps empty margin and fullscreen has as little
|
|
68
|
+
// empty space as the ratio allows
|
|
69
|
+
.remote-session-wrap.fit
|
|
70
|
+
.remote-session-viewport
|
|
71
|
+
overflow hidden
|
|
72
|
+
> *
|
|
73
|
+
width 100%
|
|
74
|
+
height 100%
|
|
75
|
+
margin 0
|
|
76
|
+
|
|
77
|
+
// surface that should always fill the viewport (noVNC sizes its canvas itself)
|
|
78
|
+
.remote-session-fill
|
|
79
|
+
width 100%
|
|
80
|
+
height 100%
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* antd Tabs wrapper that collapses to a single tab selector when narrow.
|
|
3
|
+
*
|
|
4
|
+
* A row of tabs is the first thing to break down on a phone: antd keeps them
|
|
5
|
+
* on one line and hides the overflow behind its own dropdown, so the active
|
|
6
|
+
* tab ends up buried. On mobile this wrapper renders only the active tab with
|
|
7
|
+
* a down arrow on the right instead — click it to pick another tab from a
|
|
8
|
+
* dropdown.
|
|
9
|
+
*
|
|
10
|
+
* - desktop: every prop is forwarded to antd Tabs untouched, so it is a
|
|
11
|
+
* drop-in replacement (`items` / `activeKey` / `onChange` / `type` / `size`
|
|
12
|
+
* / `destroyOnHidden` ... all behave as before)
|
|
13
|
+
* - mobile: renders the active tab only; the tabs come from the same `items`
|
|
14
|
+
* array, so callers don't have to describe their tabs twice. The caller's
|
|
15
|
+
* `className` goes on the wrapper element (the button carries the
|
|
16
|
+
* `responsive-tabs-mobile` class), so a class like `.setting-tabs` keeps
|
|
17
|
+
* positioning the selector exactly where it positioned the tab bar.
|
|
18
|
+
*
|
|
19
|
+
* Which breakpoint counts as mobile is decided by the caller via `isMobile`
|
|
20
|
+
* (the setting panel collapses at its own 800px drill-down breakpoint, the
|
|
21
|
+
* theme form at the global 600px one). When omitted it follows the global
|
|
22
|
+
* `store.isMobile`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { auto } from 'manate/react'
|
|
26
|
+
import { Dropdown, Tabs } from 'antd'
|
|
27
|
+
import { DownOutlined } from '@ant-design/icons'
|
|
28
|
+
import classNames from 'classnames'
|
|
29
|
+
import './responsive-tabs.styl'
|
|
30
|
+
|
|
31
|
+
export default auto(function ResponsiveTabs (props) {
|
|
32
|
+
const {
|
|
33
|
+
isMobile,
|
|
34
|
+
items = [],
|
|
35
|
+
activeKey,
|
|
36
|
+
onChange,
|
|
37
|
+
className,
|
|
38
|
+
...rest
|
|
39
|
+
} = props
|
|
40
|
+
const mobile = isMobile === undefined
|
|
41
|
+
? window.store.isMobile
|
|
42
|
+
: isMobile
|
|
43
|
+
if (!mobile) {
|
|
44
|
+
return (
|
|
45
|
+
<Tabs
|
|
46
|
+
{...rest}
|
|
47
|
+
className={className}
|
|
48
|
+
items={items}
|
|
49
|
+
activeKey={activeKey}
|
|
50
|
+
onChange={onChange}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
const current = items.find(item => item.key === activeKey) || items[0]
|
|
55
|
+
if (!current) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
// item level onClick, same as the session tabs dropdown (tabs/index.jsx):
|
|
59
|
+
// it fires with the item's own key and keeps onChange single argument
|
|
60
|
+
const menu = {
|
|
61
|
+
selectable: true,
|
|
62
|
+
selectedKeys: [String(activeKey)],
|
|
63
|
+
items: items.map(item => {
|
|
64
|
+
return {
|
|
65
|
+
key: item.key,
|
|
66
|
+
label: item.label,
|
|
67
|
+
disabled: item.disabled,
|
|
68
|
+
onClick: () => onChange && onChange(item.key)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
return (
|
|
73
|
+
<div className={classNames('responsive-tabs-mobile-wrap', className)}>
|
|
74
|
+
<Dropdown
|
|
75
|
+
menu={menu}
|
|
76
|
+
trigger={['click']}
|
|
77
|
+
placement='bottomLeft'
|
|
78
|
+
>
|
|
79
|
+
<button
|
|
80
|
+
type='button'
|
|
81
|
+
className='responsive-tabs-mobile'
|
|
82
|
+
>
|
|
83
|
+
<span className='responsive-tabs-mobile-label elli'>{current.label}</span>
|
|
84
|
+
<DownOutlined className='responsive-tabs-mobile-icon' />
|
|
85
|
+
</button>
|
|
86
|
+
</Dropdown>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// common/responsive-tabs — the mobile (collapsed) tab bar, see responsive-tabs.jsx
|
|
2
|
+
//
|
|
3
|
+
// .responsive-tabs-mobile-wrap is the element the caller's className lands on
|
|
4
|
+
// (e.g. `.setting-tabs`, which positions the bar absolutely), the button is
|
|
5
|
+
// always full width inside it.
|
|
6
|
+
.responsive-tabs-mobile
|
|
7
|
+
box-sizing border-box
|
|
8
|
+
display flex
|
|
9
|
+
align-items center
|
|
10
|
+
width 100%
|
|
11
|
+
height 36px
|
|
12
|
+
padding 0 10px
|
|
13
|
+
font-size 13px
|
|
14
|
+
line-height 1
|
|
15
|
+
color var(--text)
|
|
16
|
+
background transparent
|
|
17
|
+
border 1px solid var(--main-light)
|
|
18
|
+
border-radius 4px
|
|
19
|
+
cursor pointer
|
|
20
|
+
overflow hidden
|
|
21
|
+
&:hover
|
|
22
|
+
color var(--primary)
|
|
23
|
+
border-color var(--primary)
|
|
24
|
+
.responsive-tabs-mobile-label
|
|
25
|
+
flex 1
|
|
26
|
+
min-width 0
|
|
27
|
+
display flex
|
|
28
|
+
align-items center
|
|
29
|
+
text-align left
|
|
30
|
+
.responsive-tabs-mobile-icon
|
|
31
|
+
flex-shrink 0
|
|
32
|
+
margin-left 8px
|
|
33
|
+
font-size 11px
|
|
34
|
+
color var(--text-light)
|