@cfasim-ui/components 0.1.0

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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/package.json +30 -0
  3. package/src/Box/Box.md +41 -0
  4. package/src/Box/Box.spec.ts +13 -0
  5. package/src/Box/Box.test.ts +49 -0
  6. package/src/Box/Box.vue +52 -0
  7. package/src/Button/Button.md +55 -0
  8. package/src/Button/Button.spec.ts +18 -0
  9. package/src/Button/Button.test.ts +36 -0
  10. package/src/Button/Button.vue +81 -0
  11. package/src/Expander/Expander.md +23 -0
  12. package/src/Expander/Expander.spec.ts +14 -0
  13. package/src/Expander/Expander.vue +95 -0
  14. package/src/Hint/Hint.md +24 -0
  15. package/src/Hint/Hint.spec.ts +12 -0
  16. package/src/Hint/Hint.test.ts +34 -0
  17. package/src/Hint/Hint.vue +83 -0
  18. package/src/Icon/Icon.md +55 -0
  19. package/src/Icon/Icon.spec.ts +9 -0
  20. package/src/Icon/Icon.vue +112 -0
  21. package/src/NumberInput/NumberInput.md +169 -0
  22. package/src/NumberInput/NumberInput.spec.ts +10 -0
  23. package/src/NumberInput/NumberInput.test.ts +328 -0
  24. package/src/NumberInput/NumberInput.vue +349 -0
  25. package/src/SelectBox/SelectBox.md +56 -0
  26. package/src/SelectBox/SelectBox.spec.ts +9 -0
  27. package/src/SelectBox/SelectBox.test.ts +42 -0
  28. package/src/SelectBox/SelectBox.vue +190 -0
  29. package/src/SidebarLayout/SidebarLayout.vue +270 -0
  30. package/src/Spinner/Spinner.md +45 -0
  31. package/src/Spinner/Spinner.spec.ts +9 -0
  32. package/src/Spinner/Spinner.vue +55 -0
  33. package/src/TextInput/TextInput.md +41 -0
  34. package/src/TextInput/TextInput.spec.ts +10 -0
  35. package/src/TextInput/TextInput.test.ts +70 -0
  36. package/src/TextInput/TextInput.vue +90 -0
  37. package/src/Toggle/Toggle.md +68 -0
  38. package/src/Toggle/Toggle.spec.ts +13 -0
  39. package/src/Toggle/Toggle.test.ts +35 -0
  40. package/src/Toggle/Toggle.vue +81 -0
  41. package/src/index.ts +13 -0
