ui: Accessibility scan improvements (#9485)

* ui: Remove all vestiges of role=tabpanel

* Switch out tablist role for a label, default to Secondary

* Move healthcheckout-output headers to h2, ideally these would be outside the component

* Add aria-label for empty button

* Fix up non-unique ids in topology component

* Temporarily fixup h2 in KV > LockSession

* Fixup dl with no dt

* h3 > h2

* Fix up page objects that were reliant on ids
This commit is contained in:
John Cowen 2021-01-05 10:05:59 +00:00 committed by GitHub
parent d0ebb2b774
commit 17438020f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 213 additions and 159 deletions

View File

@ -7,7 +7,7 @@
<li class={{concat 'health-check-output ' item.Status}}>
<div>
<header>
<h3>{{item.Name}}</h3>
<h2>{{item.Name}}</h2>
</header>
<dl>
{{#if (eq item.Kind "node")}}

View File

@ -14,6 +14,10 @@
margin-right: 8px;
}
}
%healthcheck-output header > * {
@extend %h3;
font-size: $typo-header-300;
}
%healthcheck-output dd em {
@extend %pill;
background-color: $gray-100;

View File

@ -7,7 +7,11 @@
as |api|
>
<BlockSlot @name="form">
<div class="definition-table" data-test-session={{api.data.ID}}>
<div
class="consul-lock-session-form definition-table"
data-test-session={{api.data.ID}}
...attributes
>
<h2>
<a href="{{env 'CONSUL_DOCS_URL'}}/internals/sessions.html#session-design" rel="help noopener noreferrer" target="_blank">Lock Session</a>
</h2>

View File

@ -0,0 +1,8 @@
.consul-lock-session-form {
h2 {
border-bottom: $decor-border-200;
border-color: $gray-200;
padding-bottom: .2em;
margin-bottom: .5em;
}
}

View File

@ -5,8 +5,9 @@ as |item|>
<BlockSlot @name="header">
{{#if (eq (policy/typeof item) 'policy-management')}}
<dl class="policy-management">
<dt>Type</dt>
<dd>
<Tooltip @position="top-start">
<Tooltip>
Global Management Policy
</Tooltip>
</dd>
@ -17,7 +18,7 @@ as |item|>
<BlockSlot @name="details">
<dl class="datacenter">
<dt>
<Tooltip @position="top-start">Datacenters</Tooltip>
<Tooltip>Datacenters</Tooltip>
</dt>
<dd>
{{join ', ' (policy/datacenters item)}}

View File

@ -6,6 +6,10 @@
%notice::before {
@extend %as-pseudo;
}
%notice header > * {
@extend %h3;
font-size: $typo-header-300;
}
%notice footer * {
@extend %p3;
font-weight: $typo-weight-bold;

View File

@ -7,9 +7,11 @@
)
undefined
}}}
role="tablist"
aria-label="Secondary"
class={{concat 'tab-nav' (if isAnimatable ' animatable' '')}}
id={{guid}}>
id={{guid}}
...attributes
>
<ul>
{{#each items as |item|}}
<li

View File

@ -8,46 +8,75 @@
preserveAspectRatio="none"
>
<defs>
<marker id="allow-dot" viewBox="-2 -2 15 15" refX="6" refY="6" markerWidth="6" markerHeight="6">
<marker
id={{concat this.guid '-allow-dot'}}
class="allow-dot"
viewBox="-2 -2 15 15"
refX="6"
refY="6"
markerWidth="6"
markerHeight="6"
>
<circle
cx="6"
cy="6"
r="6"
/>
</marker>
<marker id="allow-arrow" viewBox="-1 -1 12 12" refX="5" refY="5"
markerWidth="6" markerHeight="6"
orient="auto-start-reverse">
<marker
id={{concat this.guid '-allow-arrow'}}
class="allow-arrow"
viewBox="-1 -1 12 12"
refX="5"
refY="5"
markerWidth="6" markerHeight="6"
orient="auto-start-reverse"
>
<polygon points="0 0 10 5 0 10" />
</marker>
<marker id="deny-dot" viewBox="-2 -2 15 15" refX="6" refY="6" markerWidth="6" markerHeight="6">
<marker
id={{concat this.guid '-deny-dot'}}
class="deny-dot"
viewBox="-2 -2 15 15"
refX="6"
refY="6"
markerWidth="6"
markerHeight="6"
>
<circle
cx="6"
cy="6"
r="6"
/>
</marker>
<marker id="deny-arrow" viewBox="-1 -1 12 12" refX="5" refY="5"
markerWidth="6" markerHeight="6"
orient="auto-start-reverse">
<marker
id={{concat this.guid '-deny-arrow'}}
class="deny-arrow"
viewBox="-1 -1 12 12"
refX="5"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<polygon points="0 0 10 5 0 10" />
</marker>
</defs>
{{#each @lines as |line|}}
{{#if (eq line.permission 'deny')}}
<path
id={{line.id}}
id={{concat this.guid line.id}}
d={{svg-curve line.dest src=line.src}}
marker-start="url(#deny-dot)"
marker-end="url(#deny-arrow)"
marker-start={{concat 'url(#' this.guid '-deny-dot)'}}
marker-end={{concat 'url(#' this.guid '-deny-arrow)'}}
data-permission={{line.permission}}
/>
{{else}}
<path
id={{line.id}}
id={{concat this.guid line.id}}
d={{svg-curve line.dest src=line.src}}
marker-start="url(#allow-dot)"
marker-end="url(#allow-arrow)"
marker-start={{concat 'url(#' this.guid '-allow-dot)'}}
marker-end={{concat 'url(#' this.guid '-allow-arrow)'}}
data-permission={{line.permission}}
/>
{{/if}}
@ -59,7 +88,7 @@
{{#if (or (not item.Intention.Allowed) item.Intention.HasPermissions)}}
<TopologyMetrics::Popover
@type={{if item.Intention.HasPermissions 'l7' 'deny'}}
@position={{find-by 'id' (concat item.Namespace item.Name) this.iconPositions}}
@position={{find-by 'id' (concat this.guid item.Namespace item.Name) this.iconPositions}}
@item={{item}}
@oncreate={{action @oncreate item @service}}
/>

View File

@ -1,9 +1,15 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class TopoloyMetricsDownLines extends Component {
@tracked iconPositions;
@service('dom') dom;
get guid() {
return this.dom.guid(this);
}
@action
getIconPositions() {

View File

@ -89,6 +89,7 @@
type="button"
{{on 'click' (fn (optional this.popoverController.show))}}
style={{{concat 'top:' @position.y 'px;left:' @position.x 'px;'}}}
aria-label={{if (eq @type 'deny') 'Add intention' 'View intention'}}
>
</button>
</div>

View File

@ -104,24 +104,24 @@
circle {
fill: $white;
}
#allow-arrow {
.allow-arrow {
fill: $gray-300;
stroke-linejoin: round;
}
path,
#allow-dot,
#allow-arrow {
.allow-dot,
.allow-arrow {
stroke: $gray-300;
stroke-width: 2;
}
path[data-permission='deny'] {
stroke: $red-500;
}
#deny-dot {
.deny-dot {
stroke: $red-500;
stroke-width: 2;
}
#deny-arrow {
.deny-arrow {
fill: $red-500;
stroke: $red-500;
stroke-linejoin: round;

View File

@ -8,46 +8,76 @@
preserveAspectRatio="none"
>
<defs>
<marker id="allow-dot" viewBox="-2 -2 15 15" refX="6" refY="6" markerWidth="6" markerHeight="6">
<marker
id={{concat this.guid '-allow-dot'}}
class="allow-dot"
viewBox="-2 -2 15 15"
refX="6"
refY="6"
markerWidth="6"
markerHeight="6"
>
<circle
cx="6"
cy="6"
r="6"
/>
</marker>
<marker id="allow-arrow" viewBox="-1 -1 12 12" refX="5" refY="5"
markerWidth="6" markerHeight="6"
orient="auto-start-reverse">
<marker
id={{concat this.guid '-allow-arrow'}}
class="allow-arrow"
viewBox="-1 -1 12 12"
refX="5"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<polygon points="0 0 10 5 0 10" />
</marker>
<marker id="deny-dot" viewBox="-2 -2 15 15" refX="6" refY="6" markerWidth="6" markerHeight="6">
<marker
id={{concat this.guid '-deny-dot'}}
class="deny-dot"
viewBox="-2 -2 15 15"
refX="6"
refY="6"
markerWidth="6"
markerHeight="6"
>
<circle
cx="6"
cy="6"
r="6"
/>
</marker>
<marker id="deny-arrow" viewBox="-1 -1 12 12" refX="5" refY="5"
markerWidth="6" markerHeight="6"
orient="auto-start-reverse">
<marker
id={{concat this.guid '-deny-arrow'}}
class="deny-arrow"
viewBox="-1 -1 12 12"
refX="5"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<polygon points="0 0 10 5 0 10" />
</marker>
</defs>
{{#each @lines as |line|}}
{{#if (eq line.permission 'deny')}}
<path
id={{line.id}}
id={{concat this.guid line.id}}
d={{svg-curve line.dest src=line.src}}
marker-start="url(#deny-dot)"
marker-end="url(#deny-arrow)"
marker-start={{concat 'url(#' this.guid '-deny-dot)'}}
marker-end={{concat 'url(#' this.guid '-deny-arrow)'}}
data-permission={{line.permission}}
/>
{{else}}
<path
id={{line.id}}
id={{concat this.guid line.id}}
d={{svg-curve line.dest src=line.src}}
marker-start="url(#allow-dot)"
marker-end="url(#allow-arrow)"
marker-start={{concat 'url(#' this.guid '-allow-dot)'}}
marker-end={{concat 'url(#' this.guid '-allow-arrow)'}}
data-permission={{line.permission}}
/>
{{/if}}
@ -59,7 +89,7 @@
{{#if (or (not item.Intention.Allowed) item.Intention.HasPermissions)}}
<TopologyMetrics::Popover
@type={{if item.Intention.HasPermissions 'l7' 'deny'}}
@position={{find-by 'id' (concat item.Namespace item.Name) this.iconPositions}}
@position={{find-by 'id' (concat this.guid item.Namespace item.Name) this.iconPositions}}
@item={{item}}
@oncreate={{action @oncreate @service item}}
/>

View File

@ -1,9 +1,15 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class TopologyMetricsUpLines extends Component {
@tracked iconPositions;
@service('dom') dom;
get guid() {
return this.dom.guid(this);
}
@action
getIconPositions() {

View File

@ -62,6 +62,7 @@
@import 'consul-ui/components/consul/external-source';
@import 'consul-ui/components/consul/kind';
@import 'consul-ui/components/consul/intention';
@import 'consul-ui/components/consul/lock-session/form';
@import 'consul-ui/components/role-selector';
@import 'consul-ui/components/topology-metrics';

View File

@ -18,7 +18,7 @@ a[rel*='external']::after {
margin-left: 0.2em;
}
[role='tabpanel'] > p:only-child [rel*='help']::after {
.tab-section > p:only-child [rel*='help']::after {
content: none;
}
%main-content p a,

View File

@ -26,10 +26,6 @@
}
/* content */
%app-view-content h2 {
padding-bottom: 0.2em;
margin-bottom: 0.5em;
}
%app-view-content-empty {
margin-top: 0 !important;
padding: 50px;

View File

@ -4,13 +4,9 @@
%app-view-title {
border-bottom: $decor-border-100;
}
%app-view-content h2,
%app-view-content form:not(.filter-bar) fieldset {
border-bottom: $decor-border-200;
}
%app-view-content fieldset h2 {
border-bottom: none;
}
%app-view-header h1 > em {
color: $gray-600;
}
@ -21,7 +17,6 @@
color: $gray-400;
}
%app-view-title,
%app-view-content h2,
%app-view-content form:not(.filter-bar) fieldset {
border-color: $gray-200;
}

View File

@ -17,9 +17,9 @@ html[data-route$='edit'] .app-view > header + div > *:first-child {
/* most tabs have margin after the tab bar, unless the tab has a filter bar */
/* if it is a filter bar and the thing after the filter bar is a p then it also */
/* needs a top margun :S */
%app-view-content [role='tabpanel'] > *:first-child:not(.filter-bar):not(table),
%app-view-content [role='tabpanel'] > .filter-bar + p,
%app-view-content [role='tabpanel'] .consul-health-check-list {
%app-view-content .tab-section > *:first-child:not(.filter-bar):not(table),
%app-view-content .tab-section > .filter-bar + p,
%app-view-content .tab-section .consul-health-check-list {
margin-top: 1.25em;
}
.consul-upstream-instance-list,

View File

@ -8,11 +8,11 @@ html[data-route^='dc.services.instance'] .app-view > header dl {
html[data-route^='dc.services.instance'] .app-view > header dt {
font-weight: $typo-weight-bold;
}
html[data-route^='dc.services.instance'] [role='tabpanel'] section:not(:last-child) {
html[data-route^='dc.services.instance'] .tab-section section:not(:last-child) {
padding-bottom: 24px;
border-bottom: 1px solid $gray-200;
}
html[data-route^='dc.services.instance.metadata'] [role='tabpanel'] section h3,
html[data-route^='dc.services.instance.proxy'] [role='tabpanel'] section h3 {
html[data-route^='dc.services.instance.metadata'] .tab-section section h3,
html[data-route^='dc.services.instance.proxy'] .tab-section section h3 {
margin: 24px 0 12px 0;
}

View File

@ -64,7 +64,7 @@
@type="info"
as |notice|>
<notice.Header>
<h3>Update</h3>
<h2>Update</h2>
</notice.Header>
<notice.Body>
<p>

View File

@ -60,7 +60,7 @@
@type="info"
as |notice|>
<notice.Header>
<h3>Update</h3>
<h2>Update</h2>
</notice.Header>
<notice.Body>
<p data-test-notification-update>We have upgraded our ACL System to allow the creation of reusable policies that can be applied to tokens. Read more about the changes and how to upgrade legacy tokens in our <a href="{{env 'CONSUL_DOCS_URL'}}/guides/acl-migrate-tokens.html" target="_blank" rel="noopener noreferrer">documentation</a>.</p>

View File

@ -9,7 +9,6 @@
) as |filters|}}
{{#let (or sortBy "Status:asc") as |sort|}}
<div class="tab-section">
<div role="tabpanel">
{{#if (gt item.Checks.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
<Consul::HealthCheck::SearchBar
@ -52,7 +51,6 @@
</EmptyState>
</collection.Empty>
</DataCollection>
</div>
</div>
{{/let}}
{{/let}}

View File

@ -1,5 +1,4 @@
<div id="metadata" class="tab-section">
<div role="tabpanel">
<div class="tab-section">
{{#if item.Meta}}
<Consul::Metadata::List @items={{entries item.Meta}} />
{{else}}
@ -11,5 +10,4 @@
</BlockSlot>
</EmptyState>
{{/if}}
</div>
</div>

View File

@ -1,28 +1,26 @@
<div id="round-trip-time" class="tab-section">
<div role="tabpanel">
<div class="definition-table">
<dl>
<dt>
Minimum
</dt>
<dd>
{{format-number tomography.min maximumFractionDigits=2}}ms
</dd>
<dt>
Median
</dt>
<dd>
{{format-number tomography.median maximumFractionDigits=2}}ms
</dd>
<dt>
Maximum
</dt>
<dd>
{{format-number tomography.max maximumFractionDigits=2}}ms
</dd>
</dl>
</div>
<Consul::Tomography::Graph @distances={{tomography.distances}} />
<div class="tab-section">
<div class="definition-table">
<dl>
<dt>
Minimum
</dt>
<dd>
{{format-number tomography.min maximumFractionDigits=2}}ms
</dd>
<dt>
Median
</dt>
<dd>
{{format-number tomography.median maximumFractionDigits=2}}ms
</dd>
<dt>
Maximum
</dt>
<dd>
{{format-number tomography.max maximumFractionDigits=2}}ms
</dd>
</dl>
</div>
<Consul::Tomography::Graph @distances={{tomography.distances}} />
</div>

View File

@ -9,7 +9,6 @@
{{#let (or sortBy "Status:asc") as |sort|}}
{{#let (reject-by 'Service.Kind' 'connect-proxy' item.Services) as |items|}}
<div class="tab-section">
<div role="tabpanel">
{{#if (gt items.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
<Consul::ServiceInstance::SearchBar
@ -55,7 +54,6 @@
</EmptyState>
</collection.Empty>
</DataCollection>
</div>
</div>
{{/let}}
{{/let}}

View File

@ -1,6 +1,5 @@
<EventSource @src={{sessions}} />
<div id="lock-sessions" class="tab-section">
<div role="tabpanel">
<div class="tab-section">
{{#if (gt sessions.length 0)}}
<Consul::LockSession::List @items={{sessions}} @onInvalidate={{action send 'invalidateSession'}}/>
{{else}}
@ -12,5 +11,4 @@
</BlockSlot>
</EmptyState>
{{/if}}
</div>
</div>

View File

@ -1,29 +1,28 @@
<div id="addresses" class="tab-section">
<div role="tabpanel">
{{#if item.Service.TaggedAddresses }}
<TabularCollection
data-test-addresses
@items={{entries item.Service.TaggedAddresses}} as |taggedAddress index|
>
<BlockSlot @name="header">
<th>Tag</th>
<th>Address</th>
</BlockSlot>
<BlockSlot @name="row">
{{#with (object-at 1 taggedAddress) as |address|}}
<td>
{{object-at 0 taggedAddress}}{{#if (and (eq address.Address item.Address) (eq address.Port item.Port))}}&nbsp;<em data-test-address-default>(default)</em>{{/if}}
</td>
<td data-test-address>
{{address.Address}}:{{address.Port}}
</td>
{{/with}}
</BlockSlot>
</TabularCollection>
{{else}}
<p>
There are no additional addresses.
</p>
{{/if}}
</div>
<div class="tab-section">
{{#if item.Service.TaggedAddresses }}
<TabularCollection
data-test-addresses
class="consul-tagged-addresses"
@items={{entries item.Service.TaggedAddresses}} as |taggedAddress index|
>
<BlockSlot @name="header">
<th>Tag</th>
<th>Address</th>
</BlockSlot>
<BlockSlot @name="row">
{{#with (object-at 1 taggedAddress) as |address|}}
<td>
{{object-at 0 taggedAddress}}{{#if (and (eq address.Address item.Address) (eq address.Port item.Port))}}&nbsp;<em data-test-address-default>(default)</em>{{/if}}
</td>
<td data-test-address>
{{address.Address}}:{{address.Port}}
</td>
{{/with}}
</BlockSlot>
</TabularCollection>
{{else}}
<p>
There are no additional addresses.
</p>
{{/if}}
</div>

View File

@ -1,10 +1,8 @@
<div class="tab-section">
<div role="tabpanel">
{{#if (gt proxy.Service.Proxy.Expose.Paths.length 0)}}
<p>
The following list shows individual HTTP paths exposed through Envoy for external services like Prometheus. Read more about this in our documentation.
</p>
<Consul::ExposedPath::List @items={{proxy.Service.Proxy.Expose.Paths}} @address={{item.Address}} />
{{/if}}
</div>
{{#if (gt proxy.Service.Proxy.Expose.Paths.length 0)}}
<p>
The following list shows individual HTTP paths exposed through Envoy for external services like Prometheus. Read more about this in our documentation.
</p>
<Consul::ExposedPath::List @items={{proxy.Service.Proxy.Expose.Paths}} @address={{item.Address}} />
{{/if}}
</div>

View File

@ -9,7 +9,6 @@
) as |filters|}}
{{#let (or sortBy "Status:asc") as |sort|}}
<div class="tab-section">
<div role="tabpanel">
{{#if (gt item.MeshChecks.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
@ -54,7 +53,6 @@
</collection.Empty>
</DataCollection>
</div>
</div>
{{/let}}
{{/let}}

View File

@ -1,5 +1,4 @@
<div id="meta" class="tab-section">
<div role="tabpanel">
<div class="tab-section">
<section class="tags">
<h3>Tags</h3>
{{#if (gt item.Tags.length 0) }}
@ -28,5 +27,4 @@
</EmptyState>
{{/if}}
</section>
</div>
</div>

View File

@ -1,5 +1,4 @@
<div class="tab-section">
<div role="tabpanel">
{{#let (hash
searchproperties=(if (not-eq searchproperty undefined)
(split searchproperty ',')
@ -49,5 +48,4 @@
</DataCollection>
{{/let}}
{{/let}}
</div>
</div>

View File

@ -1,5 +1,4 @@
<div class="tab-section">
<div role="tabpanel">
{{#let (hash
statuses=(if status (split status ',') undefined)
sources=(if source (split source ',') undefined)
@ -54,5 +53,4 @@
</DataCollection>
{{/let}}
{{/let}}
</div>
</div>

View File

@ -22,7 +22,6 @@ as |api|>
) as |filters|}}
{{#let (or sortBy "Action:asc") as |sort|}}
<div class="tab-section">
<div role="tabpanel">
<Portal @target="app-view-actions">
<a data-test-create href={{href-to 'dc.services.show.intentions.create'}} class="type-create">Create</a>
</Portal>
@ -78,7 +77,6 @@ as |api|>
</DataCollection>
</BlockSlot>
</DataWriter>
</div>
</div>
{{/let}}
{{/let}}

View File

@ -1,9 +1,7 @@
<EventSource @src={{chain}} />
<div id="routing" class="tab-section">
<div role="tabpanel">
<Consul::DiscoveryChain
@chain={{chain.Chain}}
/>
</div>
<div class="tab-section">
<Consul::DiscoveryChain
@chain={{chain.Chain}}
/>
</div>

View File

@ -1,6 +1,5 @@
<EventSource @src={{gatewayServices}} />
<div class="tab-section">
<div role="tabpanel">
{{#let (hash
instances=(if instance (split instance ',') undefined)
searchproperties=(if (not-eq searchproperty undefined)
@ -55,5 +54,4 @@
</DataCollection>
{{/let}}
{{/let}}
</div>
</div>

View File

@ -1,5 +1,4 @@
<div id="tags" class="tab-section">
<div role="tabpanel">
<div class="tab-section">
{{#let (flatten (map-by "Tags" items)) as |tags|}}
{{#if (gt tags.length 0) }}
<TagList @item={{hash Tags=tags}} />
@ -13,5 +12,4 @@
</EmptyState>
{{/if}}
{{/let}}
</div>
</div>

View File

@ -1,6 +1,5 @@
<EventSource @src={{topology}} />
<div class="tab-section">
<div role="tabpanel">
{{#if (and (eq topology.Upstreams.length 0) (eq topology.Downstreams.length 0))}}
<EmptyState>
<BlockSlot @name="header">
@ -37,5 +36,4 @@
@oncreate={{route-action 'createIntention'}}
/>
{{/if}}
</div>
</div>

View File

@ -1,6 +1,5 @@
<EventSource @src={{gatewayServices}} />
<div class="tab-section">
<div role="tabpanel">
{{#let (hash
instances=(if instance (split instance ',') undefined)
searchproperties=(if (not-eq searchproperty undefined)
@ -55,5 +54,4 @@
</DataCollection>
{{/let}}
{{/let}}
</div>
</div>

View File

@ -10,7 +10,7 @@
@type="info"
as |notice|>
<notice.Header>
<h3>Local Storage</h3>
<h2>Local Storage</h2>
</notice.Header>
<notice.Body>
<p>

View File

@ -29,6 +29,6 @@ export default function(
actions: clickable('label'),
...deletable(),
}),
metadata: collection('#metadata [data-test-tabular-row]', {}),
metadata: collection('.consul-metadata-list [data-test-tabular-row]', {}),
};
}

View File

@ -21,7 +21,7 @@ export default function(
exposedPaths: collection('[data-test-proxy-exposed-paths] > tbody tr', {
combinedAddress: text('[data-test-combined-address]'),
}),
addresses: collection('#addresses [data-test-tabular-row]', {
addresses: collection('.consul-tagged-addresses [data-test-tabular-row]', {
address: text('[data-test-address]'),
}),
metadata: collection('.metadata [data-test-tabular-row]', {}),