@linxin666/dsh-usage 0.4.9 → 0.4.11
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/README.i18n.yaml +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/lib/client.js +1762 -366
- package/lib/client.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +251 -0
- package/lib/types/client/RangeBubble.d.ts +46 -0
- package/lib/types/client/SegmentGroup.d.ts +28 -0
- package/lib/types/client/UsagePage.d.ts +19 -0
- package/lib/types/client/UsageSectionCard.d.ts +11 -18
- package/lib/types/client/locales.d.ts +13 -2
- package/lib/types/client/section-integration.d.ts +47 -0
- package/lib/types/client/settings-form.d.ts +235 -0
- package/lib/types/client/usage-settings-card.d.ts +48 -0
- package/lib/types/client/web-extension-page.d.ts +50 -0
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +817 -0
- package/src/client/RangeBubble.tsx +164 -0
- package/src/client/SegmentGroup.tsx +93 -0
- package/src/client/UsagePage.tsx +108 -0
- package/src/client/UsageSectionCard.tsx +173 -270
- package/src/client/index.ts +19 -30
- package/src/client/locales.ts +46 -24
- package/src/client/section-integration.ts +88 -0
- package/src/client/settings-card.module.css +821 -0
- package/src/client/settings-form.ts +510 -0
- package/src/client/usage-settings-card.ts +72 -0
- package/src/client/usage.module.css +74 -82
- package/src/client/web-extension-page.ts +151 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/RangeBubble.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Coarse adjustment for a bounded number: resting the pointer on the control
|
|
4
|
+
* (or moving focus into it) raises a small bubble holding a slider across the
|
|
5
|
+
* field's whole range, so a large change is one drag instead of many presses
|
|
6
|
+
* or a retyped value. The control itself stays the precise entry.
|
|
7
|
+
*
|
|
8
|
+
* The bubble belongs to the anchor's subtree, so moving from the control onto
|
|
9
|
+
* the bubble never counts as leaving. It opens after a short rest and closes a
|
|
10
|
+
* moment after the pointer and focus have both gone, so crossing the control
|
|
11
|
+
* on the way elsewhere does not flash it. While a drag is in progress the
|
|
12
|
+
* bubble stays put even if the pointer strays off it.
|
|
13
|
+
*
|
|
14
|
+
* Dragging reports each value through `onInput`, so the field shows it live;
|
|
15
|
+
* releasing the thumb, or a key press on it, reports `onCommit` once. The
|
|
16
|
+
* bubble opens on the side of the control that has room inside the nearest
|
|
17
|
+
* scrolling ancestor, so a row near the top of a settings page does not hang
|
|
18
|
+
* its bubble under the page edge.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useRef, useState, type ChangeEvent, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
|
|
22
|
+
import css from './settings-card.module.css'
|
|
23
|
+
|
|
24
|
+
/** Rest on the control before the bubble opens. */
|
|
25
|
+
const OPEN_DELAY_MS = 220
|
|
26
|
+
/** Grace after pointer and focus have left before the bubble closes. */
|
|
27
|
+
const CLOSE_DELAY_MS = 200
|
|
28
|
+
/** Room the bubble needs between the control and the scroller's edge. */
|
|
29
|
+
const BUBBLE_ROOM_PX = 64
|
|
30
|
+
|
|
31
|
+
/** Props of {@link RangeBubble}. */
|
|
32
|
+
export interface RangeBubbleProps {
|
|
33
|
+
/** Lowest value the slider reaches. */
|
|
34
|
+
min: number
|
|
35
|
+
/** Highest value the slider reaches. */
|
|
36
|
+
max: number
|
|
37
|
+
/** Slider granularity. */
|
|
38
|
+
step: number
|
|
39
|
+
/** Current value; a value outside the range parks the thumb at the nearer end. */
|
|
40
|
+
value: number
|
|
41
|
+
/** Accessible name of the slider. */
|
|
42
|
+
label: string
|
|
43
|
+
/** Disables the bubble along with the control. */
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
/** Each value while the thumb moves. */
|
|
46
|
+
onInput: (value: number) => void
|
|
47
|
+
/** The value the thumb was released on, once per drag or key press. */
|
|
48
|
+
onCommit: (value: number) => void
|
|
49
|
+
/** The precise control the bubble hangs off. */
|
|
50
|
+
children: ReactNode
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The nearest ancestor that scrolls vertically, or the viewport's root. */
|
|
54
|
+
function scroller(node: HTMLElement): HTMLElement {
|
|
55
|
+
for (let parent = node.parentElement; parent !== null; parent = parent.parentElement) {
|
|
56
|
+
const overflow = getComputedStyle(parent).overflowY
|
|
57
|
+
if (overflow === 'auto' || overflow === 'scroll') return parent
|
|
58
|
+
}
|
|
59
|
+
return node.ownerDocument.documentElement
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render the control with its coarse-adjustment bubble.
|
|
64
|
+
* @param props - the range, the value and the two report callbacks.
|
|
65
|
+
* @returns the anchor holding the control and the bubble.
|
|
66
|
+
*/
|
|
67
|
+
export function RangeBubble(props: RangeBubbleProps): ReactNode {
|
|
68
|
+
const { min, max, step, disabled = false } = props
|
|
69
|
+
const [open, setOpen] = useState(false)
|
|
70
|
+
const [side, setSide] = useState<'above' | 'below'>('above')
|
|
71
|
+
const [live, setLive] = useState<number | null>(null)
|
|
72
|
+
const anchorRef = useRef<HTMLDivElement | null>(null)
|
|
73
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
74
|
+
const hovered = useRef(false)
|
|
75
|
+
const focused = useRef(false)
|
|
76
|
+
const dragging = useRef(false)
|
|
77
|
+
|
|
78
|
+
useEffect(() => () => { clearTimeout(timer.current) }, [])
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (disabled) {
|
|
81
|
+
clearTimeout(timer.current)
|
|
82
|
+
setOpen(false)
|
|
83
|
+
}
|
|
84
|
+
}, [disabled])
|
|
85
|
+
|
|
86
|
+
const show = (): void => {
|
|
87
|
+
const anchor = anchorRef.current
|
|
88
|
+
if (anchor !== null) {
|
|
89
|
+
const room = anchor.getBoundingClientRect().top - scroller(anchor).getBoundingClientRect().top
|
|
90
|
+
setSide(room >= BUBBLE_ROOM_PX ? 'above' : 'below')
|
|
91
|
+
}
|
|
92
|
+
setOpen(true)
|
|
93
|
+
}
|
|
94
|
+
const schedule = (): void => {
|
|
95
|
+
clearTimeout(timer.current)
|
|
96
|
+
if (disabled) return
|
|
97
|
+
if (hovered.current || focused.current || dragging.current) {
|
|
98
|
+
if (!open) timer.current = setTimeout(show, focused.current ? 0 : OPEN_DELAY_MS)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
timer.current = setTimeout(() => { setOpen(false) }, CLOSE_DELAY_MS)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const commit = (value: number): void => {
|
|
105
|
+
dragging.current = false
|
|
106
|
+
setLive(null)
|
|
107
|
+
props.onCommit(value)
|
|
108
|
+
schedule()
|
|
109
|
+
}
|
|
110
|
+
const onChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
|
111
|
+
const value = Number(event.target.value)
|
|
112
|
+
setLive(value)
|
|
113
|
+
props.onInput(value)
|
|
114
|
+
}
|
|
115
|
+
const onKeyUp = (event: KeyboardEvent<HTMLInputElement>): void => {
|
|
116
|
+
if (live !== null) commit(live)
|
|
117
|
+
if (event.key === 'Escape') {
|
|
118
|
+
clearTimeout(timer.current)
|
|
119
|
+
setOpen(false)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const shown = live ?? Math.min(max, Math.max(min, props.value))
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
ref={anchorRef}
|
|
127
|
+
className={css.rangeAnchor}
|
|
128
|
+
onPointerEnter={() => { hovered.current = true; schedule() }}
|
|
129
|
+
onPointerLeave={() => { hovered.current = false; schedule() }}
|
|
130
|
+
onFocus={() => { focused.current = true; schedule() }}
|
|
131
|
+
onBlur={(event) => {
|
|
132
|
+
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return
|
|
133
|
+
focused.current = false
|
|
134
|
+
if (live !== null) commit(live)
|
|
135
|
+
else schedule()
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{props.children}
|
|
139
|
+
<div className={css.rangeBubble} data-open={open ? '' : undefined} data-side={side}>
|
|
140
|
+
<input
|
|
141
|
+
type="range"
|
|
142
|
+
className={css.rangeSlider}
|
|
143
|
+
min={min}
|
|
144
|
+
max={max}
|
|
145
|
+
step={step}
|
|
146
|
+
value={shown}
|
|
147
|
+
aria-label={props.label}
|
|
148
|
+
tabIndex={open ? 0 : -1}
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
style={{ '--range-fill': `${((shown - min) / (max - min)) * 100}%` } as CSSProperties}
|
|
151
|
+
onPointerDown={() => { dragging.current = true }}
|
|
152
|
+
onPointerUp={(event) => { commit(Number(event.currentTarget.value)) }}
|
|
153
|
+
onPointerCancel={(event) => { commit(Number(event.currentTarget.value)) }}
|
|
154
|
+
onChange={onChange}
|
|
155
|
+
onKeyUp={onKeyUp}
|
|
156
|
+
/>
|
|
157
|
+
<span className={css.rangeEnds} aria-hidden="true">
|
|
158
|
+
<span>{min}</span>
|
|
159
|
+
<span>{max}</span>
|
|
160
|
+
</span>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/SegmentGroup.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* The family's segmented control: a quiet track whose options share one
|
|
4
|
+
* highlight that slides to the pressed option, instead of each option fading
|
|
5
|
+
* its own background out and the next one in. The settings fields' small
|
|
6
|
+
* choices and the Web Extensions TAB strip both render through it, so the two
|
|
7
|
+
* read as one control.
|
|
8
|
+
*
|
|
9
|
+
* The caller builds the option buttons and marks the pressed one with
|
|
10
|
+
* `data-active`; the group measures that button after every render and writes
|
|
11
|
+
* its box as four custom properties, and the stylesheet draws the highlight
|
|
12
|
+
* there (`.segmentGroup[data-pill]::before`). Attribute and first placement
|
|
13
|
+
* land in the same task, so the highlight appears in place and only later
|
|
14
|
+
* changes slide. A ResizeObserver re-measures when the group or an option
|
|
15
|
+
* changes size: a longer locale, a font arriving, a container growing, a
|
|
16
|
+
* strip wrapping onto a second line.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Children, useLayoutEffect, useRef, type HTMLAttributes, type ReactNode } from 'react'
|
|
20
|
+
import css from './settings-card.module.css'
|
|
21
|
+
|
|
22
|
+
/** The attribute marking a group whose highlight has been placed. */
|
|
23
|
+
const PILL_ATTR = 'data-pill'
|
|
24
|
+
/** The pressed option, relative to the group. */
|
|
25
|
+
const ACTIVE_SELECTOR = ':scope > [data-active]'
|
|
26
|
+
const PILL_PROPERTIES = ['--segment-pill-x', '--segment-pill-y', '--segment-pill-w', '--segment-pill-h'] as const
|
|
27
|
+
|
|
28
|
+
/** Props of {@link SegmentGroup}: the track's own attributes and the option buttons. */
|
|
29
|
+
export interface SegmentGroupProps extends HTMLAttributes<HTMLDivElement> {
|
|
30
|
+
/** The option buttons; the pressed one carries `data-active`. */
|
|
31
|
+
children: ReactNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Write the highlight's box from the pressed option's box, or take the
|
|
36
|
+
* highlight off when nothing is pressed or the group has no layout yet.
|
|
37
|
+
* The two boxes are read in viewport pixels and turned back into the group's
|
|
38
|
+
* own CSS pixels, so a dialog that opens through a scale transform still
|
|
39
|
+
* lands the highlight on its option.
|
|
40
|
+
*/
|
|
41
|
+
function placePill(group: HTMLElement): void {
|
|
42
|
+
const item = group.querySelector<HTMLElement>(ACTIVE_SELECTOR)
|
|
43
|
+
const box = group.getBoundingClientRect()
|
|
44
|
+
if (item === null || group.offsetWidth === 0 || box.width === 0) {
|
|
45
|
+
group.removeAttribute(PILL_ATTR)
|
|
46
|
+
for (const property of PILL_PROPERTIES) group.style.removeProperty(property)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const scale = box.width / group.offsetWidth
|
|
50
|
+
const itemBox = item.getBoundingClientRect()
|
|
51
|
+
const values = [
|
|
52
|
+
(itemBox.left - box.left) / scale - group.clientLeft,
|
|
53
|
+
(itemBox.top - box.top) / scale - group.clientTop,
|
|
54
|
+
itemBox.width / scale,
|
|
55
|
+
itemBox.height / scale,
|
|
56
|
+
]
|
|
57
|
+
values.forEach((value, index) => {
|
|
58
|
+
group.style.setProperty(PILL_PROPERTIES[index], `${Math.round(value * 100) / 100}px`)
|
|
59
|
+
})
|
|
60
|
+
if (!group.hasAttribute(PILL_ATTR)) group.setAttribute(PILL_ATTR, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Render a segmented track around the caller's option buttons.
|
|
65
|
+
* @param props - the track's attributes (role, accessible name, key handler) and its options.
|
|
66
|
+
* @returns the track.
|
|
67
|
+
*/
|
|
68
|
+
export function SegmentGroup(props: SegmentGroupProps): ReactNode {
|
|
69
|
+
const { children, className, ...rest } = props
|
|
70
|
+
const group = useRef<HTMLDivElement | null>(null)
|
|
71
|
+
const count = Children.count(children)
|
|
72
|
+
|
|
73
|
+
// Placed before the frame is painted, after every render: a press moves the
|
|
74
|
+
// pressed mark and the highlight follows it in the same frame.
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
if (group.current !== null) placePill(group.current)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
useLayoutEffect(() => {
|
|
80
|
+
const element = group.current
|
|
81
|
+
if (element === null || typeof ResizeObserver === 'undefined') return undefined
|
|
82
|
+
const observer = new ResizeObserver(() => { placePill(element) })
|
|
83
|
+
observer.observe(element)
|
|
84
|
+
for (const item of Array.from(element.children)) observer.observe(item)
|
|
85
|
+
return () => { observer.disconnect() }
|
|
86
|
+
}, [count])
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div {...rest} ref={group} className={className === undefined ? css.segmentGroup : `${css.segmentGroup} ${className}`}>
|
|
90
|
+
{children}
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage page's card chrome: the family settings card whose header holds
|
|
3
|
+
* the plugin switch, and the display rows the body closes with.
|
|
4
|
+
* @module @linxin666/dsh-usage/client/UsagePage
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { t } from './locales.ts'
|
|
9
|
+
import styles from './usage.module.css'
|
|
10
|
+
import { ChoiceField, PluginSettingsCard, ToggleSwitch, ValueField } from './PluginSettingsCard.tsx'
|
|
11
|
+
import { POLL_INTERVAL_RANGE, SPEND_CURRENCIES, type UsageSettingsCardFace, type UsageSettingsCardState } from './usage-settings-card.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The page's card: the plugin switch in the header, the statistics and the
|
|
15
|
+
* display rows in the body. A switched-off plugin keeps only the header, whose
|
|
16
|
+
* line says how to bring it back; a refused write keeps the body, so the
|
|
17
|
+
* failure line under the rows stays readable.
|
|
18
|
+
*/
|
|
19
|
+
export function UsagePage(props: { card: UsageSettingsCardState; face: UsageSettingsCardFace; enabled: boolean; children: ReactNode }): ReactNode {
|
|
20
|
+
const { card, face, enabled } = props
|
|
21
|
+
return (
|
|
22
|
+
<ul className={styles.sectionList} data-dsh-plugin="usage">
|
|
23
|
+
<PluginSettingsCard
|
|
24
|
+
t={t}
|
|
25
|
+
titleKey="usage.title"
|
|
26
|
+
titleId="settings-usage-title"
|
|
27
|
+
descriptionKey={enabled ? 'usage.description' : 'usage.disabled'}
|
|
28
|
+
headerAction={card.exposed
|
|
29
|
+
? (
|
|
30
|
+
<ToggleSwitch
|
|
31
|
+
labelledBy="settings-usage-title"
|
|
32
|
+
checked={enabled}
|
|
33
|
+
disabled={!card.writable}
|
|
34
|
+
onChange={(next) => { face.edit('enabled', String(next)) }}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
: undefined}
|
|
38
|
+
headerOnly={!enabled && !card.failed}
|
|
39
|
+
state={card}
|
|
40
|
+
alwaysOpen
|
|
41
|
+
renderChildrenWhenNotExposed
|
|
42
|
+
hideNotExposedNotice
|
|
43
|
+
onSave={face.save}
|
|
44
|
+
onDiscard={face.discard}
|
|
45
|
+
>
|
|
46
|
+
{props.children}
|
|
47
|
+
{card.exposed ? <SettingsRows card={card} face={face} /> : null}
|
|
48
|
+
</PluginSettingsCard>
|
|
49
|
+
</ul>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The display rows under the statistics. Each control writes through the card
|
|
55
|
+
* form: the currency pick at once, a typed number after a pause or on Enter.
|
|
56
|
+
* The rate only matters while spend shows in dollars, so it appears then.
|
|
57
|
+
*/
|
|
58
|
+
function SettingsRows(props: { card: UsageSettingsCardState; face: UsageSettingsCardFace }): ReactNode {
|
|
59
|
+
const { card, face } = props
|
|
60
|
+
const fieldProps = {
|
|
61
|
+
overriddenLabel: t('settings.overridden'),
|
|
62
|
+
resetLabel: t('settings.reset'),
|
|
63
|
+
invalidLabel: t('settings.invalidNumber'),
|
|
64
|
+
disabled: !card.writable,
|
|
65
|
+
}
|
|
66
|
+
return (
|
|
67
|
+
<section className={styles.settingsGroup} aria-labelledby="settings-usage-display-title" data-dsh-part="settings-rows">
|
|
68
|
+
<h3 className={styles.groupTitle} id="settings-usage-display-title">{t('usage.config.title')}</h3>
|
|
69
|
+
<ValueField
|
|
70
|
+
id="settings-usage-poll-interval"
|
|
71
|
+
label={t('usage.config.pollIntervalSec')}
|
|
72
|
+
hint={t('usage.config.pollIntervalHint')}
|
|
73
|
+
numeric
|
|
74
|
+
range={POLL_INTERVAL_RANGE}
|
|
75
|
+
{...fieldProps}
|
|
76
|
+
{...card.pollIntervalSec}
|
|
77
|
+
onEdit={(text) => { face.edit('pollIntervalSec', text) }}
|
|
78
|
+
onReset={() => { face.resetField('pollIntervalSec') }}
|
|
79
|
+
/>
|
|
80
|
+
<ChoiceField
|
|
81
|
+
id="settings-usage-spend-currency"
|
|
82
|
+
label={t('usage.config.spendCurrency')}
|
|
83
|
+
hint={t('usage.config.spendCurrencyHint')}
|
|
84
|
+
inheritLabel={t('settings.inherit')}
|
|
85
|
+
presentation="segment"
|
|
86
|
+
choices={SPEND_CURRENCIES.map(currency => ({ value: currency, label: currency }))}
|
|
87
|
+
{...fieldProps}
|
|
88
|
+
{...card.spendCurrency}
|
|
89
|
+
onEdit={(text) => { face.edit('spendCurrency', text) }}
|
|
90
|
+
onReset={() => { face.resetField('spendCurrency') }}
|
|
91
|
+
/>
|
|
92
|
+
{card.spendCurrency.text === 'USD'
|
|
93
|
+
? (
|
|
94
|
+
<ValueField
|
|
95
|
+
id="settings-usage-cny-per-usd"
|
|
96
|
+
label={t('usage.config.cnyPerUsd')}
|
|
97
|
+
hint={t('usage.config.cnyPerUsdHint')}
|
|
98
|
+
numeric
|
|
99
|
+
{...fieldProps}
|
|
100
|
+
{...card.cnyPerUsd}
|
|
101
|
+
onEdit={(text) => { face.edit('cnyPerUsd', text) }}
|
|
102
|
+
onReset={() => { face.resetField('cnyPerUsd') }}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
: null}
|
|
106
|
+
</section>
|
|
107
|
+
)
|
|
108
|
+
}
|