Skip to content

Commit 9f7c44c

Browse files
committed
✨ Add Counter component
1 parent 96763bc commit 9f7c44c

File tree

17 files changed

+881
-0
lines changed

17 files changed

+881
-0
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ html body {
167167
--w-collapsible-initial-height: 0;
168168
--w-collapsible-max-height: 100%;
169169

170+
// Counter component
171+
--w-counter-width: 10ch;
172+
170173
// Masonry component
171174
--w-masonry-gap: 5px;
172175

@@ -287,6 +290,7 @@ import { Accordion } from 'webcoreui/react'
287290
- [ConditionalWrapper](https://github.com/Frontendland/webcoreui/tree/main/src/components/ConditionalWrapper)
288291
- [ContextMenu](https://github.com/Frontendland/webcoreui/tree/main/src/components/ContextMenu)
289292
- [Copy](https://github.com/Frontendland/webcoreui/tree/main/src/components/Copy)
293+
- [Counter](https://github.com/Frontendland/webcoreui/tree/main/src/components/Counter)
290294
- [DataTable](https://github.com/Frontendland/webcoreui/tree/main/src/components/DataTable)
291295
- [Flex](https://github.com/Frontendland/webcoreui/tree/main/src/components/Flex)
292296
- [Footer](https://github.com/Frontendland/webcoreui/tree/main/src/components/Footer)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
---
2+
import type { CounterProps } from './counter'
3+
4+
import Icon from '../Icon/Icon.astro'
5+
6+
import minusIcon from '../../icons/minus.svg?raw'
7+
import plusIcon from '../../icons/plus.svg?raw'
8+
9+
import styles from './counter.module.scss'
10+
11+
interface Props extends CounterProps {}
12+
13+
const {
14+
type = 'compact',
15+
theme,
16+
rounded,
17+
minIcon,
18+
maxIcon,
19+
className,
20+
width,
21+
value = 0,
22+
disabled,
23+
...rest
24+
} = Astro.props
25+
26+
const classes = [
27+
styles.counter,
28+
styles[type],
29+
theme && styles[theme],
30+
rounded && styles.rounded,
31+
className
32+
]
33+
34+
const subtractIcon = minIcon || minusIcon
35+
const addIcon = maxIcon || plusIcon
36+
37+
const styleVariable = width
38+
? `--w-counter-width: ${width};`
39+
: null
40+
---
41+
42+
<div class:list={classes} data-id="w-counter" style={styleVariable}>
43+
<button data-id="w-counter-min" disabled={disabled}>
44+
<Fragment>
45+
{subtractIcon.startsWith('<svg')
46+
? <Fragment set:html={subtractIcon} />
47+
: <Icon type={subtractIcon} />
48+
}
49+
</Fragment>
50+
</button>
51+
<input
52+
type="number"
53+
value={value}
54+
disabled={disabled}
55+
{...rest}
56+
/>
57+
<button data-id="w-counter-max" disabled={disabled}>
58+
<Fragment>
59+
{addIcon.startsWith('<svg')
60+
? <Fragment set:html={addIcon} />
61+
: <Icon type={addIcon} />
62+
}
63+
</Fragment>
64+
</button>
65+
</div>
66+
67+
<script>
68+
import { off, on } from '../../utils/DOMUtils'
69+
import { dispatch } from '../../utils/event'
70+
71+
const addEventListeners = () => {
72+
const buttonSelector = '[data-id="w-counter"] button'
73+
const inputSelector = '[data-id="w-counter"] input'
74+
const eventName = 'counterOnChange'
75+
76+
let intervalId: ReturnType<typeof setTimeout>
77+
let timeoutId: ReturnType<typeof setTimeout>
78+
let longPressDelay = 500
79+
let isKeyDown = false
80+
81+
const updateValue = (input: HTMLInputElement, min?: boolean) => {
82+
const step = input.step ? Number(input.step) : 1
83+
const direction = min ? -1 : 1
84+
const newValue = Number(input.value) + (direction * step)
85+
86+
if ((input.min && newValue < Number(input.min)) || (input.max && newValue > Number(input.max))) {
87+
return
88+
}
89+
90+
input.value = String(newValue)
91+
92+
dispatch(eventName, {
93+
name: input.name,
94+
value: newValue
95+
})
96+
}
97+
98+
const startHold = (event: Event) => {
99+
const target = event.currentTarget
100+
101+
if (target instanceof HTMLButtonElement && target.parentElement) {
102+
const input = target.parentElement.querySelector('input') as HTMLInputElement
103+
const min = target.dataset.id === 'w-counter-min'
104+
105+
updateValue(input, min)
106+
107+
timeoutId = setTimeout(function repeat() {
108+
updateValue(input, min)
109+
110+
longPressDelay = Math.max(50, longPressDelay * 0.8)
111+
112+
intervalId = setTimeout(repeat, longPressDelay)
113+
}, 500)
114+
}
115+
}
116+
117+
const stopHold = () => {
118+
clearTimeout(timeoutId)
119+
clearTimeout(intervalId)
120+
121+
isKeyDown = false
122+
longPressDelay = 500
123+
}
124+
125+
on(buttonSelector, 'mousedown', startHold, true)
126+
on(buttonSelector, 'touchstart', startHold, true)
127+
128+
on(buttonSelector, 'mouseup', stopHold, true)
129+
on(buttonSelector, 'mouseleave', stopHold, true)
130+
on(buttonSelector, 'touchend', stopHold, true)
131+
on(buttonSelector, 'touchcancel', stopHold, true)
132+
133+
on(buttonSelector, 'keydown', (event: KeyboardEvent) => {
134+
if (event.key === 'Enter' && !isKeyDown) {
135+
event.preventDefault()
136+
startHold(event)
137+
138+
isKeyDown = true
139+
}
140+
}, true)
141+
142+
on(buttonSelector, 'keyup', (event: KeyboardEvent) => {
143+
if (event.key === 'Enter') {
144+
stopHold()
145+
}
146+
}, true)
147+
148+
on(inputSelector, 'input', (event: Event) => {
149+
const target = event.target
150+
151+
if (target instanceof HTMLInputElement) {
152+
dispatch(eventName, {
153+
name: target.name,
154+
value: Number(target.value)
155+
})
156+
}
157+
}, true)
158+
}
159+
160+
off(document, 'astro:after-swap', addEventListeners)
161+
on(document, 'astro:after-swap', addEventListeners)
162+
163+
addEventListeners()
164+
</script>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<script lang="ts">
2+
import type { SvelteCounterProps } from './counter'
3+
4+
import { classNames } from '../../utils/classNames'
5+
6+
import minusIcon from '../../icons/minus.svg?raw'
7+
import plusIcon from '../../icons/plus.svg?raw'
8+
9+
import styles from './counter.module.scss'
10+
11+
let {
12+
type = 'compact',
13+
theme,
14+
rounded,
15+
minIcon,
16+
maxIcon,
17+
className,
18+
width,
19+
value = $bindable(0),
20+
disabled,
21+
onChange,
22+
step = 1,
23+
min,
24+
max,
25+
...rest
26+
}: SvelteCounterProps = $props()
27+
28+
const classes = classNames([
29+
styles.counter,
30+
styles[type],
31+
theme && styles[theme],
32+
rounded && styles.rounded,
33+
className
34+
])
35+
36+
const subtractIcon = minIcon || minusIcon
37+
const addIcon = maxIcon || plusIcon
38+
39+
const styleVariable = width
40+
? `--w-counter-width: ${width};`
41+
: null
42+
43+
let intervalId: ReturnType<typeof setTimeout>
44+
let timeoutId: ReturnType<typeof setTimeout>
45+
let longPressDelay = 500
46+
let isKeyDown = false
47+
48+
const updateValue = (isMin?: boolean) => {
49+
const direction = isMin ? -1 : 1
50+
const newValue = value + (direction * step)
51+
52+
if ((min !== undefined && newValue < min) || (max !== undefined && newValue > max)) {
53+
return
54+
}
55+
56+
value = newValue
57+
58+
onChange?.(newValue)
59+
}
60+
61+
const startHold = (event: Event) => {
62+
const target = event.currentTarget
63+
64+
if (target instanceof HTMLButtonElement) {
65+
const isMin = target.dataset.id === 'w-counter-min'
66+
67+
updateValue(isMin)
68+
69+
timeoutId = setTimeout(function repeat() {
70+
updateValue(isMin)
71+
72+
longPressDelay = Math.max(50, longPressDelay * 0.8)
73+
intervalId = setTimeout(repeat, longPressDelay)
74+
}, 500)
75+
}
76+
}
77+
78+
const stopHold = () => {
79+
clearTimeout(timeoutId)
80+
clearTimeout(intervalId)
81+
82+
isKeyDown = false
83+
longPressDelay = 500
84+
}
85+
86+
const handleKeyDown = (event: KeyboardEvent) => {
87+
if (event.key === 'Enter' && !isKeyDown) {
88+
event.preventDefault()
89+
startHold(event)
90+
91+
isKeyDown = true
92+
}
93+
}
94+
95+
const handleKeyUp = (event: KeyboardEvent) => {
96+
if (event.key === 'Enter') {
97+
stopHold()
98+
}
99+
}
100+
</script>
101+
102+
<div class={classes} style={styleVariable}>
103+
<button
104+
data-id="w-counter-min"
105+
disabled={disabled}
106+
onmousedown={startHold}
107+
ontouchstart={startHold}
108+
onmouseup={stopHold}
109+
onmouseleave={stopHold}
110+
ontouchend={stopHold}
111+
ontouchcancel={stopHold}
112+
onkeydown={handleKeyDown}
113+
onkeyup={handleKeyUp}
114+
>
115+
{@html subtractIcon}
116+
</button>
117+
<input
118+
bind:value={value}
119+
type="number"
120+
disabled={disabled}
121+
step={step}
122+
min={min}
123+
max={max}
124+
oninput={() => onChange?.(value)}
125+
{...rest}
126+
/>
127+
<button
128+
data-id="w-counter-max"
129+
disabled={disabled}
130+
onmousedown={startHold}
131+
ontouchstart={startHold}
132+
onmouseup={stopHold}
133+
onmouseleave={stopHold}
134+
ontouchend={stopHold}
135+
ontouchcancel={stopHold}
136+
onkeydown={handleKeyDown}
137+
onkeyup={handleKeyUp}
138+
>
139+
{@html addIcon}
140+
</button>
141+
</div>

0 commit comments

Comments
 (0)