UI update

This commit is contained in:
2026-09-14 08:59:58 +03:00
parent 78d4fc68cb
commit 72d5fce921
8 changed files with 859 additions and 51 deletions

View File

@ -21,5 +21,5 @@ export function pagesLabel(entry) {
if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Нет';
if (entry.selectedPages.size === entry.totalPages) return 'Все';
if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`;
return `${entry.selectedPages.size} из ${entry.totalPages}`;
return `${entry.selectedPages.size}/${entry.totalPages} стр.`;
}

View File

@ -7,8 +7,8 @@ export const QUALITIES = [
];
export const COLOR_MODES = [
{ id: 'bw', label: б' },
{ id: 'color', label: 'Цвет' }
{ id: 'bw', label: ерно-белая' },
{ id: 'color', label: 'Цветная' }
];
export const FORMATS = ['A4', 'A5'];

View File

@ -0,0 +1,79 @@
<!--
Плавающий минималистичный виджет выбора формата листа.
Открывается поверх окна; клик по фону или выбор — закрывает.
-->
<script>
let {
open = $bindable(false),
options = [],
selected = '',
onselect = null,
} = $props();
function pick(v) {
onselect?.(v);
open = false;
}
</script>
{#if open}
<div class="overlay" role="dialog" aria-modal="true" onclick={() => (open = false)}>
<div class="picker" onclick={(e) => e.stopPropagation()}>
<p class="picker-title">Формат листа</p>
<div class="picker-grid">
{#each options as f (f)}
<button type="button" class="pick-btn" class:active={f === selected} onclick={() => pick(f)}>
{f}
</button>
{/each}
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.picker {
min-width: 260px;
padding: 16px;
border-radius: 16px;
background: #16171d;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
.picker-title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
color: #8b93a1;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
}
.picker-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.pick-btn {
padding: 12px 8px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #f5f7fa;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.18s ease;
}
.pick-btn.active { background: rgba(99, 102, 241, 0.25); border-color: #6366f1; color: #fff; }
.pick-btn:not(.active):hover { background: rgba(255, 255, 255, 0.09); }
</style>

View File

@ -0,0 +1,111 @@
<!--
Кнопка «настроить»: текущее значение + шестерёнка справа от текста.
Не циклит значение сама — клик открывает интерфейс выбора (onclick).
Внешне — родня cycle-кнопки (та же ДНК toggle-btn).
-->
<script>
let {
value = '', // отображаемый текст («Все страницы», «Авто (A4)»…)
hint = '',
disabled = false,
open = false, // подсветка, пока её интерфейс открыт
onclick = null,
} = $props();
let helpOpen = $state(false);
</script>
<div class="gear-wrap" class:disabled>
<button
type="button"
class="gear-btn"
class:open
disabled={disabled}
onclick={() => {
helpOpen = false;
onclick?.();
}}
>
<span class="val">{value}</span>
<svg
class="icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
{#if hint}
<div class="help">
<button
type="button"
class="help-btn"
aria-label={'Подсказка'}
onclick={() => (helpOpen = !helpOpen)}
>?</button>
{#if helpOpen}
<div class="tip" role="tooltip">{hint}</div>
{/if}
</div>
{/if}
</div>
<style>
.gear-wrap { position: relative; flex: 1; min-width: 0; }
.gear-wrap.disabled { opacity: 0.45; pointer-events: none; }
.gear-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 10px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #f5f7fa;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease;
user-select: none;
-webkit-user-select: none;
}
.gear-btn:hover { background: rgba(99, 102, 241, 0.15); border-color: rgba(99, 102, 241, 0.5); }
.gear-btn.open { background: rgba(99, 102, 241, 0.2); border-color: #6366f1; }
.val { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.icon {
flex: 0 0 auto;
color: rgba(139, 147, 161, 0.75);
transition: color 0.18s ease, transform 0.3s ease;
}
.gear-btn:hover .icon { color: #a5b4fc; transform: rotate(60deg); }
.help { position: absolute; top: -9px; right: 3px; z-index: 6; }
.help-btn {
width: 16px; height: 16px; border: none; border-radius: 50%;
background: transparent; color: rgba(139, 147, 161, 0.7);
font-size: 11px; font-weight: 700; line-height: 1; cursor: pointer;
transition: color 0.2s; padding: 0;
}
.help-btn:hover, .help-btn:focus-visible { color: #c7cdd8; }
.tip {
position: absolute; right: -3px; top: calc(100% + 8px); z-index: 40;
width: max-content; max-width: 240px; padding: 10px 12px; border-radius: 10px;
background: #16171d; border: 1px solid rgba(255, 255, 255, 0.08);
color: #9ca3af; font-size: 13px; line-height: 140%; text-align: left;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
</style>

View File

@ -0,0 +1,80 @@
<!--
Кнопка «открыть выбор»: только текущее значение, без иконки.
Клик — открывает интерфейс выбора (родитель решает в onclick).
-->
<script>
let {
value = '',
hint = '',
disabled = false,
open = false, // подсветка, пока её интерфейс открыт
onclick = null,
} = $props();
let helpOpen = $state(false);
</script>
<div class="pick-wrap" class:disabled>
<button
type="button"
class="pick-btn"
class:open
disabled={disabled}
onclick={() => {
helpOpen = false;
onclick?.();
}}
>
<span class="val">{value}</span>
</button>
{#if hint}
<div class="help">
<button type="button" class="help-btn" aria-label="Подсказка" onclick={() => (helpOpen = !helpOpen)}>?</button>
{#if helpOpen}<div class="tip" role="tooltip">{hint}</div>{/if}
</div>
{/if}
</div>
<style>
.pick-wrap { position: relative; flex: 1; min-width: 0; }
.pick-wrap.disabled { opacity: 0.45; pointer-events: none; }
.pick-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 10px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #f5f7fa;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease;
user-select: none;
-webkit-user-select: none;
}
.pick-btn:hover { background: rgba(99, 102, 241, 0.15); border-color: rgba(99, 102, 241, 0.5); }
.pick-btn.open { background: rgba(99, 102, 241, 0.2); border-color: #6366f1; }
.val { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.help { position: absolute; top: -10px; right: 2px; z-index: 6; }
.help-btn {
width: 18px; height: 18px; border: none; border-radius: 50%;
background: transparent; color: rgba(139, 147, 161, 0.7);
font-size: 13px; font-weight: 700; line-height: 1; cursor: pointer;
transition: color 0.2s; padding: 0;
}
.help-btn:hover, .help-btn:focus-visible { color: #c7cdd8; }
.tip {
position: absolute; right: -2px; top: calc(100% + 8px); z-index: 40;
width: max-content; max-width: 240px; padding: 10px 12px; border-radius: 10px;
background: #16171d; border: 1px solid rgba(255, 255, 255, 0.08);
color: #9ca3af; font-size: 13px; line-height: 140%; text-align: left;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
</style>

View File

@ -1,10 +1,38 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_label_has_associated_control slot_element_deprecated svelte_self_deprecated -->
<!-- svelte-ignore a11y_click_1vents_have_key_events a11y_no_static_element_interactions a11y_label_has_associated_control slot_element_deprecated svelte_self_deprecated -->
<!-- lib/pages/print/PrintUI.svelte -->
<script>
import { QUALITIES, COLOR_MODES, FORMATS } from '$lib/common/utils/pricing.util.js';
import { QUALITIES, COLOR_MODES } from '$lib/common/utils/pricing.util.js';
import SliderOption from './SliderOption.svelte';
import ToggleOption from './ToggleOption.svelte';
import GearOption from './GearOption.svelte';
import FormatPicker from './FormatPicker.svelte';
/** @type {'button' | 'icon-button' | 'counter' | 'toggle-group' | 'range-slider' | 'file-card' | 'upload-zone' | 'footer'} */
const QUALITY_LABELS = QUALITIES.map((q) => q.label);
const COLOR_LABELS = COLOR_MODES.map((c) => c.label);
const DPI_OPTIONS = ['300', '600', 'Авто', '1200', '2400'];
const SIDES_OPTIONS = ['Односторонняя', 'Двусторонняя'];
const FORMAT_OPTIONS = ['Авто', 'A2', 'A3', 'A4', 'A5', '10x15'];
/** @type {'button' | 'icon-button' | 'counter' | 'toggle-group' | 'file-card' | 'upload-zone' | 'footer'} */
let { as, ...props } = $props();
// локально на экземпляр карточки файла
let formatOpen = $state(false);
$effect(() => {
if (!props.file?.expanded) formatOpen = false;
});
function pagesText(file) {
const t = props.pagesLabel?.(file);
if (!t || t === 'Все') return 'Все страницы';
return t;
}
function formatText(file) {
if (!file.format || file.format === 'Авто') return `Авто (${file.autoFormat ?? 'A4'})`;
return file.format;
}
</script>
{#if as === 'button'}
@ -50,24 +78,6 @@
</div>
</div>
{:else if as === 'range-slider'}
<div class="option-sub quality-sub">
<label class="option-title">{props.label} {QUALITIES[props.value]?.label ?? ''}</label>
<input
type="range"
min={0}
max={props.options.length - 1}
step={1}
value={props.value}
oninput={(e) => props.onChange?.(parseInt(e.target.value))}
class="quality-slider"
style="--fill: {Math.round((props.value / (props.options.length - 1)) * 100)}%"
/>
<div class="quality-marks-inline">
{#each props.options as q}<span class="quality-mark">{q.label}</span>{/each}
</div>
</div>
{:else if as === 'file-card'}
{@const file = props.file}
<section class="file-card" class:expanded={file.expanded}>
@ -83,14 +93,49 @@
</div>
{#if file.expanded}
<div class="file-options">
<div class="print-params-row">
<div class="pages-control">
<label class="option-title compact">Страницы:</label>
<button class="pages-btn" onclick={() => props.onPageSelect?.(file)}>
{props.pagesLabel?.(file) ?? '...'}
</button>
<!-- 1. Кнопки с настройками -->
<!-- 2. Переключатели — сразу под кнопками настроек -->
<div class="duo-group">
<div class="duo-row">
<GearOption value={pagesText(file)} onclick={() => props.onPageSelect?.(file)} />
<GearOption value={formatText(file)} open={formatOpen} onclick={() => (formatOpen = !formatOpen)} />
</div>
<div class="copies-control-inline">
<div class="duo-row">
<ToggleOption
label="Печать"
options={SIDES_OPTIONS}
value={file.sides ?? 'Односторонняя'}
onchange={(v) => props.onUpdate?.(file.id, { sides: v })}
/>
<ToggleOption
label="Цветность"
options={COLOR_LABELS}
value={COLOR_LABELS[Math.max(0, COLOR_MODES.findIndex((c) => c.id === file.colorMode))]}
onchange={(v, i) => props.onUpdate?.(file.id, { colorMode: COLOR_MODES[i]?.id })}
/>
</div>
</div>
<!-- 3. Ползунки — ниже -->
<div class="slider-options">
<SliderOption
label="Качество"
options={QUALITY_LABELS}
hint="Качество печати: влияет на расход тонера и время прогрева."
value={QUALITIES[file.qualityIndex]?.label ?? QUALITY_LABELS[1] ?? QUALITY_LABELS[0]}
onchange={(v) => props.onUpdate?.(file.id, { qualityIndex: QUALITY_LABELS.indexOf(v) })}
/>
<SliderOption
label="DPI"
options={DPI_OPTIONS}
hint="Разрешение печати. «Авто» подбирает DPI по содержимому страницы."
value={file.dpi ?? 'Авто'}
onchange={(v) => props.onUpdate?.(file.id, { dpi: v })}
/>
</div>
<!-- 4. Количество — в конце -->
<div class="copies-row">
<label class="option-title compact">Кол-во:</label>
<svelte:self
as="counter"
@ -101,10 +146,13 @@
/>
</div>
</div>
<svelte:self as="toggle-group" label="Формат" options={FORMATS} selected={file.format} onSelect={(v) => props.onUpdate?.(file.id, { format: v })} />
<svelte:self as="toggle-group" label="Цветность" options={COLOR_MODES} selected={file.colorMode} valueKey="id" labelKey="label" onSelect={(v) => props.onUpdate?.(file.id, { colorMode: v })} />
<svelte:self as="range-slider" label="Качество" options={QUALITIES} value={file.qualityIndex} onChange={(v) => props.onUpdate?.(file.id, { qualityIndex: v })} />
</div>
<FormatPicker
bind:open={formatOpen}
options={FORMAT_OPTIONS}
selected={file.format ?? 'Авто'}
onselect={(v) => props.onUpdate?.(file.id, { format: v })}
/>
{/if}
</section>
@ -138,6 +186,9 @@
{/if}
<style>
.duo-group + .slider-options { margin-top: -1px; }
.duo-row { display: flex; gap: 5px; width: 100%; }
.duo-group { display: flex; flex-direction: column; gap: 6px; width: 100%; }
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; transition: opacity 0.2s; }
.action-btn:disabled { opacity: 0.6; cursor: wait; }
.action-btn.primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 8px 24px -6px rgba(99,102,241,0.5); }
@ -156,22 +207,16 @@
.toggle-group { display: flex; gap: 10px; }
.toggle-btn { flex: 1; padding: 12px 8px; border-radius: 14px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.05); color: #f5f7fa; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.18s ease; }
.toggle-btn.active { background: rgba(99,102,241,0.25); border-color: #6366f1; color: #fff; }
.quality-sub { gap: 8px; }
.quality-slider { -webkit-appearance: none; width: 100%; height: 6px; border-radius: 4px; background: rgba(255,255,255,0.12); outline: none; }
.quality-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #6366f1; cursor: pointer; border: 2px solid #fff; }
.quality-marks-inline { display: flex; justify-content: space-between; font-size: 11px; color: #8b93a1; margin-top: 4px; }
.slider-options { display: flex; flex-direction: column; gap: 7px; width: 100%; }
.cycle-row { display: flex; gap: 10px; width: 100%; }
.copies-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.06); }
.file-card { border-radius: 16px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); overflow: hidden; padding-bottom: 6px; }
.file-row { width: 100%; box-sizing: border-box; display: flex; align-items: center; gap: 12px; padding: 14px; background: none; border: none; color: #f5f7fa; cursor: pointer; user-select: none; text-align: left; }
.file-row:hover { background: rgba(255,255,255,0.03); }
.chevron { color: #6366f1; font-size: 12px; transition: transform 0.15s; }
.file-name { flex: 1; font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row-actions { display: flex; gap: 6px; }
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 18px; }
.print-params-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.06); margin-bottom: 4px; }
.pages-control { display: flex; align-items: center; flex: 1; min-width: 0; }
.pages-btn { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); color: #fff; border-radius: 8px; padding: 6px 16px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; }
.pages-btn:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.2); }
.copies-control-inline { display: flex; align-items: center; flex-shrink: 0; }
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 12px; }
.empty-upload { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; }
.empty-hint { color: #8b93a1; font-size: 15px; margin: 0; }
.actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; }

View File

@ -0,0 +1,309 @@
<!--
Ползунок-переключатель:
- дорожка во всю ширину; сегменты равные: ширина ползунка = trackW / N;
- ползунок ВСЕГДА в фикс. позиции, переезжает сам (даже пока держишь);
- мёртвая зона у границы (dead, доля зоны);
- имя параметра дублируется ровно N раз, по копии в центре каждой зоны;
лента едет с ползунком;
- hint !== '' → «?» надстрочником справа сверху.
-->
<script>
let helpEl = $state(undefined);
// клик мимо подсказки — закрывает её
$effect(() => {
if (!helpOpen) return;
/** @param {PointerEvent} e */
function onOutsideDown(e) {
if (helpEl && !helpEl.contains(/** @type {Node} */ (e.target))) helpOpen = false;
}
document.addEventListener('pointerdown', onOutsideDown);
return () => document.removeEventListener('pointerdown', onOutsideDown);
});
import { tick } from 'svelte';
let {
label = '',
options = [],
value = $bindable(undefined),
hint = '',
disabled = false,
onchange = null,
fontSize = 13,
dead = 0.08,
} = $props();
let trackEl = $state(undefined);
let trackW = $state(0);
let dragging = $state(false);
let helpOpen = $state(false);
let idx = $derived.by(() => {
const i = options.indexOf(value);
return i >= 0 ? i : 0;
});
let zoneW = $derived(options.length ? trackW / options.length : 0);
let thumbW = $derived(zoneW);
let thumbLeft = $derived(idx * zoneW);
let tapeShift = $derived(-thumbLeft);
let copies = $derived.by(() => {
const n = options.length * 2;
return Array.from({ length: n }, (_, i) => i);
});
function measure() {
if (trackEl) trackW = trackEl.offsetWidth;
}
$effect(() => {
options, label;
tick().then(measure);
});
$effect(() => {
measure();
window.addEventListener('resize', measure);
document.fonts?.ready?.then(() => measure());
return () => window.removeEventListener('resize', measure);
});
$effect(() => {
if (value === undefined && options.length) value = options[0];
});
function commit(i) {
if (i < 0 || i >= options.length || i === idx) return;
value = options[i];
onchange?.(options[i], i);
}
function xOf(e) {
return e.clientX - trackEl.getBoundingClientRect().left;
}
function zoneFromX(x, hyst) {
const n = options.length;
let z = Math.min(Math.max(Math.floor(x / zoneW), 0), n - 1);
if (hyst && z !== idx) {
const m = zoneW * dead;
if (z > idx && x < z * zoneW + m) z = idx;
else if (z < idx && x > (z + 1) * zoneW - m) z = idx;
}
return z;
}
function onPointerDown(e) {
if (disabled || !zoneW) return;
helpOpen = false;
dragging = true;
trackEl.setPointerCapture(e.pointerId);
commit(zoneFromX(xOf(e), false));
}
function onPointerMove(e) {
if (!dragging) return;
commit(zoneFromX(xOf(e), true));
}
function endDrag() {
dragging = false;
}
function onKey(e) {
if (disabled) return;
let next = idx;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = idx - 1;
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = idx + 1;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = options.length - 1;
else return;
e.preventDefault();
commit(Math.min(Math.max(next, 0), options.length - 1));
}
</script>
<div class="row" class:disabled>
<div
class="track"
class:dragging
bind:this={trackEl}
style:--seg-font="{fontSize}px"
role="slider"
tabindex={disabled ? -1 : 0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={options.length - 1}
aria-valuenow={idx}
aria-valuetext={options[idx]}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={endDrag}
onpointercancel={endDrag}
onkeydown={onKey}
>
<!-- фон дорожки + лента имён -->
<div class="clip">
<div class="tape" style:transform="translateX({tapeShift}px)">
{#each copies as i (i)}
<span class="cell" style:left="{i * zoneW}px" style:width="{zoneW}px">
<span class="u-label">{label}</span>
</span>
{/each}
</div>
</div>
<!-- ползунок -->
<div class="thumb" style:left="{thumbLeft}px" style:width="{thumbW}px">
<span class="thumb-text">{options[idx] ?? ''}</span>
</div>
</div>
{#if hint}
<div class="help" bind:this={helpEl}>
<button
type="button"
class="help-btn"
aria-label={'Подсказка: ' + label}
onclick={() => (helpOpen = !helpOpen)}
>?</button>
{#if helpOpen}
<div class="tip" role="tooltip">{hint}</div>
{/if}
</div>
{/if}
</div>
<style>
.row {
position: relative;
width: 100%;
user-select: none;
-webkit-user-select: none;
}
.row.disabled {
opacity: 0.45;
pointer-events: none;
}
.track {
position: relative;
display: block;
width: 100%;
height: 40px;
cursor: pointer;
touch-action: none;
outline: none;
}
.track.dragging { cursor: grabbing; }
.track:focus-visible {
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.4);
border-radius: 12px;
}
.clip {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.tape {
position: absolute;
inset: 0;
pointer-events: none;
will-change: transform;
transition: transform 0.28s cubic-bezier(0.22, 0.9, 0.3, 1);
}
.cell {
position: absolute;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
}
.u-label {
font-size: var(--seg-font, 13px);
letter-spacing: 0.02em;
color: rgba(139, 147, 161, 0.55);
white-space: nowrap;
}
/* ползунок: приглушённый, не яркий, в тон UI */
.thumb {
position: absolute;
top: 3px;
height: calc(100% - 6px);
z-index: 1;
border-radius: 10px;
background: rgb(40, 41, 64); /* было: rgba(99, 102, 241, 0.14) */
border: 1px solid rgba(99, 102, 241, 0.45);
box-shadow: 0 0 8px rgba(99, 102, 241, 0.10);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
transition:
left 0.28s cubic-bezier(0.22, 0.9, 0.3, 1),
width 0.28s cubic-bezier(0.22, 0.9, 0.3, 1);
}
.thumb-text {
padding: 0 8px;
color: #e0e4ec;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.help {
position: absolute;
top: -10px;
right: -2px;
z-index: 6;
}
.help-btn {
width: 20px;
height: 20px;
border: 1px solid rgba(255, 255, 255, 0.08); /* едва заметное кольцо */
border-radius: 50%;
background: rgba(37, 38, 42, 1.03);
color: rgba(139, 147, 161, 0.8);
font-size: 12px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: color 0.2s, border-color 0.2s, background 0.2s;
padding: 0;
}
.help-btn:hover,
.help-btn:focus-visible {
color: #c7cdd8;
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.07);
}
.tip {
position: absolute;
right: -3px;
top: calc(100% + 8px);
z-index: 40;
width: max-content;
max-width: 240px;
padding: 10px 12px;
border-radius: 10px;
background: #16171d;
border: 1px solid rgba(255, 255, 255, 0.08);
color: #9ca3af;
font-size: 13px;
line-height: 140%;
text-align: left;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
</style>

View File

@ -0,0 +1,184 @@
<!--
Кнопка-цикл в стиле toggle-btn (как A4/A5):
- внутри кнопки: текущее значение + значок «две стрелки по кругу» СПРАВА от текста;
- клик по кнопке — значение заменяется на следующее по кругу, стрелка крутится;
- занимает половину места пары кнопок → бинарные опции ставятся по две в строку;
- hint !== '' → «?» надстрочником у правого верхнего угла кнопки.
-->
<script>
let {
label = '', // имя настройки (aria + подсказка)
options = [],
value = $bindable(undefined),
hint = '',
disabled = false,
onchange = null, // (value, index) => void
} = $props();
let helpOpen = $state(false);
let spinCount = $state(0); // для перезапуска анимации стрелки
let idx = $derived.by(() => {
const i = options.indexOf(value);
return i >= 0 ? i : 0;
});
$effect(() => {
if (value === undefined && options.length) value = options[0];
});
function cycle() {
if (disabled || options.length < 2) return;
helpOpen = false;
const next = (idx + 1) % options.length;
value = options[next];
onchange?.(options[next], next);
spinCount += 1;
}
</script>
<div class="cycle-wrap" class:disabled>
<button
type="button"
class="cycle-btn"
disabled={disabled}
aria-label={(label ? label + ': ' : '') + (options[idx] ?? '')}
onclick={cycle}
>
<span class="val">{options[idx] ?? ''}</span>
{#key spinCount}
<svg
class="icon"
class:spin={spinCount > 0}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
<path d="M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
{/key}
</button>
{#if hint}
<div class="help">
<button
type="button"
class="help-btn"
aria-label={'Подсказка: ' + label}
onclick={() => (helpOpen = !helpOpen)}
>?</button>
{#if helpOpen}
<div class="tip" role="tooltip">{hint}</div>
{/if}
</div>
{/if}
</div>
<style>
.cycle-wrap {
position: relative;
flex: 1;
min-width: 0;
}
.cycle-wrap.disabled {
opacity: 0.45;
pointer-events: none;
}
/* та же ДНК, что у .toggle-btn (A4/A5), но одна кнопка на всю опцию */
.cycle-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 10px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #f5f7fa;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease;
user-select: none;
-webkit-user-select: none;
}
.cycle-btn:hover {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.5);
}
.cycle-btn:active {
background: rgba(99, 102, 241, 0.25);
}
.val {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
flex: 0 0 auto;
color: rgba(139, 147, 161, 0.75);
transition: color 0.18s ease;
}
.cycle-btn:hover .icon { color: #a5b4fc; }
.icon.spin { animation: cycle-spin 0.45s cubic-bezier(0.4, 0, 0.2, 1); }
@keyframes cycle-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* «?» надстрочником, как у ползунка */
.help {
position: absolute;
top: -9px;
right: 3px;
z-index: 6;
}
.help-btn {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: transparent;
color: rgba(139, 147, 161, 0.7);
font-size: 11px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: color 0.2s;
padding: 0;
}
.help-btn:hover,
.help-btn:focus-visible {
color: #c7cdd8;
}
.tip {
position: absolute;
right: -3px;
top: calc(100% + 8px);
z-index: 40;
width: max-content;
max-width: 240px;
padding: 10px 12px;
border-radius: 10px;
background: #16171d;
border: 1px solid rgba(255, 255, 255, 0.08);
color: #9ca3af;
font-size: 13px;
line-height: 140%;
text-align: left;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
</style>