ux: de-facto standard select-all checkbox
Massimo Melina committed
Feb 5, 2023 at 18:28 UTC
0102cff65945f25e6e8caee046e358745edeb197
6 files changed
+95
-73
frontend/src/FilterBar.ts
new
+45
@@ -0,0 +1,45 @@
1
+import { state, useSnapState } from './state'
2
+import { createElement as h, useEffect, useState } from 'react'
3
+import { useDebounce } from 'use-debounce'
4
+import { Checkbox } from './components'
5
+
6
+export function FilterBar() {
7
+ const { patternFilter, showFilter } = useSnapState()
8
+ const [all, setAll] = useState(false)
9
+ const [filter, setFilter] = useState(patternFilter)
10
+ useEffect(() => setAll(false), [patternFilter]) // reset on change
11
+
12
+ ;[state.patternFilter] = useDebounce(showFilter ? filter : '', 300)
13
+
14
+ if (!showFilter)
15
+ return null
16
+ return h('div', { id: 'filter-bar' },
17
+ h(Checkbox, {
18
+ value: all,
19
+ onChange(){
20
+ const will = !all
21
+ const sel = state.selected
22
+ for (const { n } of state.filteredList || state.list) {
23
+ const was = sel[n]
24
+ if (was === will) continue
25
+ if (was)
26
+ delete sel[n]
27
+ else
28
+ sel[n] = true
29
+ }
30
+
31
+ setAll(will)
32
+ },
33
+ }),
34
+ h('input', {
35
+ id: 'filter',
36
+ placeholder: "Type here to filter the list below",
37
+ autoComplete: 'off',
38
+ value: filter,
39
+ autoFocus: true,
40
+ onChange(ev) {
41
+ setFilter(ev.target.value)
42
+ }
43
+ }),
44
+ )
45
+}
\ No newline at end of file
frontend/src/Head.ts
+16
-12
@@ -1,18 +1,19 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, useMemo} from 'react'
3
+import { createElement as h, Fragment, useMemo } from 'react'
4
import { formatBytes, hIcon, prefix } from './misc'
5
import { Spinner } from './components'
6
import { useSnapState } from './state'
7
import { MenuPanel } from './menu'
8
import { Breadcrumbs } from './Breadcrumbs'
9
+import { FilterBar } from './FilterBar'
10
11
export function Head() {
12
return h('header', {},
13
h(MenuPanel),
14
h(Breadcrumbs),
15
h(FolderStats),
15
- h('div', { style:{ clear:'both' }}),
16
+ h(FilterBar),
17
)
18
}
19
@@ -31,15 +32,18 @@ function FolderStats() {
32
}, [list])
33
const sel = Object.keys(selected).length
34
const fil = filteredList?.length
34
- return h('div', { id:'folder-stats' },
35
- stoppedSearch ? hIcon('interrupted', { title:'Search was interrupted' })
36
- : list.length>0 && loading && h(Spinner),
37
- [
38
- prefix('', stats.files,' file(s)'),
39
- prefix('', stats.folders, ' folder(s)'),
40
- stats.size ? formatBytes(stats.size) : '',
41
- sel && sel+' selected',
42
- fil !== undefined && fil < list.length && fil+' displayed',
43
- ].filter(Boolean).join(', ')
35
+ return h(Fragment, {},
36
+ h('div', { id:'folder-stats' },
37
+ stoppedSearch ? hIcon('interrupted', { title:'Search was interrupted' })
38
+ : list.length>0 && loading && h(Spinner),
39
+ [
40
+ prefix('', stats.files,' file(s)'),
41
+ prefix('', stats.folders, ' folder(s)'),
42
+ stats.size ? formatBytes(stats.size) : '',
43
+ sel && sel+' selected',
44
+ fil !== undefined && fil < list.length && fil+' displayed',
45
+ ].filter(Boolean).join(', '),
46
+ ),
47
+ h('div', { style:{ clear:'both' }}),
48
)
49
}
frontend/src/components.ts
+8
-10
@@ -25,16 +25,14 @@ export function FlexV(props:any) {
25
26
interface CheckboxProps { children?:ReactNode, value:any, onChange?:(v:boolean)=>void }
27
export function Checkbox({ onChange, value, children, ...props }:CheckboxProps) {
28
- return h('label', {},
29
- h('input',{
30
- type:'checkbox',
31
- onChange: ev => onChange?.(Boolean(ev.target.checked)),
32
- checked: Boolean(value),
33
- value: 1,
34
- ...props
35
- }),
36
- children
37
- )
28
+ const ret = h('input',{
29
+ type: 'checkbox',
30
+ onChange: ev => onChange?.(Boolean(ev.target.checked)),
31
+ checked: Boolean(value),
32
+ value: 1,
33
+ ...props
34
+ })
35
+ return !children ? ret : h('label', {}, ret, children)
36
}
37
38
type Options = { label:string, value:string }[]
frontend/src/index.scss
+14
-10
@@ -56,10 +56,12 @@ input, select {
56
max-width: 100%; box-sizing: border-box; // avoid scrolling
57
}
58
input[type=checkbox] {
59
- margin: 0 1.3em 0 0.8em;
59
transform: scale(1.7);
60
accent-color: var(--button-bg);
61
}
62
+label input[type=checkbox] {
63
+ margin-right: .8em;
64
+}
65
66
.hidden { display: none !important }
67
@@ -152,19 +154,18 @@ header {
154
margin-right: .3em;
155
}
156
}
155
-header input {
156
- width: 100%;
157
+#filter {
158
+ flex: 1;
159
margin: 0.2em auto;
160
box-sizing: border-box;
161
}
162
#filter-bar {
163
display: flex;
164
+ align-items: center;
165
gap: .3em;
163
- margin: 0.5em 0;
164
- & input { flex: 1 }
165
- & button {
166
- padding: 0 0.5em;
167
- }
166
+ margin: 0.3em 0 0.1em;
167
+ padding-left: 11px;
168
+ & input[type=checkbox] { margin-right: .7em }
169
}
170
171
ul.dir {
@@ -179,6 +180,9 @@ ul.dir {
180
padding: 0.3em;
181
border-top: 1px solid var(--button-bg);
182
183
+ & input[type=checkbox] {
184
+ margin: 0 .8em;
185
+ }
186
& a {
187
word-break: break-word;
188
padding-right: 0.3em;
@@ -250,6 +254,7 @@ button label {
254
}
255
256
.dialog {
257
+ min-width: 10em;
258
--color: var(--button-bg);
259
}
260
@@ -315,13 +320,12 @@ button label {
320
321
@media (max-width: 42em) {
322
body, button, select { font-size: 14pt; }
318
- #menu-bar {
323
+ #menu-bar, #filter-bar {
324
& button label { display: none } /* icons only */
325
}
326
#filter-bar {
327
margin: 0.2em 0;
328
}
324
- #filter-bar label { display:none }
329
#filter-bar button { /* make it same size of top bar */
330
width: 17.6vw;
331
height: 2.3em;
frontend/src/menu.ts
+3
-40
@@ -1,10 +1,9 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { state, useSnapState } from './state'
4
-import { createElement as h, useEffect, useState } from 'react'
5
-import { useDebounce } from 'use-debounce'
4
+import { createElement as h, useEffect } from 'react'
5
import { alertDialog, confirmDialog, ConfirmOptions, promptDialog } from './dialog'
7
-import { hIcon, isMobile, prefix, useStateMounted } from './misc'
6
+import { hIcon, prefix, useStateMounted } from './misc'
7
import { loginDialog } from './login'
8
import { showOptions } from './options'
9
import showUserPanel from './UserPanel'
@@ -15,10 +14,8 @@ import { showUpload, uploadState } from './upload'
14
import { useSnapshot } from 'valtio'
15
16
export function MenuPanel() {
18
- const { showFilter, remoteSearch, stopSearch, stoppedSearch, patternFilter, selected, can_upload } = useSnapState()
17
+ const { showFilter, remoteSearch, stopSearch, stoppedSearch, selected, can_upload } = useSnapState()
18
const { uploading, qs } = useSnapshot(uploadState)
20
- const [filter, setFilter] = useState(patternFilter)
21
- ;[state.patternFilter] = useDebounce(showFilter ? filter : '', 300)
19
useEffect(() => {
20
if (!showFilter)
21
state.selected = {}
@@ -83,42 +80,8 @@ export function MenuPanel() {
80
),
81
remoteSearch && h('div', { id: 'searched' },
82
(stopSearch ? 'Searching' : 'Searched') + ': ' + remoteSearch + prefix(' (', stoppedSearch && 'interrupted', ')')),
86
- showFilter && h('div', { id: 'filter-bar' },
87
- h('input', {
88
- id: 'filter',
89
- placeholder: "Type here to filter the list below",
90
- autoComplete: 'off',
91
- value: filter,
92
- autoFocus: true,
93
- onChange(ev) {
94
- setFilter(ev.target.value)
95
- }
96
- }),
97
- !isMobile() && h(MenuButton, {
98
- icon: 'check',
99
- label: "Select all",
100
- onClick() { workSel(() => true) }
101
- }),
102
- h(MenuButton, {
103
- icon: 'invert',
104
- label: 'Invert selection',
105
- onClick() { workSel(x => !x) }
106
- }),
107
- )
83
)
84
110
- function workSel(cb: (b:boolean) => boolean) {
111
- const sel = state.selected
112
- for (const { n } of state.filteredList || state.list) {
113
- const was = sel[n]
114
- if (was !== cb(was))
115
- if (was)
116
- delete sel[n]
117
- else
118
- sel[n] = true
119
- }
120
- }
121
-
85
function getSearchProps() {
86
return stopSearch && started1secAgo ? {
87
icon: 'stop',
frontend/src/upload.ts
+9
-1
@@ -167,7 +167,15 @@ function FilesList({ files, remove }: { files: File[], remove: (f:File) => any }
167
}
168
169
function iconBtn(icon: string, onClick: () => any, { small=true, style={}, ...props }={}) {
170
- return h('button', { onClick, ...props, ...small && { style: { padding: '.1em', width: 35, height: 30, ...style } } }, icon.length > 1 ? hIcon(icon) : icon)
170
+ return h('button', {
171
+ onClick,
172
+ ...props,
173
+ ...small && {
174
+ style: { padding: '.1em', width: 35, height: 30, ...style }
175
+ }
176
+ },
177
+ icon.length > 1 ? hIcon(icon) : icon
178
+ )
179
}
180
181
function formatPerc(p: number) {