@linxin666/dsh-usage 0.4.10 → 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.
@@ -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
+ }