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) {