diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 9682f8b..895c0cc 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.26.2', - gatewayClient: '0.27.2', + macClient: '0.26.3', + gatewayClient: '0.27.3', gatewayBackend: '0.27.0', }); diff --git a/src/web/features/routing/RoutingFeature.tsx b/src/web/features/routing/RoutingFeature.tsx index 2f59644..8d094ea 100644 --- a/src/web/features/routing/RoutingFeature.tsx +++ b/src/web/features/routing/RoutingFeature.tsx @@ -83,6 +83,7 @@ interface RuleDragSession extends RuleReorderLifecycle { pointerOffsetY: number; translateY: number; raf: number | null; + capture: HTMLElement; handle: HTMLButtonElement; } @@ -447,9 +448,10 @@ export function useRoutingFeature({ const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'pointer'); if (!lifecycle) return; const row = event.currentTarget.closest('[data-rule-key]'); - if (!row) return; + const capture = event.currentTarget.closest('.client-local-rules-list'); + if (!row || !capture) return; const rect = row.getBoundingClientRect(); - event.currentTarget.setPointerCapture(event.pointerId); + capture.setPointerCapture(event.pointerId); dragRef.current = { ...lifecycle, originRules: [...rulesRef.current], @@ -459,11 +461,12 @@ export function useRoutingFeature({ pointerOffsetY: event.clientY - (rect.top + rect.height / 2), translateY: 0, raf: null, + capture, handle: event.currentTarget, }; } - function movePointerReorder(event: ReactPointerEvent) { + function movePointerReorder(event: ReactPointerEvent) { const session = dragRef.current; if (!session || session.pointerId !== event.pointerId) return; session.latestY = event.clientY; @@ -514,9 +517,9 @@ export function useRoutingFeature({ if ( outcome.releasePointerCapture && session.pointerId !== null - && session.handle.hasPointerCapture(session.pointerId) + && session.capture.hasPointerCapture(session.pointerId) ) { - session.handle.releasePointerCapture(session.pointerId); + session.capture.releasePointerCapture(session.pointerId); } if (session.lifted) { if (reason === 'drop' && session.input === 'pointer') suppressHandleClickRef.current = true; @@ -536,17 +539,17 @@ export function useRoutingFeature({ return finishReorder('cancel', announce); } - function finishPointerReorder(event: ReactPointerEvent) { + function finishPointerReorder(event: ReactPointerEvent) { if (dragRef.current?.pointerId !== event.pointerId) return; dropReorder(); } - function cancelPointerReorder(event: ReactPointerEvent) { + function cancelPointerReorder(event: ReactPointerEvent) { if (dragRef.current?.pointerId !== event.pointerId) return; cancelReorder(); } - function losePointerReorder(event: ReactPointerEvent) { + function losePointerReorder(event: ReactPointerEvent) { if (dragRef.current?.pointerId !== event.pointerId) return; finishReorder('lost-capture'); } @@ -569,6 +572,7 @@ export function useRoutingFeature({ pointerOffsetY: 0, translateY: 0, raf: null, + capture: handle, handle, }; setLiftedKey(ruleKey); @@ -929,6 +933,10 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; className={`client-local-rules-list${draftRules.length > 1 ? ' has-order-flow' : ''}`} role="list" aria-label="Правила применяются сверху вниз" + onPointerMove={feature.movePointerReorder} + onPointerUp={feature.finishPointerReorder} + onPointerCancel={feature.cancelPointerReorder} + onLostPointerCapture={feature.losePointerReorder} > {feature.rules.map((rule, index) => { const [status, statusLabel] = localRuleStatus( @@ -959,18 +967,19 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; aria-pressed={lifted} disabled={handleDisabled} onPointerDown={(event) => feature.startPointerReorder(event, rule._key)} - onPointerMove={feature.movePointerReorder} - onPointerUp={feature.finishPointerReorder} - onPointerCancel={feature.cancelPointerReorder} - onLostPointerCapture={feature.losePointerReorder} onKeyDown={(event) => feature.handleReorderKey(event, rule._key)} onClick={(event) => feature.handleReorderClick(event, rule._key)} onBlur={(event) => feature.handleReorderBlur(event, rule._key)} > - @@ -984,11 +993,15 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; onClick={() => feature.change(index, 'enabled', !rule.enabled)} > - {rule.enabled ? 'Отключить правило' : 'Включить правило'} + + {rule.enabled + ? 'Отключить: правило перестанет участвовать в маршрутизации' + : 'Включить: правило снова будет участвовать в маршрутизации'} + :not(.client-delete-strike) { +.client-deletable-row.is-removing > :not(.client-delete-strike):not(.client-local-rule-enabled-wrap) { animation: client-delete-content-dim 820ms ease-out both; } -.client-deletable-row.is-removing .client-rule-switch-thumb { +.client-deletable-row.is-removing .client-local-rule-enabled circle, +.client-deletable-row.is-removing .client-rule-check { opacity: 0; } @@ -301,15 +302,15 @@ padding: 0; border: 0; background: transparent; - color: var(--client-muted); + color: oklch(0.62 0.16 28); font-size: var(--font-size-icon-delete); cursor: pointer; transition: color 180ms ease, filter 260ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1); } .client-row-delete:hover { - color: oklch(0.68 0.15 28); - filter: drop-shadow(0 0 7px oklch(0.68 0.15 28 / 0.42)); + color: oklch(0.7 0.18 28); + filter: drop-shadow(0 0 7px oklch(0.7 0.18 28 / 0.42)); } .client-row-delete:hover:not(:has(svg)) { @@ -341,7 +342,6 @@ .client-local-rule-enabled:focus-visible, .client-rule-type-trigger:focus-visible, .client-rule-outbound button:focus-visible, -.client-row-delete:focus-visible, .client-row-add:focus-visible, .client-local-rules-save:focus-visible, .client-local-rules-actions button:focus-visible { @@ -350,6 +350,12 @@ text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 48%, transparent); } +.client-row-delete:focus-visible { + outline: 0; + color: oklch(0.7 0.18 28); + filter: drop-shadow(0 0 7px oklch(0.7 0.18 28 / 0.42)); +} + .client-row-add { width: fit-content; padding: 7px 0; diff --git a/src/web/styles/themes.css b/src/web/styles/themes.css index 6bd4a85..f2da90b 100644 --- a/src/web/styles/themes.css +++ b/src/web/styles/themes.css @@ -122,11 +122,11 @@ } .client-local-rule, - .client-local-rule::after, .client-rule-handle, + .client-rule-handle svg, .client-local-rule-enabled svg, - .client-rule-switch-track, - .client-rule-switch-thumb, + .client-local-rule-enabled circle, + .client-local-rule-enabled path, .client-rule-type-trigger, .client-rule-type-trigger svg, .client-rule-type-list, diff --git a/test/web/routing-feature-contract.test.js b/test/web/routing-feature-contract.test.js index 2883238..3b77ba1 100644 --- a/test/web/routing-feature-contract.test.js +++ b/test/web/routing-feature-contract.test.js @@ -37,13 +37,16 @@ test('routing controller owns ordered outbound drafts, capability gating and dra assert.match(feature, /if \(!editable \|\| blocked\) return/); assert.match(feature, /sameRule\(rule, savedRules\[index\]\)/); assert.match(feature, /sameRule\(rule, activeRules\[index\]\)/); - assert.match(feature, /beginRuleReorder\(dragRef\.current, ruleKey, 'pointer'\)[\s\S]*setPointerCapture/); + assert.match(feature, /beginRuleReorder\(dragRef\.current, ruleKey, 'pointer'\)[\s\S]*capture\.setPointerCapture/); + assert.match(feature, /const capture = event\.currentTarget\.closest\('\.client-local-rules-list'\)/); + assert.doesNotMatch(feature, /event\.currentTarget\.setPointerCapture/); assert.match(feature, /beginRuleReorder\(dragRef\.current, ruleKey, 'keyboard'\)/); assert.match(feature, /event\.detail === 0\) toggleKeyboardReorder/); assert.match(feature, /event\.key === 'Tab'[\s\S]*finishReorder\('focus-leave'\)/); assert.match(feature, /onBlur=\{\(event\) => feature\.handleReorderBlur/); assert.match(feature, /endRuleReorder\(session, 'unmount'\)\.stopAutoScroll/); - assert.match(feature, /onLostPointerCapture=\{feature\.losePointerReorder\}/); + assert.match(feature, /className=\{`client-local-rules-list[\s\S]*onPointerMove=\{feature\.movePointerReorder\}[\s\S]*onLostPointerCapture=\{feature\.losePointerReorder\}/); + assert.match(feature, /session\.capture\.hasPointerCapture[\s\S]*session\.capture\.releasePointerCapture/); assert.match(feature, /keyboardEvent\.preventDefault\(\);[\s\S]*cancelReorder\(\)/); assert.match(feature, /stopAutoScroll\(session\)[\s\S]*Перемещение отменено/); assert.match(feature, /const slotCenter = \(element: HTMLElement\) => listTop \+ element\.offsetTop \+ element\.offsetHeight \/ 2/); diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js index 1180d56..e5037ec 100644 --- a/test/web/rule-editor-contract.test.js +++ b/test/web/rule-editor-contract.test.js @@ -102,12 +102,14 @@ test('copy feedback, drawers and Gateway access actions expose complete semantic }); test('ordered rules use one accessible drag handle and a compact icon route control', () => { + const handle = //.exec(routing)?.[0] || ''; assert.match(routing, /className="client-rule-handle"[\s\S]*type="button"[\s\S]*aria-label=\{`Переместить правило, позиция/); assert.match(routing, /aria-describedby="client-rule-reorder-instructions"[\s\S]*aria-pressed=\{lifted\}/); assert.match(routing, /onPointerDown=\{\(event\) => feature\.startPointerReorder\(event, rule\._key\)\}/); + assert.doesNotMatch(handle, /onPointerMove|onPointerUp|onPointerCancel|onLostPointerCapture/); assert.doesNotMatch(routing, /data-rule-key[^>]*onPointerDown/); assert.match(routing, /onClick=\{\(event\) => feature\.handleReorderClick\(event, rule\._key\)\}/); - assert.match(routing, /