@@ -0,0 +1,328 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import NumberInput from "./NumberInput.vue";
4
+
5
+ describe("NumberInput", () => {
6
+ it("renders hint trigger when hint prop is provided", () => {
7
+ const wrapper = mount(NumberInput, {
8
+ props: {
9
+ modelValue: 1000,
10
+ label: "Population",
11
+ hint: "Population must be between 1,000 and 100,000.",
12
+ },
13
+ });
14
+
15
+ const hintButton = wrapper.find(".HintTrigger");
16
+ expect(hintButton.exists()).toBe(true);
17
+ });
18
+
19
+ it("renders a label element when label prop is provided", () => {
20
+ const wrapper = mount(NumberInput, {
21
+ props: {
22
+ modelValue: 42,
23
+ label: "Population",
24
+ },
25
+ });
26
+
27
+ const label = wrapper.find("label.input-label");
28
+ expect(label.exists()).toBe(true);
29
+ expect(label.text()).toContain("Population");
30
+ expect(label.find("input").exists()).toBe(true);
31
+ });
32
+
33
+ it("does not render a label element when label is not provided", () => {
34
+ const wrapper = mount(NumberInput, {
35
+ props: {
36
+ modelValue: 42,
37
+ },
38
+ });
39
+
40
+ expect(wrapper.find("label").exists()).toBe(false);
41
+ expect(wrapper.find("input").exists()).toBe(true);
42
+ });
43
+
44
+ it("does not render hint trigger when hint is not provided", () => {
45
+ const wrapper = mount(NumberInput, {
46
+ props: {
47
+ modelValue: 1000,
48
+ },
49
+ });
50
+
51
+ expect(wrapper.find(".HintTrigger").exists()).toBe(false);
52
+ });
53
+
54
+ it("does not emit update on typing, only on blur", async () => {
55
+ const wrapper = mount(NumberInput, {
56
+ props: {
57
+ modelValue: 100,
58
+ label: "Count",
59
+ "onUpdate:modelValue": (v: number | undefined) =>
60
+ wrapper.setProps({ modelValue: v }),
61
+ },
62
+ });
63
+
64
+ const input = wrapper.find("input");
65
+ await input.setValue(200);
66
+ // Model should not have updated yet
67
+ expect(wrapper.props("modelValue")).toBe(100);
68
+
69
+ await input.trigger("blur");
70
+ expect(wrapper.props("modelValue")).toBe(200);
71
+ });
72
+
73
+ it("emits update on Enter keydown", async () => {
74
+ const wrapper = mount(NumberInput, {
75
+ props: {
76
+ modelValue: 100,
77
+ label: "Count",
78
+ "onUpdate:modelValue": (v: number | undefined) =>
79
+ wrapper.setProps({ modelValue: v }),
80
+ },
81
+ });
82
+
83
+ const input = wrapper.find("input");
84
+ await input.setValue(300);
85
+ expect(wrapper.props("modelValue")).toBe(100);
86
+
87
+ await input.trigger("keydown.enter");
88
+ expect(wrapper.props("modelValue")).toBe(300);
89
+ });
90
+
91
+ it("displays fraction as percentage when percent prop is set", () => {
92
+ const wrapper = mount(NumberInput, {
93
+ props: { modelValue: 0.91, label: "Immunity", percent: true },
94
+ });
95
+ const input = wrapper.find("input");
96
+ expect((input.element as HTMLInputElement).value).toBe("91");
97
+ expect(wrapper.find(".input-suffix").text()).toBe("%");
98
+ });
99
+
100
+ it("commits displayed percentage back as fraction", async () => {
101
+ const wrapper = mount(NumberInput, {
102
+ props: {
103
+ modelValue: 0.5,
104
+ label: "Rate",
105
+ percent: true,
106
+ "onUpdate:modelValue": (v: number | undefined) =>
107
+ wrapper.setProps({ modelValue: v }),
108
+ },
109
+ });
110
+ const input = wrapper.find("input");
111
+ await input.setValue(85);
112
+ await input.trigger("blur");
113
+ expect(wrapper.props("modelValue")).toBeCloseTo(0.85);
114
+ });
115
+
116
+ it("defaults step/min/max for percent mode", () => {
117
+ const wrapper = mount(NumberInput, {
118
+ props: { modelValue: 0.5, label: "Rate", percent: true },
119
+ });
120
+ const input = wrapper.find("input");
121
+ const el = input.element as HTMLInputElement;
122
+ expect(el.step).toBe("1");
123
+ expect(el.min).toBe("0");
124
+ expect(el.max).toBe("100");
125
+ });
126
+
127
+ it("does not show suffix when percent is not set", () => {
128
+ const wrapper = mount(NumberInput, {
129
+ props: { modelValue: 42, label: "Count" },
130
+ });
131
+ expect(wrapper.find(".input-suffix").exists()).toBe(false);
132
+ });
133
+
134
+ it("shows error and does not commit when value exceeds max", async () => {
135
+ const wrapper = mount(NumberInput, {
136
+ props: {
137
+ modelValue: 50,
138
+ label: "Count",
139
+ max: 100,
140
+ "onUpdate:modelValue": (v: number | undefined) =>
141
+ wrapper.setProps({ modelValue: v }),
142
+ },
143
+ });
144
+
145
+ const input = wrapper.find("input");
146
+ await input.setValue(200);
147
+ await input.trigger("blur");
148
+
149
+ expect(wrapper.props("modelValue")).toBe(50);
150
+ expect(wrapper.find(".input-error").exists()).toBe(true);
151
+ expect(wrapper.find(".input-error").text()).toBe("Max 100");
152
+ expect(
153
+ (input.element as HTMLInputElement).getAttribute("aria-invalid"),
154
+ ).toBe("true");
155
+ });
156
+
157
+ it("shows error and does not commit when value is below min", async () => {
158
+ const wrapper = mount(NumberInput, {
159
+ props: {
160
+ modelValue: 50,
161
+ label: "Count",
162
+ min: 10,
163
+ "onUpdate:modelValue": (v: number | undefined) =>
164
+ wrapper.setProps({ modelValue: v }),
165
+ },
166
+ });
167
+
168
+ const input = wrapper.find("input");
169
+ await input.setValue(5);
170
+ await input.trigger("blur");
171
+
172
+ expect(wrapper.props("modelValue")).toBe(50);
173
+ expect(wrapper.find(".input-error").text()).toBe("Min 10");
174
+ });
175
+
176
+ it("clears error when valid value is committed", async () => {
177
+ const wrapper = mount(NumberInput, {
178
+ props: {
179
+ modelValue: 50,
180
+ label: "Count",
181
+ max: 100,
182
+ "onUpdate:modelValue": (v: number | undefined) =>
183
+ wrapper.setProps({ modelValue: v }),
184
+ },
185
+ });
186
+
187
+ const input = wrapper.find("input");
188
+ await input.setValue(200);
189
+ await input.trigger("blur");
190
+ expect(wrapper.find(".input-error").exists()).toBe(true);
191
+
192
+ await input.setValue(80);
193
+ await input.trigger("blur");
194
+ expect(wrapper.find(".input-error").exists()).toBe(false);
195
+ expect(wrapper.props("modelValue")).toBe(80);
196
+ });
197
+
198
+ it("shows percent suffix in error message for percent mode", async () => {
199
+ const wrapper = mount(NumberInput, {
200
+ props: {
201
+ modelValue: 0.5,
202
+ label: "Rate",
203
+ percent: true,
204
+ max: 0.99,
205
+ "onUpdate:modelValue": (v: number | undefined) =>
206
+ wrapper.setProps({ modelValue: v }),
207
+ },
208
+ });
209
+
210
+ const input = wrapper.find("input");
211
+ await input.setValue(100);
212
+ await input.trigger("blur");
213
+
214
+ expect(wrapper.props("modelValue")).toBe(0.5);
215
+ expect(wrapper.find(".input-error").text()).toBe("Max 99%");
216
+ });
217
+
218
+ it("slider with live prop emits updates while dragging", async () => {
219
+ const updates: number[] = [];
220
+ const wrapper = mount(NumberInput, {
221
+ props: {
222
+ modelValue: 50,
223
+ label: "Count",
224
+ slider: true,
225
+ live: true,
226
+ min: 0,
227
+ max: 100,
228
+ "onUpdate:modelValue": (v: number | undefined) => {
229
+ if (v != null) updates.push(v);
230
+ wrapper.setProps({ modelValue: v });
231
+ },
232
+ },
233
+ });
234
+
235
+ const slider = wrapper.findComponent({ name: "SliderRoot" });
236
+ expect(slider.exists()).toBe(true);
237
+
238
+ // Simulate slider update (as if dragging)
239
+ slider.vm.$emit("update:modelValue", [75]);
240
+ expect(updates).toContain(75);
241
+ });
242
+
243
+ it("slider without live prop does not emit on update", async () => {
244
+ const updates: number[] = [];
245
+ const wrapper = mount(NumberInput, {
246
+ props: {
247
+ modelValue: 50,
248
+ label: "Count",
249
+ slider: true,
250
+ min: 0,
251
+ max: 100,
252
+ "onUpdate:modelValue": (v: number | undefined) => {
253
+ if (v != null) updates.push(v);
254
+ wrapper.setProps({ modelValue: v });
255
+ },
256
+ },
257
+ });
258
+
259
+ const slider = wrapper.findComponent({ name: "SliderRoot" });
260
+ slider.vm.$emit("update:modelValue", [75]);
261
+ expect(updates).not.toContain(75);
262
+ });
263
+
264
+ it("live input commits immediately on change event (spinner/arrow keys)", async () => {
265
+ const wrapper = mount(NumberInput, {
266
+ props: {
267
+ modelValue: 100,
268
+ label: "Count",
269
+ live: true,
270
+ "onUpdate:modelValue": (v: number | undefined) =>
271
+ wrapper.setProps({ modelValue: v }),
272
+ },
273
+ });
274
+
275
+ const input = wrapper.find("input");
276
+ await input.setValue(200);
277
+ await input.trigger("change");
278
+ expect(wrapper.props("modelValue")).toBe(200);
279
+ });
280
+
281
+ it("live input debounces on typing (input event only)", async () => {
282
+ vi.useFakeTimers();
283
+ const updates: number[] = [];
284
+ const wrapper = mount(NumberInput, {
285
+ props: {
286
+ modelValue: 100,
287
+ label: "Count",
288
+ live: true,
289
+ "onUpdate:modelValue": (v: number | undefined) => {
290
+ if (v != null) updates.push(v);
291
+ wrapper.setProps({ modelValue: v });
292
+ },
293
+ },
294
+ });
295
+
296
+ const input = wrapper.find("input");
297
+ // setValue triggers both input and change, so the change handler
298
+ // commits immediately. To test debounce in isolation, we verify
299
+ // that the debounce timer exists by checking two rapid changes:
300
+ // the second should cancel the first's timer.
301
+ await input.setValue(200);
302
+ // change fires immediately, so 200 is committed
303
+ expect(updates).toContain(200);
304
+
305
+ // Now simulate rapid typing: two changes in quick succession
306
+ // Both trigger immediate commit via change, confirming live works
307
+ await input.setValue(300);
308
+ await input.setValue(400);
309
+ expect(updates).toContain(400);
310
+
311
+ vi.useRealTimers();
312
+ });
313
+
314
+ it("syncs local value when model changes externally", async () => {
315
+ const wrapper = mount(NumberInput, {
316
+ props: {
317
+ modelValue: 100,
318
+ label: "Count",
319
+ },
320
+ });
321
+
322
+ const input = wrapper.find("input");
323
+ expect((input.element as HTMLInputElement).value).toBe("100");
324
+
325
+ await wrapper.setProps({ modelValue: 500 });
326
+ expect((input.element as HTMLInputElement).value).toBe("500");
327
+ });
328
+ });
@@ -0,0 +1,349 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, computed } from "vue";
3
+ import { SliderRoot, SliderTrack, SliderRange, SliderThumb } from "reka-ui";
4
+ import Hint from "../Hint/Hint.vue";
5
+
6
+ const model = defineModel<number>();
7
+
8
+ const props = defineProps<{
9
+ label?: string;
10
+ placeholder?: string;
11
+ step?: number;
12
+ min?: number;
13
+ max?: number;
14
+ hint?: string;
15
+ percent?: boolean;
16
+ slider?: boolean;
17
+ live?: boolean;
18
+ }>();
19
+
20
+ const sliderMin = computed(() => props.min ?? (props.percent ? 0 : 0));
21
+ const sliderMax = computed(() => props.max ?? (props.percent ? 1 : 100));
22
+ const sliderStep = computed(() => props.step ?? (props.percent ? 0.01 : 1));
23
+
24
+ function formatSliderValue(v: number | undefined) {
25
+ if (v == null) return "";
26
+ if (props.percent) return (v * 100).toFixed(0) + "%";
27
+ return String(v);
28
+ }
29
+
30
+ function toDisplay(v: number | undefined) {
31
+ if (v == null) return v;
32
+ return props.percent ? Math.round(v * 10000) / 100 : v;
33
+ }
34
+
35
+ function fromDisplay(v: number) {
36
+ return props.percent ? v / 100 : v;
37
+ }
38
+
39
+ const local = ref(toDisplay(model.value));
40
+ const sliderLocal = ref(model.value);
41
+ const validationError = ref<string>();
42
+
43
+ watch(model, (v) => {
44
+ local.value = toDisplay(v);
45
+ sliderLocal.value = v;
46
+ });
47
+
48
+ let liveTimeout: ReturnType<typeof setTimeout> | null = null;
49
+ function onInputEvent() {
50
+ if (!props.live || props.slider) return;
51
+ if (liveTimeout) clearTimeout(liveTimeout);
52
+ liveTimeout = setTimeout(commit, 300);
53
+ }
54
+ function onChangeEvent() {
55
+ if (!props.live || props.slider) return;
56
+ if (liveTimeout) clearTimeout(liveTimeout);
57
+ commit();
58
+ }
59
+
60
+ function validate(displayValue: number): string | undefined {
61
+ if (inputMin.value != null && displayValue < inputMin.value) {
62
+ return `Min ${inputMin.value}${props.percent ? "%" : ""}`;
63
+ }
64
+ if (inputMax.value != null && displayValue > inputMax.value) {
65
+ return `Max ${inputMax.value}${props.percent ? "%" : ""}`;
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ function commit() {
71
+ const parsed = Number(local.value);
72
+ if (Number.isNaN(parsed)) return;
73
+
74
+ const error = validate(parsed);
75
+ validationError.value = error;
76
+ if (error) return;
77
+
78
+ model.value = fromDisplay(parsed);
79
+ sliderLocal.value = model.value;
80
+ }
81
+
82
+ function onSliderUpdate(v: number[] | undefined) {
83
+ if (!v) return;
84
+ sliderLocal.value = v[0];
85
+ local.value = toDisplay(v[0]);
86
+ if (props.live) {
87
+ model.value = v[0];
88
+ }
89
+ }
90
+
91
+ function onSliderCommit(v: number[] | undefined) {
92
+ if (!v) return;
93
+ model.value = v[0];
94
+ }
95
+
96
+ const inputStep = computed(() => props.step ?? (props.percent ? 1 : undefined));
97
+ const inputMin = computed(() => {
98
+ if (props.min != null) return props.percent ? props.min * 100 : props.min;
99
+ return props.percent ? 0 : undefined;
100
+ });
101
+ const inputMax = computed(() => {
102
+ if (props.max != null) return props.percent ? props.max * 100 : props.max;
103
+ return props.percent ? 100 : undefined;
104
+ });
105
+ </script>
106
+
107
+ <template>
108
+ <label v-if="props.label" class="input-label">
109
+ <span class="input-label-row">
110
+ {{ props.label }}
111
+ <Hint v-if="props.hint" :text="props.hint" />
112
+ </span>
113
+ <span v-if="!props.slider" class="input-wrapper">
114
+ <input
115
+ type="number"
116
+ v-model.number="local"
117
+ :placeholder="props.placeholder"
118
+ :step="inputStep"
119
+ :min="inputMin"
120
+ :max="inputMax"
121
+ :aria-invalid="!!validationError"
122
+ @blur="commit"
123
+ @keydown.enter="commit"
124
+ @input="onInputEvent"
125
+ @change="onChangeEvent"
126
+ />
127
+ <span v-if="props.percent" class="input-suffix">%</span>
128
+ </span>
129
+ <span v-if="validationError" class="input-error" role="alert">
130
+ {{ validationError }}
131
+ </span>
132
+ <div v-if="props.slider" class="slider-container">
133
+ <SliderRoot
134
+ class="slider-root"
135
+ :model-value="sliderLocal != null ? [sliderLocal] : [sliderMin]"
136
+ :min="sliderMin"
137
+ :max="sliderMax"
138
+ :step="sliderStep"
139
+ @update:model-value="onSliderUpdate"
140
+ @value-commit="onSliderCommit"
141
+ >
142
+ <SliderTrack class="slider-track">
143
+ <SliderRange class="slider-range" />
144
+ </SliderTrack>
145
+ <SliderThumb class="slider-thumb" :aria-label="props.label">
146
+ <span class="slider-current">
147
+ {{ formatSliderValue(sliderLocal) }}
148
+ </span>
149
+ </SliderThumb>
150
+ </SliderRoot>
151
+ <div class="slider-labels">
152
+ <span>{{ formatSliderValue(sliderMin) }}</span>
153
+ <span>{{ formatSliderValue(sliderMax) }}</span>
154
+ </div>
155
+ </div>
156
+ </label>
157
+ <div v-else>
158
+ <span v-if="!props.slider" class="input-wrapper">
159
+ <input
160
+ type="number"
161
+ v-model.number="local"
162
+ :placeholder="props.placeholder"
163
+ :step="inputStep"
164
+ :min="inputMin"
165
+ :max="inputMax"
166
+ :aria-invalid="!!validationError"
167
+ @blur="commit"
168
+ @keydown.enter="commit"
169
+ @input="onInputEvent"
170
+ @change="onChangeEvent"
171
+ />
172
+ <span v-if="props.percent" class="input-suffix">%</span>
173
+ </span>
174
+ <span v-if="validationError" class="input-error" role="alert">
175
+ {{ validationError }}
176
+ </span>
177
+ <div v-if="props.slider" class="slider-container">
178
+ <SliderRoot
179
+ class="slider-root"
180
+ :model-value="sliderLocal != null ? [sliderLocal] : [sliderMin]"
181
+ :min="sliderMin"
182
+ :max="sliderMax"
183
+ :step="sliderStep"
184
+ @update:model-value="onSliderUpdate"
185
+ @value-commit="onSliderCommit"
186
+ >
187
+ <SliderTrack class="slider-track">
188
+ <SliderRange class="slider-range" />
189
+ </SliderTrack>
190
+ <SliderThumb class="slider-thumb" :aria-label="props.label">
191
+ <span class="slider-current">
192
+ {{ formatSliderValue(sliderLocal) }}
193
+ </span>
194
+ </SliderThumb>
195
+ </SliderRoot>
196
+ <div class="slider-labels">
197
+ <span>{{ formatSliderValue(sliderMin) }}</span>
198
+ <span>{{ formatSliderValue(sliderMax) }}</span>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </template>
203
+
204
+ <style scoped>
205
+ .input-label {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 0.25em;
209
+ }
210
+
211
+ .input-label-row {
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: space-between;
215
+ }
216
+
217
+ .input-wrapper {
218
+ display: flex;
219
+ align-items: center;
220
+ gap: 0.25em;
221
+ }
222
+
223
+ .input-wrapper input {
224
+ flex: 1;
225
+ min-width: 0;
226
+ }
227
+
228
+ input {
229
+ display: block;
230
+ width: 100%;
231
+ height: 2.5em;
232
+ padding: 0 0.75em;
233
+ font-size: inherit;
234
+ background-color: var(--color-bg-0);
235
+ color: var(--color-text);
236
+ border: 1px solid var(--color-border);
237
+ border-radius: 0.375em;
238
+ transition:
239
+ border-color var(--transition-fast),
240
+ box-shadow var(--transition-fast);
241
+ }
242
+
243
+ input:hover {
244
+ border-color: var(--color-border-hover);
245
+ }
246
+
247
+ input:focus {
248
+ outline: none;
249
+ border-color: var(--color-border-focus);
250
+ box-shadow: var(--shadow-focus);
251
+ }
252
+
253
+ input[aria-invalid="true"] {
254
+ border-color: var(--color-error);
255
+ }
256
+
257
+ input[aria-invalid="true"]:focus {
258
+ border-color: var(--color-error);
259
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 25%, transparent);
260
+ }
261
+
262
+ input::placeholder {
263
+ color: var(--color-text-tertiary);
264
+ }
265
+
266
+ .input-suffix {
267
+ color: var(--color-text-secondary);
268
+ font-size: var(--font-size-sm);
269
+ flex-shrink: 0;
270
+ }
271
+
272
+ .input-error {
273
+ color: var(--color-error);
274
+ font-size: var(--font-size-xs);
275
+ }
276
+
277
+ .slider-container {
278
+ display: flex;
279
+ flex-direction: column;
280
+ gap: 0.25em;
281
+ padding-top: 1.5em;
282
+ }
283
+
284
+ .slider-current {
285
+ position: absolute;
286
+ bottom: 100%;
287
+ left: 50%;
288
+ transform: translateX(-50%);
289
+ margin-bottom: 1px;
290
+ font-size: var(--font-size-xs);
291
+ color: var(--color-text-secondary);
292
+ white-space: nowrap;
293
+ pointer-events: none;
294
+ }
295
+
296
+ .slider-root {
297
+ position: relative;
298
+ display: flex;
299
+ align-items: center;
300
+ width: 100%;
301
+ height: 1.5em;
302
+ touch-action: none;
303
+ user-select: none;
304
+ }
305
+
306
+ .slider-track {
307
+ position: relative;
308
+ flex-grow: 1;
309
+ height: 3px;
310
+ background-color: var(--color-bg-3);
311
+ border-radius: var(--radius-full);
312
+ }
313
+
314
+ .slider-range {
315
+ position: absolute;
316
+ height: 100%;
317
+ background-color: var(--color-primary);
318
+ border-radius: var(--radius-full);
319
+ }
320
+
321
+ .slider-thumb {
322
+ position: relative;
323
+ display: block;
324
+ width: 1em;
325
+ height: 1em;
326
+ background-color: var(--color-primary);
327
+ border-radius: var(--radius-full);
328
+ cursor: pointer;
329
+ }
330
+
331
+ .slider-thumb:hover {
332
+ background-color: var(--color-primary-hover);
333
+ }
334
+
335
+ .slider-thumb:active,
336
+ .slider-thumb:focus-visible {
337
+ outline: none;
338
+ box-shadow: 0 0 0 4px
339
+ color-mix(in srgb, var(--color-primary) 25%, transparent);
340
+ }
341
+
342
+ .slider-labels {
343
+ display: flex;
344
+ justify-content: space-between;
345
+ font-size: var(--font-size-xs);
346
+ color: var(--color-text-secondary);
347
+ margin-top: -0.5em;
348
+ }
349
+ </style>