UI update
This commit is contained in:
@ -21,5 +21,5 @@ export function pagesLabel(entry) {
|
|||||||
if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Нет';
|
if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Нет';
|
||||||
if (entry.selectedPages.size === entry.totalPages) return 'Все';
|
if (entry.selectedPages.size === entry.totalPages) return 'Все';
|
||||||
if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`;
|
if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`;
|
||||||
return `${entry.selectedPages.size} из ${entry.totalPages}`;
|
return `${entry.selectedPages.size}/${entry.totalPages} стр.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,8 @@ export const QUALITIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const COLOR_MODES = [
|
export const COLOR_MODES = [
|
||||||
{ id: 'bw', label: 'Чб' },
|
{ id: 'bw', label: 'Черно-белая' },
|
||||||
{ id: 'color', label: 'Цвет' }
|
{ id: 'color', label: 'Цветная' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FORMATS = ['A4', 'A5'];
|
export const FORMATS = ['A4', 'A5'];
|
||||||
|
|||||||
79
src/lib/pages/print/FormatPicker.svelte
Normal file
79
src/lib/pages/print/FormatPicker.svelte
Normal 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>
|
||||||
111
src/lib/pages/print/GearOption.svelte
Normal file
111
src/lib/pages/print/GearOption.svelte
Normal 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>
|
||||||
80
src/lib/pages/print/PickerOption.svelte
Normal file
80
src/lib/pages/print/PickerOption.svelte
Normal 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>
|
||||||
@ -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 -->
|
<!-- lib/pages/print/PrintUI.svelte -->
|
||||||
<script>
|
<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 { 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>
|
</script>
|
||||||
|
|
||||||
{#if as === 'button'}
|
{#if as === 'button'}
|
||||||
@ -50,24 +78,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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'}
|
{:else if as === 'file-card'}
|
||||||
{@const file = props.file}
|
{@const file = props.file}
|
||||||
<section class="file-card" class:expanded={file.expanded}>
|
<section class="file-card" class:expanded={file.expanded}>
|
||||||
@ -83,28 +93,66 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if file.expanded}
|
{#if file.expanded}
|
||||||
<div class="file-options">
|
<div class="file-options">
|
||||||
<div class="print-params-row">
|
<!-- 1. Кнопки с настройками -->
|
||||||
<div class="pages-control">
|
<!-- 2. Переключатели — сразу под кнопками настроек -->
|
||||||
<label class="option-title compact">Страницы:</label>
|
<div class="duo-group">
|
||||||
<button class="pages-btn" onclick={() => props.onPageSelect?.(file)}>
|
<div class="duo-row">
|
||||||
{props.pagesLabel?.(file) ?? '...'}
|
<GearOption value={pagesText(file)} onclick={() => props.onPageSelect?.(file)} />
|
||||||
</button>
|
<GearOption value={formatText(file)} open={formatOpen} onclick={() => (formatOpen = !formatOpen)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="copies-control-inline">
|
<div class="duo-row">
|
||||||
<label class="option-title compact">Кол-во:</label>
|
<ToggleOption
|
||||||
<svelte:self
|
label="Печать"
|
||||||
as="counter"
|
options={SIDES_OPTIONS}
|
||||||
value={file.copies}
|
value={file.sides ?? 'Односторонняя'}
|
||||||
min={1}
|
onchange={(v) => props.onUpdate?.(file.id, { sides: v })}
|
||||||
onIncrement={() => props.onUpdate?.(file.id, { copies: file.copies + 1 })}
|
/>
|
||||||
onDecrement={() => props.onUpdate?.(file.id, { copies: Math.max(1, file.copies - 1) })}
|
<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>
|
||||||
</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 })} />
|
<!-- 3. Ползунки — ниже -->
|
||||||
<svelte:self as="range-slider" label="Качество" options={QUALITIES} value={file.qualityIndex} onChange={(v) => props.onUpdate?.(file.id, { qualityIndex: v })} />
|
<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"
|
||||||
|
value={file.copies}
|
||||||
|
min={1}
|
||||||
|
onIncrement={() => props.onUpdate?.(file.id, { copies: file.copies + 1 })}
|
||||||
|
onDecrement={() => props.onUpdate?.(file.id, { copies: Math.max(1, file.copies - 1) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FormatPicker
|
||||||
|
bind:open={formatOpen}
|
||||||
|
options={FORMAT_OPTIONS}
|
||||||
|
selected={file.format ?? 'Авто'}
|
||||||
|
onselect={(v) => props.onUpdate?.(file.id, { format: v })}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -138,6 +186,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 { 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: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); }
|
.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-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 { 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; }
|
.toggle-btn.active { background: rgba(99,102,241,0.25); border-color: #6366f1; color: #fff; }
|
||||||
.quality-sub { gap: 8px; }
|
.slider-options { display: flex; flex-direction: column; gap: 7px; width: 100%; }
|
||||||
.quality-slider { -webkit-appearance: none; width: 100%; height: 6px; border-radius: 4px; background: rgba(255,255,255,0.12); outline: none; }
|
.cycle-row { display: flex; gap: 10px; width: 100%; }
|
||||||
.quality-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #6366f1; cursor: pointer; border: 2px solid #fff; }
|
.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); }
|
||||||
.quality-marks-inline { display: flex; justify-content: space-between; font-size: 11px; color: #8b93a1; margin-top: 4px; }
|
|
||||||
.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-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 { 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); }
|
.file-row:hover { background: rgba(255,255,255,0.03); }
|
||||||
.chevron { color: #6366f1; font-size: 12px; transition: transform 0.15s; }
|
.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-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-row-actions { display: flex; gap: 6px; }
|
||||||
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 18px; }
|
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
.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; }
|
|
||||||
.empty-upload { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; }
|
.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; }
|
.empty-hint { color: #8b93a1; font-size: 15px; margin: 0; }
|
||||||
.actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; }
|
.actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|||||||
309
src/lib/pages/print/SliderOption.svelte
Normal file
309
src/lib/pages/print/SliderOption.svelte
Normal 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>
|
||||||
184
src/lib/pages/print/ToggleOption.svelte
Normal file
184
src/lib/pages/print/ToggleOption.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user