@celestia-island/hikari 0.40.36 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/HkAffixPicker.scss +5 -0
- package/src/components/HkStatusBar.test.tsx +46 -0
- package/src/components/HkStatusBar.tsx +22 -0
- package/src/components/HkTimeline.scss +131 -12
- package/src/components/HkTimeline.test.tsx +40 -2
- package/src/components/HkTimeline.tsx +40 -21
- package/src/i18n/context.flatKeys.test.ts +40 -0
- package/src/i18n/context.ts +13 -4
- package/src/i18n/flatKeyParity.test.ts +45 -0
- package/src/i18n/locales/ar/components.json +19 -1
- package/src/i18n/locales/de/components.json +19 -1
- package/src/i18n/locales/es/components.json +19 -1
- package/src/i18n/locales/fr/components.json +19 -1
- package/src/i18n/locales/ja/components.json +19 -1
- package/src/i18n/locales/ko/components.json +19 -1
- package/src/i18n/locales/pt/components.json +19 -1
- package/src/i18n/locales/ru/components.json +19 -1
package/package.json
CHANGED
|
@@ -134,6 +134,11 @@
|
|
|
134
134
|
&[data-unset] {
|
|
135
135
|
font-style: italic;
|
|
136
136
|
color: rgb(var(--color-muted));
|
|
137
|
+
/* Italic final strokes lean past the glyph advance; overflow:hidden
|
|
138
|
+
* (the ellipsis pair above) shaves them at the content edge. The
|
|
139
|
+
* inline-end cushion is clipping room, and because it sits INSIDE
|
|
140
|
+
* the padding box it never triggers the ellipsis for fitting text. */
|
|
141
|
+
padding-inline-end: 0.2em;
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
|
|
@@ -38,6 +38,52 @@ afterEach(() => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
describe("HkStatusBar", () => {
|
|
41
|
+
it("renders extraDetails rows capped at 400px with an ellipsized, title-backed value", async () => {
|
|
42
|
+
const container = mountBar({
|
|
43
|
+
version: "1.2.3",
|
|
44
|
+
connectionStatus: "connected",
|
|
45
|
+
connectionInfo: INFO,
|
|
46
|
+
extraDetails: [
|
|
47
|
+
{ key: "gateway", label: "Gateway", value: "gateway.celestia.world" },
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
await nextTick();
|
|
51
|
+
// Popover content teleports to <body> and only renders once the tag
|
|
52
|
+
// is hovered open (same grammar as the protocol/network rows).
|
|
53
|
+
const tag = container.querySelector<HTMLElement>(".s-status-bar-tag")!;
|
|
54
|
+
tag.dispatchEvent(new MouseEvent("mouseenter"));
|
|
55
|
+
await nextTick();
|
|
56
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel");
|
|
57
|
+
expect(panel, "popover opens on hover").toBeTruthy();
|
|
58
|
+
const row = panel!.querySelector('[data-extra-detail="gateway"]');
|
|
59
|
+
expect(row).not.toBeNull();
|
|
60
|
+
expect((row as HTMLElement).style.maxWidth).toBe("400px");
|
|
61
|
+
const value = panel!.querySelector("span[title]") as HTMLElement;
|
|
62
|
+
expect(value.textContent).toBe("gateway.celestia.world");
|
|
63
|
+
expect(value.title).toBe("gateway.celestia.world");
|
|
64
|
+
expect(value.style.textOverflow).toBe("ellipsis");
|
|
65
|
+
// The label column keeps a minimum width — a long value must not
|
|
66
|
+
// squeeze it (the row space-between-aligns instead).
|
|
67
|
+
const label = row!.querySelector("span[style*='min-width'], span:nth-child(2)") as HTMLElement;
|
|
68
|
+
expect(label.style.minWidth).toBe("72px");
|
|
69
|
+
expect(label.style.flexShrink).toBe("0");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders no extraDetails rows when the prop is absent", async () => {
|
|
73
|
+
const container = mountBar({
|
|
74
|
+
version: "1.2.3",
|
|
75
|
+
connectionStatus: "connected",
|
|
76
|
+
connectionInfo: INFO,
|
|
77
|
+
});
|
|
78
|
+
await nextTick();
|
|
79
|
+
container
|
|
80
|
+
.querySelector<HTMLElement>(".s-status-bar-tag")!
|
|
81
|
+
.dispatchEvent(new MouseEvent("mouseenter"));
|
|
82
|
+
await nextTick();
|
|
83
|
+
const panel = document.body.querySelector(".hk-popover-panel");
|
|
84
|
+
expect(panel?.querySelector("[data-extra-detail]")).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
41
87
|
it("mounts with the traffic light and version rows inline, protocol row on hover-open", async () => {
|
|
42
88
|
const container = mountBar({
|
|
43
89
|
version: "1.2.3",
|
|
@@ -96,6 +96,21 @@ export const HkStatusBar = defineComponent({
|
|
|
96
96
|
transportTier: { type: String as PropType<string>, default: undefined },
|
|
97
97
|
attemptNumber: { type: Number, default: undefined },
|
|
98
98
|
countdown: { type: Number, default: undefined },
|
|
99
|
+
/**
|
|
100
|
+
* Optional extra rows appended to the connection-info popover (after
|
|
101
|
+
* the network row): host-supplied label/value pairs with a prebuilt
|
|
102
|
+
* icon vnode each — the same loose-icon contract as HkAuthMethodList.
|
|
103
|
+
* Rows grow with their content but cap at 400px; values past the cap
|
|
104
|
+
* ellipsize (full text rides the native title). Lets a host surface
|
|
105
|
+
* stage endpoints (gateway, registry…) inside the popover without
|
|
106
|
+
* forking the component.
|
|
107
|
+
*/
|
|
108
|
+
extraDetails: {
|
|
109
|
+
type: Array as PropType<
|
|
110
|
+
Array<{ key: string; icon?: unknown; label: string; value: string }>
|
|
111
|
+
>,
|
|
112
|
+
default: undefined,
|
|
113
|
+
},
|
|
99
114
|
},
|
|
100
115
|
setup(props) {
|
|
101
116
|
const popupOpen = ref(false);
|
|
@@ -358,6 +373,13 @@ export const HkStatusBar = defineComponent({
|
|
|
358
373
|
<span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.network", "Network")}</span>
|
|
359
374
|
<span>{regionDisplayName(info.region, locale, t)}{info.asn != null ? ` · AS${info.asn}` : ""}{info.isLocalhost ? " · " + t("hikari::statusBar.local", "Local") : ""}</span>
|
|
360
375
|
</div>
|
|
376
|
+
{(props.extraDetails ?? []).map((row) => (
|
|
377
|
+
<div key={row.key} data-extra-detail={row.key} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "6px", maxWidth: "400px" }}>
|
|
378
|
+
<span style={{ opacity: 0.5, flexShrink: 0, display: "inline-flex" }}>{row.icon}</span>
|
|
379
|
+
<span style={{ opacity: 0.5, flexShrink: 0, minWidth: "72px", marginRight: "auto" }}>{row.label}</span>
|
|
380
|
+
<span title={row.value} style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.value}</span>
|
|
381
|
+
</div>
|
|
382
|
+
))}
|
|
361
383
|
</>
|
|
362
384
|
) : (
|
|
363
385
|
<div style={{ opacity: 0.5 }}>{t("hikari::statusBar.fetching", "Fetching connection info...")}</div>
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
align-items: center;
|
|
4
4
|
gap: 0;
|
|
5
5
|
width: 100%;
|
|
6
|
+
|
|
7
|
+
// Shared node geometry — ONE source of truth for the 24px indicator
|
|
8
|
+
// circle and the rail thickness. Every connector math keys off these:
|
|
9
|
+
// window-mode links center on the node along the inline axis, the
|
|
10
|
+
// vertical rail hangs centered under it, so both stay glued to the
|
|
11
|
+
// circles even if the node size ever changes.
|
|
12
|
+
--hk-timeline-node-size: 24px;
|
|
13
|
+
--hk-timeline-link-thickness: 2px;
|
|
14
|
+
--hk-timeline-link-top: calc(
|
|
15
|
+
(var(--hk-timeline-node-size) - var(--hk-timeline-link-thickness)) / 2
|
|
16
|
+
);
|
|
6
17
|
}
|
|
7
18
|
|
|
8
19
|
.hk-timeline[data-orientation="vertical"] {
|
|
@@ -26,12 +37,9 @@
|
|
|
26
37
|
// Geometry contract with the stacked steps above: the node is the 24px
|
|
27
38
|
// indicator circle at the top of every stack, so its center sits half a
|
|
28
39
|
// node below the cell top in ALL columns — that is where link segments go.
|
|
29
|
-
|
|
40
|
+
// (Node size, thickness and link-top are the shared tokens on
|
|
41
|
+
// `.hk-timeline`.)
|
|
30
42
|
--hk-timeline-link-clearance: 20px;
|
|
31
|
-
--hk-timeline-link-thickness: 2px;
|
|
32
|
-
--hk-timeline-link-top: calc(
|
|
33
|
-
(var(--hk-timeline-node-size) - var(--hk-timeline-link-thickness)) / 2
|
|
34
|
-
);
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
.hk-timeline[data-mode="window"] .hk-timeline-window {
|
|
@@ -142,6 +150,95 @@
|
|
|
142
150
|
transform: scaleX(-1);
|
|
143
151
|
}
|
|
144
152
|
|
|
153
|
+
/* ── Vertical window mode ────────────────────────────────────────────────
|
|
154
|
+
The height-starved sibling of the compact horizontal window: when the
|
|
155
|
+
host cannot fit the full column, the timeline collapses to the same
|
|
156
|
+
three-cell window (previous / current / next) stacked as equal thirds,
|
|
157
|
+
the outer two dimmed exactly like their left/right counterparts, and
|
|
158
|
+
vertical rail segments joining the node centers on an overlay — with
|
|
159
|
+
fading continuations past the top/bottom edges where hidden steps go. */
|
|
160
|
+
.hk-timeline[data-orientation="vertical"][data-mode="window"] {
|
|
161
|
+
grid-template-columns: minmax(0, 1fr);
|
|
162
|
+
grid-template-rows: repeat(3, minmax(0, 1fr));
|
|
163
|
+
align-items: stretch;
|
|
164
|
+
|
|
165
|
+
// Each cell centers its step within its third, which puts every node
|
|
166
|
+
// center at the SAME fractions of the host height as the horizontal
|
|
167
|
+
// mode puts them across the width (1/6, 1/2, 5/6) — the link math
|
|
168
|
+
// below is the horizontal one rotated, nothing more.
|
|
169
|
+
.hk-timeline-window {
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-direction: column;
|
|
172
|
+
justify-content: center;
|
|
173
|
+
min-height: 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.hk-timeline-window .hk-timeline-step {
|
|
177
|
+
flex: 0 0 auto;
|
|
178
|
+
max-width: 100%;
|
|
179
|
+
|
|
180
|
+
[data-el="label"] {
|
|
181
|
+
overflow: hidden;
|
|
182
|
+
text-overflow: ellipsis;
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Vertical rails: centered under the node column, spanning between the
|
|
188
|
+
// cells' node centers (at 1/6, 1/2, 5/6 of the height) minus the same
|
|
189
|
+
// clearance the horizontal bonds keep from the circles. Each segment
|
|
190
|
+
// also re-asserts the inline geometry — the horizontal segment rules
|
|
191
|
+
// above carry the same attribute specificity and must not leak in.
|
|
192
|
+
.hk-timeline-link {
|
|
193
|
+
width: var(--hk-timeline-link-thickness);
|
|
194
|
+
height: auto;
|
|
195
|
+
inset-inline-start: var(--hk-timeline-link-top);
|
|
196
|
+
top: auto;
|
|
197
|
+
bottom: auto;
|
|
198
|
+
|
|
199
|
+
&[data-segment="before-current"] {
|
|
200
|
+
inset-inline-start: var(--hk-timeline-link-top);
|
|
201
|
+
width: var(--hk-timeline-link-thickness);
|
|
202
|
+
top: calc(16.6667% + var(--hk-timeline-link-clearance));
|
|
203
|
+
height: calc(33.3334% - (var(--hk-timeline-link-clearance) * 2));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
&[data-segment="current-after"] {
|
|
207
|
+
inset-inline-start: var(--hk-timeline-link-top);
|
|
208
|
+
width: var(--hk-timeline-link-thickness);
|
|
209
|
+
top: calc(50% + var(--hk-timeline-link-clearance));
|
|
210
|
+
height: calc(33.3334% - (var(--hk-timeline-link-clearance) * 2));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Continuation hints: from the container edge toward that cell's node,
|
|
214
|
+
// fading INTO the edge ("more steps exist beyond this side").
|
|
215
|
+
&[data-segment="edge-before"] {
|
|
216
|
+
inset-inline-start: var(--hk-timeline-link-top);
|
|
217
|
+
width: var(--hk-timeline-link-thickness);
|
|
218
|
+
top: 0;
|
|
219
|
+
height: calc(16.6667% - var(--hk-timeline-link-clearance));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
&[data-segment="edge-after"] {
|
|
223
|
+
inset-inline-start: var(--hk-timeline-link-top);
|
|
224
|
+
width: var(--hk-timeline-link-thickness);
|
|
225
|
+
top: auto;
|
|
226
|
+
bottom: 0;
|
|
227
|
+
height: calc(16.6667% - var(--hk-timeline-link-clearance));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
&[data-fade-dir="up"] {
|
|
231
|
+
background-color: transparent;
|
|
232
|
+
background-image: linear-gradient(to top, var(--_link-color), transparent);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
&[data-fade-dir="down"] {
|
|
236
|
+
background-color: transparent;
|
|
237
|
+
background-image: linear-gradient(to bottom, var(--_link-color), transparent);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
145
242
|
|
|
146
243
|
.hk-timeline-step {
|
|
147
244
|
display: flex;
|
|
@@ -187,7 +284,7 @@
|
|
|
187
284
|
}
|
|
188
285
|
|
|
189
286
|
[data-el="connector"] {
|
|
190
|
-
height:
|
|
287
|
+
height: var(--hk-timeline-link-thickness);
|
|
191
288
|
flex: 1 1 0%;
|
|
192
289
|
min-width: 0.5rem;
|
|
193
290
|
margin: 0 var(--hi-space-4, 4px);
|
|
@@ -198,19 +295,41 @@
|
|
|
198
295
|
flex: 0 0 auto;
|
|
199
296
|
}
|
|
200
297
|
|
|
201
|
-
.hk-timeline[data-orientation="vertical"] .hk-timeline-step {
|
|
298
|
+
.hk-timeline[data-orientation="vertical"][data-mode="full"] .hk-timeline-step {
|
|
202
299
|
flex-direction: row;
|
|
203
300
|
flex: 0 0 auto;
|
|
204
|
-
|
|
301
|
+
|
|
302
|
+
// The gap this step's rail must bridge: exactly the space between the
|
|
303
|
+
// bottom edge of this step's node circle and the top edge of the next
|
|
304
|
+
// step's node circle. Padding and rail height share the token, so the
|
|
305
|
+
// rail always lands flush on both circles — no floating dashes, no
|
|
306
|
+
// overlap, whatever the rhythm is tuned to.
|
|
307
|
+
--hk-timeline-vert-gap: var(--hi-space-24, 24px);
|
|
308
|
+
padding-bottom: var(--hk-timeline-vert-gap);
|
|
205
309
|
|
|
206
310
|
[data-el="connector"] {
|
|
207
|
-
width:
|
|
208
|
-
|
|
311
|
+
width: var(--hk-timeline-link-thickness);
|
|
312
|
+
// The horizontal base rule leaves `min-width: 0.5rem` behind; on an
|
|
313
|
+
// absolutely-positioned rail min-width beats width, which once fatted
|
|
314
|
+
// the rail to 8px and shoved it off the node center. Neutralize the
|
|
315
|
+
// leaked sizing constraints explicitly.
|
|
316
|
+
min-width: 0;
|
|
209
317
|
flex: 0 0 auto;
|
|
210
318
|
position: absolute;
|
|
211
|
-
|
|
212
|
-
|
|
319
|
+
height: var(--hk-timeline-vert-gap);
|
|
320
|
+
// Centered under the NODE, not the row: the circle occupies the first
|
|
321
|
+
// node-size square of the row, so the rail's inline offset is
|
|
322
|
+
// (node − thickness) / 2. Logical property mirrors it under RTL
|
|
323
|
+
// together with the flipped flex column.
|
|
324
|
+
inset-inline-start: calc(
|
|
325
|
+
(var(--hk-timeline-node-size) - var(--hk-timeline-link-thickness)) / 2
|
|
326
|
+
);
|
|
327
|
+
inset-inline-end: auto;
|
|
328
|
+
top: var(--hk-timeline-node-size);
|
|
329
|
+
bottom: auto;
|
|
213
330
|
margin: 0;
|
|
331
|
+
// Rounded caps keep the rail's ends from aliasing against the circles.
|
|
332
|
+
border-radius: calc(var(--hk-timeline-link-thickness) / 2);
|
|
214
333
|
}
|
|
215
334
|
}
|
|
216
335
|
|
|
@@ -138,10 +138,48 @@ describe("HkTimeline full mode", () => {
|
|
|
138
138
|
expect(els[3].getAttribute("aria-current")).toBeNull();
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
it("keeps vertical
|
|
142
|
-
|
|
141
|
+
it("keeps a short vertical timeline in full mode", () => {
|
|
142
|
+
// A 3-step vertical timeline is its own window: nothing to collapse.
|
|
143
|
+
const steps = [
|
|
144
|
+
{ key: "a", label: "Alpha" },
|
|
145
|
+
{ key: "b", label: "Beta" },
|
|
146
|
+
{ key: "c", label: "Gamma" },
|
|
147
|
+
];
|
|
148
|
+
const t = mountTimeline({ currentKey: "b", orientation: "vertical", collapse: "always" }, steps);
|
|
143
149
|
expect(t.container.querySelector(".hk-timeline")?.getAttribute("data-mode")).toBe("full");
|
|
144
150
|
});
|
|
151
|
+
|
|
152
|
+
it("collapses a vertical timeline into the stacked window", () => {
|
|
153
|
+
const t = mountTimeline({ currentKey: "c", orientation: "vertical", collapse: "always" });
|
|
154
|
+
const root = t.container.querySelector(".hk-timeline");
|
|
155
|
+
expect(root?.getAttribute("data-orientation")).toBe("vertical");
|
|
156
|
+
expect(root?.getAttribute("data-mode")).toBe("window");
|
|
157
|
+
|
|
158
|
+
const before = t.container.querySelector('.hk-timeline-window[data-side="before"]');
|
|
159
|
+
const current = t.container.querySelector('.hk-timeline-window[data-side="current"]');
|
|
160
|
+
const after = t.container.querySelector('.hk-timeline-window[data-side="after"]');
|
|
161
|
+
|
|
162
|
+
expect(before?.querySelector('[data-el="label"]')?.textContent).toBe("Beta");
|
|
163
|
+
expect(current?.querySelector('[data-el="label"]')?.textContent).toBe("Gamma");
|
|
164
|
+
expect(after?.querySelector('[data-el="label"]')?.textContent).toBe("Delta");
|
|
165
|
+
|
|
166
|
+
// Same dimming grammar as the horizontal window: outer cells dimmed,
|
|
167
|
+
// the current cell at full strength.
|
|
168
|
+
expect(before?.hasAttribute("data-dimmed")).toBe(true);
|
|
169
|
+
expect(current?.hasAttribute("data-dimmed")).toBe(false);
|
|
170
|
+
expect(after?.hasAttribute("data-dimmed")).toBe(true);
|
|
171
|
+
|
|
172
|
+
// Vertical continuation hints fade along the block axis.
|
|
173
|
+
expect(
|
|
174
|
+
t.container.querySelector('.hk-timeline-link[data-segment="edge-before"]')?.getAttribute("data-fade-dir"),
|
|
175
|
+
).toBe("up");
|
|
176
|
+
expect(
|
|
177
|
+
t.container.querySelector('.hk-timeline-link[data-segment="edge-after"]')?.getAttribute("data-fade-dir"),
|
|
178
|
+
).toBe("down");
|
|
179
|
+
|
|
180
|
+
// The current step keeps its aria contract inside the stacked window.
|
|
181
|
+
expect(current?.querySelector(".hk-timeline-step")?.getAttribute("aria-current")).toBe("step");
|
|
182
|
+
});
|
|
145
183
|
});
|
|
146
184
|
|
|
147
185
|
describe("HkTimeline window mode", () => {
|
|
@@ -91,6 +91,23 @@ function naturalRowWidth(el: HTMLElement): number {
|
|
|
91
91
|
return Math.ceil(width);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** Vertical twin of [`naturalRowWidth`]: the column's natural height when
|
|
95
|
+
* free to grow past the host (steps are `flex: 0 0 auto`, so an offscreen
|
|
96
|
+
* clone with `height: max-content` measures the unclamped stack). */
|
|
97
|
+
function naturalColumnHeight(el: HTMLElement): number {
|
|
98
|
+
const probe = el.cloneNode(true) as HTMLElement;
|
|
99
|
+
probe.style.position = "absolute";
|
|
100
|
+
probe.style.visibility = "hidden";
|
|
101
|
+
probe.style.pointerEvents = "none";
|
|
102
|
+
probe.style.height = "max-content";
|
|
103
|
+
const parent = el.parentElement;
|
|
104
|
+
if (!parent) return el.scrollHeight;
|
|
105
|
+
parent.appendChild(probe);
|
|
106
|
+
const height = probe.getBoundingClientRect().height;
|
|
107
|
+
probe.remove();
|
|
108
|
+
return Math.ceil(height);
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
export default defineComponent({
|
|
95
112
|
name: "HkTimeline",
|
|
96
113
|
props: {
|
|
@@ -112,22 +129,22 @@ export default defineComponent({
|
|
|
112
129
|
setup(props, { emit }) {
|
|
113
130
|
const host = ref<HTMLElement | null>(null);
|
|
114
131
|
const collapsed = ref(false);
|
|
115
|
-
/** Full-row width
|
|
116
|
-
*
|
|
117
|
-
|
|
132
|
+
/** Full-row width (horizontal) or full-column height (vertical)
|
|
133
|
+
* captured right before collapsing; used as the expand-again
|
|
134
|
+
* threshold (plus hysteresis) while windowed. */
|
|
135
|
+
let fullNaturalSize = 0;
|
|
118
136
|
let observer: ResizeObserver | undefined;
|
|
119
137
|
|
|
120
138
|
const currentIndex = computed(() =>
|
|
121
139
|
props.steps.findIndex((s) => s.key === props.currentKey),
|
|
122
140
|
);
|
|
123
141
|
|
|
124
|
-
/** The window only makes sense for
|
|
125
|
-
*
|
|
142
|
+
/** The window only makes sense for more steps than the window can show
|
|
143
|
+
* (a 3-step timeline is its own window). Horizontal collapse keys off
|
|
144
|
+
* width overflow, vertical collapse off height overflow — the stepper
|
|
145
|
+
* behaves the same whether the host squeezes its row or its column. */
|
|
126
146
|
const windowable = computed(
|
|
127
|
-
() =>
|
|
128
|
-
props.orientation === "horizontal" &&
|
|
129
|
-
props.steps.length > 3 &&
|
|
130
|
-
currentIndex.value >= 0,
|
|
147
|
+
() => props.steps.length > 3 && currentIndex.value >= 0,
|
|
131
148
|
);
|
|
132
149
|
|
|
133
150
|
const windowed = computed(
|
|
@@ -152,23 +169,25 @@ export default defineComponent({
|
|
|
152
169
|
|
|
153
170
|
function measure(): void {
|
|
154
171
|
const el = host.value;
|
|
155
|
-
if (!el ||
|
|
172
|
+
if (!el || props.collapse !== "auto") {
|
|
156
173
|
collapsed.value = false;
|
|
157
174
|
return;
|
|
158
175
|
}
|
|
176
|
+
const vertical = props.orientation === "vertical";
|
|
177
|
+
const fitSize = vertical ? el.clientHeight : el.clientWidth;
|
|
159
178
|
if (collapsed.value) {
|
|
160
|
-
// Expand again only once the host comfortably fits the full row
|
|
161
|
-
// could not fit when we collapsed (16px hysteresis
|
|
162
|
-
// around the exact threshold).
|
|
163
|
-
if (
|
|
179
|
+
// Expand again only once the host comfortably fits the full row or
|
|
180
|
+
// column it could not fit when we collapsed (16px hysteresis
|
|
181
|
+
// against flapping around the exact threshold).
|
|
182
|
+
if (fullNaturalSize > 0 && fitSize >= fullNaturalSize + 16) {
|
|
164
183
|
collapsed.value = false;
|
|
165
|
-
|
|
184
|
+
fullNaturalSize = 0;
|
|
166
185
|
}
|
|
167
186
|
return;
|
|
168
187
|
}
|
|
169
|
-
const natural = naturalRowWidth(el);
|
|
170
|
-
if (natural >
|
|
171
|
-
|
|
188
|
+
const natural = vertical ? naturalColumnHeight(el) : naturalRowWidth(el);
|
|
189
|
+
if (natural > fitSize + 1) {
|
|
190
|
+
fullNaturalSize = natural;
|
|
172
191
|
collapsed.value = true;
|
|
173
192
|
}
|
|
174
193
|
}
|
|
@@ -197,7 +216,7 @@ export default defineComponent({
|
|
|
197
216
|
],
|
|
198
217
|
() => {
|
|
199
218
|
collapsed.value = false;
|
|
200
|
-
|
|
219
|
+
fullNaturalSize = 0;
|
|
201
220
|
void nextTick(measure);
|
|
202
221
|
},
|
|
203
222
|
);
|
|
@@ -288,7 +307,7 @@ export default defineComponent({
|
|
|
288
307
|
class="hk-timeline-link"
|
|
289
308
|
data-segment="edge-before"
|
|
290
309
|
data-status={statusOf(w.beforeIndex)}
|
|
291
|
-
data-fade-dir="left"
|
|
310
|
+
data-fade-dir={props.orientation === "vertical" ? "up" : "left"}
|
|
292
311
|
/>
|
|
293
312
|
)}
|
|
294
313
|
{w.afterIndex >= 0 && w.fadeAfter && (
|
|
@@ -296,7 +315,7 @@ export default defineComponent({
|
|
|
296
315
|
class="hk-timeline-link"
|
|
297
316
|
data-segment="edge-after"
|
|
298
317
|
data-status={statusOf(w.afterIndex)}
|
|
299
|
-
data-fade-dir="right"
|
|
318
|
+
data-fade-dir={props.orientation === "vertical" ? "down" : "right"}
|
|
300
319
|
/>
|
|
301
320
|
)}
|
|
302
321
|
</div>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { setLocale, useI18n } from "./context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression (2026-09-08 user report): the language tag's "Not set"
|
|
7
|
+
* placeholder stayed ENGLISH on a Chinese UI. Root cause —
|
|
8
|
+
* buildLocaleMessages only merged nested domain sections and silently
|
|
9
|
+
* DROPPED every top-level flat key ("hikari::x.y": "…"), so all
|
|
10
|
+
* post-0.3 flat additions (affixPicker.*, messageBox.*,
|
|
11
|
+
* localizedInput.noMatches, …) fell back to the inline English default
|
|
12
|
+
* at runtime on every locale. These tests pin flat-key loading.
|
|
13
|
+
*/
|
|
14
|
+
describe("i18n flat-key loading", () => {
|
|
15
|
+
it("serves top-level flat keys from the locale bundle", async () => {
|
|
16
|
+
await setLocale("zh-Hans");
|
|
17
|
+
const { t } = useI18n();
|
|
18
|
+
expect(t("hikari::affixPicker.unset", "Not set")).toBe("未设置");
|
|
19
|
+
expect(t("hikari::affixPicker.remove", "Remove")).toBe("移除");
|
|
20
|
+
expect(t("hikari::affixPicker.removeConfirmTitle", "Remove entry")).toBe("移除条目");
|
|
21
|
+
expect(t("hikari::messageBox.confirm", "Confirm")).toBe("确认");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("keeps nested legacy sections loading beside the flat keys", async () => {
|
|
25
|
+
await setLocale("zh-Hans");
|
|
26
|
+
const { t } = useI18n();
|
|
27
|
+
expect(t("hikari::localizedInput.chooseLanguage", "Choose editing language")).toBe(
|
|
28
|
+
"选择编辑语言",
|
|
29
|
+
);
|
|
30
|
+
expect(t("hikari::modal.close", "Close")).toBe("关闭");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("other locales serve their own flat translations, not English", async () => {
|
|
34
|
+
await setLocale("ja");
|
|
35
|
+
const { t } = useI18n();
|
|
36
|
+
expect(t("hikari::affixPicker.unset", "Not set")).toBe("未設定");
|
|
37
|
+
await setLocale("ko");
|
|
38
|
+
expect(t("hikari::affixPicker.unset", "Not set")).toBe("미설정");
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/i18n/context.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { reactive } from "vue";
|
|
|
3
3
|
type Messages = Record<string, string>;
|
|
4
4
|
|
|
5
5
|
interface LocaleModule {
|
|
6
|
-
default: Record<string,
|
|
6
|
+
default: Record<string, unknown>;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const localeModules = import.meta.glob<LocaleModule>(
|
|
@@ -18,9 +18,18 @@ function buildLocaleMessages(locale: string): Messages {
|
|
|
18
18
|
for (const [path, mod] of Object.entries(localeModules)) {
|
|
19
19
|
if (!path.startsWith(prefix)) continue;
|
|
20
20
|
const domain = mod.default;
|
|
21
|
-
for (const [
|
|
22
|
-
if (typeof
|
|
23
|
-
|
|
21
|
+
for (const [key, value] of Object.entries(domain)) {
|
|
22
|
+
if (typeof value === "string") {
|
|
23
|
+
// Flat leaf — the appended-at-the-bottom style
|
|
24
|
+
// ("hikari::x.y": "…") every post-0.3 locale addition uses
|
|
25
|
+
// (affixPicker.*, messageBox.*, localizedInput.noMatches, …).
|
|
26
|
+
// The old object-only merge SILENTLY DROPPED these, so every
|
|
27
|
+
// flat key fell back to English at runtime no matter the
|
|
28
|
+
// locale (2026-09-08 user report: "Not set" stayed English).
|
|
29
|
+
merged[key] = value;
|
|
30
|
+
} else if (value !== null && typeof value === "object") {
|
|
31
|
+
// Legacy nested domain section ("components", "chat", …).
|
|
32
|
+
Object.assign(merged, value as Messages);
|
|
24
33
|
}
|
|
25
34
|
}
|
|
26
35
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flat-key parity guard (2026-09-10, follow-up to the flat-loader fix):
|
|
5
|
+
* buildLocaleMessages now serves top-level flat keys ("hikari::x.y": "…")
|
|
6
|
+
* beside the legacy nested sections — but a key missing from a locale's
|
|
7
|
+
* components.json still silently falls back to English for that locale.
|
|
8
|
+
* Every flat key in the en bundle must therefore exist in ALL 11 locale
|
|
9
|
+
* bundles. (This is the guard that would have caught the affixPicker/
|
|
10
|
+
* messageBox flat family missing from 8 locales for months.)
|
|
11
|
+
*/
|
|
12
|
+
const modules = import.meta.glob<{ default: Record<string, unknown> }>(
|
|
13
|
+
"./locales/*/components.json",
|
|
14
|
+
{ eager: true },
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const EXPECTED_LOCALES = [
|
|
18
|
+
"ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe("components.json flat-key parity", () => {
|
|
22
|
+
const bundles = Object.entries(modules).map(([path, mod]) => ({
|
|
23
|
+
locale: path.match(/locales\/([^/]+)\/components\.json/)?.[1] ?? "",
|
|
24
|
+
flat: Object.fromEntries(
|
|
25
|
+
Object.entries(mod.default).filter(([, v]) => typeof v === "string"),
|
|
26
|
+
) as Record<string, string>,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
it("covers all 11 locales", () => {
|
|
30
|
+
expect(bundles.map((b) => b.locale).sort()).toEqual(EXPECTED_LOCALES);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("defines every en flat key in every locale, non-empty", () => {
|
|
34
|
+
const en = bundles.find((b) => b.locale === "en");
|
|
35
|
+
expect(en, "en bundle renders").toBeTruthy();
|
|
36
|
+
const keys = Object.keys(en!.flat);
|
|
37
|
+
expect(keys.length, "en has flat keys").toBeGreaterThan(0);
|
|
38
|
+
for (const { locale, flat } of bundles) {
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
expect(typeof flat[key], `${locale} must define ${key}`).toBe("string");
|
|
41
|
+
expect((flat[key] ?? "").length, `${locale} ${key} must not be empty`).toBeGreaterThan(0);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -182,5 +182,23 @@
|
|
|
182
182
|
"hikari::zoomToolbar.reset": "إعادة تعيين التكبير",
|
|
183
183
|
"hikari::zoomToolbar.zoomIn": "تكبير",
|
|
184
184
|
"hikari::zoomToolbar.zoomOut": "تصغير"
|
|
185
|
-
}
|
|
185
|
+
},
|
|
186
|
+
"hikari::affixPicker.unset": "غير محدد",
|
|
187
|
+
"hikari::affixPicker.title": "الخيارات",
|
|
188
|
+
"hikari::affixPicker.search": "بحث",
|
|
189
|
+
"hikari::affixPicker.empty": "لا تطابقات",
|
|
190
|
+
"hikari::affixPicker.selected": "المحدد",
|
|
191
|
+
"hikari::affixPicker.selectedCount": "تم اختيار {count}",
|
|
192
|
+
"hikari::affixPicker.switchTo": "التبديل إلى",
|
|
193
|
+
"hikari::affixPicker.remove": "إزالة",
|
|
194
|
+
"hikari::affixPicker.useCustom": "استخدام \"{query}\"",
|
|
195
|
+
"hikari::affixPicker.removeConfirmTitle": "إزالة العنصر",
|
|
196
|
+
"hikari::affixPicker.removeConfirm": "إزالة \"{label}\"؟ لا يمكن التراجع عن هذا الإجراء.",
|
|
197
|
+
"hikari::localizedInput.noMatches": "لا توجد لغة مطابقة",
|
|
198
|
+
"hikari::messageBox.confirm": "تأكيد",
|
|
199
|
+
"hikari::messageBox.cancel": "إلغاء",
|
|
200
|
+
"hikari::messageBox.ok": "موافق",
|
|
201
|
+
"hikari::messageBox.alertTitle": "تنبيه",
|
|
202
|
+
"hikari::messageBox.confirmTitle": "يرجى التأكيد",
|
|
203
|
+
"hikari::messageBox.promptTitle": "إدخال"
|
|
186
204
|
}
|
|
@@ -182,5 +182,23 @@
|
|
|
182
182
|
"hikari::zoomToolbar.reset": "Zoom zurücksetzen",
|
|
183
183
|
"hikari::zoomToolbar.zoomIn": "Vergrößern",
|
|
184
184
|
"hikari::zoomToolbar.zoomOut": "Verkleinern"
|
|
185
|
-
}
|
|
185
|
+
},
|
|
186
|
+
"hikari::affixPicker.unset": "Nicht festgelegt",
|
|
187
|
+
"hikari::affixPicker.title": "Optionen",
|
|
188
|
+
"hikari::affixPicker.search": "Suchen",
|
|
189
|
+
"hikari::affixPicker.empty": "Keine Treffer",
|
|
190
|
+
"hikari::affixPicker.selected": "Ausgewählt",
|
|
191
|
+
"hikari::affixPicker.selectedCount": "{count} ausgewählt",
|
|
192
|
+
"hikari::affixPicker.switchTo": "Wechseln zu",
|
|
193
|
+
"hikari::affixPicker.remove": "Entfernen",
|
|
194
|
+
"hikari::affixPicker.useCustom": "„{query}“ verwenden",
|
|
195
|
+
"hikari::affixPicker.removeConfirmTitle": "Eintrag entfernen",
|
|
196
|
+
"hikari::affixPicker.removeConfirm": "„{label}“ entfernen? Dies kann nicht rückgängig gemacht werden.",
|
|
197
|
+
"hikari::localizedInput.noMatches": "Keine passende Sprache",
|
|
198
|
+
"hikari::messageBox.confirm": "Bestätigen",
|
|
199
|
+
"hikari::messageBox.cancel": "Abbrechen",
|
|
200
|
+
"hikari::messageBox.ok": "OK",
|
|
201
|
+
"hikari::messageBox.alertTitle": "Hinweis",
|
|
202
|
+
"hikari::messageBox.confirmTitle": "Bitte bestätigen",
|
|
203
|
+
"hikari::messageBox.promptTitle": "Eingabe"
|
|
186
204
|
}
|
|
@@ -182,5 +182,23 @@
|
|
|
182
182
|
"hikari::zoomToolbar.reset": "Restablecer zoom",
|
|
183
183
|
"hikari::zoomToolbar.zoomIn": "Acercar",
|
|
184
184
|
"hikari::zoomToolbar.zoomOut": "Alejar"
|
|
185
|
-
}
|
|
185
|
+
},
|
|
186
|
+
"hikari::affixPicker.unset": "Sin establecer",
|
|
187
|
+
"hikari::affixPicker.title": "Opciones",
|
|
188
|
+
"hikari::affixPicker.search": "Buscar",
|
|
189
|
+
"hikari::affixPicker.empty": "Sin coincidencias",
|
|
190
|
+
"hikari::affixPicker.selected": "Seleccionados",
|
|
191
|
+
"hikari::affixPicker.selectedCount": "{count} seleccionados",
|
|
192
|
+
"hikari::affixPicker.switchTo": "Cambiar a",
|
|
193
|
+
"hikari::affixPicker.remove": "Quitar",
|
|
194
|
+
"hikari::affixPicker.useCustom": "Usar «{query}»",
|
|
195
|
+
"hikari::affixPicker.removeConfirmTitle": "Quitar elemento",
|
|
196
|
+
"hikari::affixPicker.removeConfirm": "¿Quitar «{label}»? Esto no se puede deshacer.",
|
|
197
|
+
"hikari::localizedInput.noMatches": "Ningún idioma coincidente",
|
|
198
|
+
"hikari::messageBox.confirm": "Confirmar",
|
|
199
|
+
"hikari::messageBox.cancel": "Cancelar",
|
|
200
|
+
"hikari::messageBox.ok": "Aceptar",
|
|
201
|
+
"hikari::messageBox.alertTitle": "Aviso",
|
|
202
|
+
"hikari::messageBox.confirmTitle": "Confirme",
|
|
203
|
+
"hikari::messageBox.promptTitle": "Entrada"
|
|
186
204
|
}
|
|
@@ -182,5 +182,23 @@
|
|
|
182
182
|
"hikari::zoomToolbar.reset": "Réinitialiser le zoom",
|
|
183
183
|
"hikari::zoomToolbar.zoomIn": "Agrandir",
|
|
184
184
|
"hikari::zoomToolbar.zoomOut": "Réduire"
|
|
185
|
-
}
|
|
185
|
+
},
|
|
186
|
+
"hikari::affixPicker.unset": "Non défini",
|
|
187
|
+
"hikari::affixPicker.title": "Options",
|
|
188
|
+
"hikari::affixPicker.search": "Rechercher",
|
|
189
|
+
"hikari::affixPicker.empty": "Aucun résultat",
|
|
190
|
+
"hikari::affixPicker.selected": "Sélectionnés",
|
|
191
|
+
"hikari::affixPicker.selectedCount": "{count} sélectionnés",
|
|
192
|
+
"hikari::affixPicker.switchTo": "Passer à",
|
|
193
|
+
"hikari::affixPicker.remove": "Retirer",
|
|
194
|
+
"hikari::affixPicker.useCustom": "Utiliser « {query} »",
|
|
195
|
+
"hikari::affixPicker.removeConfirmTitle": "Retirer l’entrée",
|
|
196
|
+
"hikari::affixPicker.removeConfirm": "Retirer « {label} » ? Cette action est irréversible.",
|
|
197
|
+
"hikari::localizedInput.noMatches": "Aucune langue correspondante",
|
|
198
|
+
"hikari::messageBox.confirm": "Confirmer",
|
|
199
|
+
"hikari::messageBox.cancel": "Annuler",
|
|
200
|
+
"hikari::messageBox.ok": "OK",
|
|
201
|
+
"hikari::messageBox.alertTitle": "Avis",
|
|
202
|
+
"hikari::messageBox.confirmTitle": "Veuillez confirmer",
|
|
203
|
+
"hikari::messageBox.promptTitle": "Saisie"
|
|
186
204
|
}
|
|
@@ -182,5 +182,23 @@
|
|
|
182
182
|
"hikari::zoomToolbar.reset": "ズームをリセット",
|
|
183
183
|
"hikari::zoomToolbar.zoomIn": "拡大",
|
|
184
184
|
"hikari::zoomToolbar.zoomOut": "縮小"
|
|
185
|
-
}
|
|
185
|
+
},
|
|
186
|
+
"hikari::affixPicker.unset": "未設定",
|
|
187
|
+
"hikari::affixPicker.title": "オプション",
|
|
188
|
+
"hikari::affixPicker.search": "検索",
|
|
189
|
+
"hikari::affixPicker.empty": "一致なし",
|
|
190
|
+
"hikari::affixPicker.selected": "選択中",
|
|
191
|
+
"hikari::affixPicker.selectedCount": "{count} 件選択中",
|
|
192
|
+
"hikari::affixPicker.switchTo": "切り替え",
|
|
193
|
+
"hikari::affixPicker.remove": "削除",
|
|
194
|
+
"hikari::affixPicker.useCustom": "「{query}」を使用",
|
|
195
|
+
"hikari::affixPicker.removeConfirmTitle": "項目を削除",
|
|
196
|
+
"hikari::affixPicker.removeConfirm": "「{label}」を削除しますか?この操作は元に戻せません。",
|
|
197
|
+
"hikari::localizedInput.noMatches": "一致する言語がありません",
|
|
198
|
+
"hikari::messageBox.confirm": "確認",
|
|
199
|
+
"hikari::messageBox.cancel": "キャンセル",
|
|
200
|
+
"hikari::messageBox.ok": "OK",
|
|
201
|
+
"hikari::messageBox.alertTitle": "お知らせ",
|
|
202
|
+
"hikari::messageBox.confirmTitle": "確認してください",
|
|
203
|
+
"hikari::messageBox.promptTitle": "入力"
|
|
186
204
|
}
|
|
@@ -182,5 +182,23 @@
|
|
|
182
182
|
"hikari::zoomToolbar.reset": "줌 초기화",
|
|
183
183
|
"hikari::zoomToolbar.zoomIn": "확대",
|
|
184
184
|
"hikari::zoomToolbar.zoomOut": "축소"
|
|
185
|
-
}
|
|
185
|
+
},
|
|
186
|
+
"hikari::affixPicker.unset": "미설정",
|
|
187
|
+
"hikari::affixPicker.title": "옵션",
|
|
188
|
+
"hikari::affixPicker.search": "검색",
|
|
189
|
+
"hikari::affixPicker.empty": "일치 항목 없음",
|
|
190
|
+
"hikari::affixPicker.selected": "선택됨",
|
|
191
|
+
"hikari::affixPicker.selectedCount": "{count}개 선택됨",
|
|
192
|
+
"hikari::affixPicker.switchTo": "전환",
|
|
193
|
+
"hikari::affixPicker.remove": "제거",
|
|
194
|
+
"hikari::affixPicker.useCustom": "\"{query}\" 사용",
|
|
195
|
+
"hikari::affixPicker.removeConfirmTitle": "항목 제거",
|
|
196
|
+
"hikari::affixPicker.removeConfirm": "\"{label}\"을(를) 제거할까요? 이 작업은 되돌릴 수 없습니다.",
|
|
197
|
+
"hikari::localizedInput.noMatches": "일치하는 언어가 없습니다",
|
|
198
|
+
"hikari::messageBox.confirm": "확인",
|
|
199
|
+
"hikari::messageBox.cancel": "취소",
|
|
200
|
+
"hikari::messageBox.ok": "확인",
|
|
201
|
+
"hikari::messageBox.alertTitle": "알림",
|
|
202
|
+
"hikari::messageBox.confirmTitle": "확인 필요",
|
|
203
|
+
"hikari::messageBox.promptTitle": "입력"
|
|
186
204
|
}
|
|
@@ -182,5 +182,23 @@
|
|
|
182
182
|
"hikari::zoomToolbar.reset": "Redefinir zoom",
|
|
183
183
|
"hikari::zoomToolbar.zoomIn": "Ampliar",
|
|
184
184
|
"hikari::zoomToolbar.zoomOut": "Reduzir"
|
|
185
|
-
}
|
|
185
|
+
},
|
|
186
|
+
"hikari::affixPicker.unset": "Não definido",
|
|
187
|
+
"hikari::affixPicker.title": "Opções",
|
|
188
|
+
"hikari::affixPicker.search": "Pesquisar",
|
|
189
|
+
"hikari::affixPicker.empty": "Sem correspondências",
|
|
190
|
+
"hikari::affixPicker.selected": "Selecionados",
|
|
191
|
+
"hikari::affixPicker.selectedCount": "{count} selecionados",
|
|
192
|
+
"hikari::affixPicker.switchTo": "Mudar para",
|
|
193
|
+
"hikari::affixPicker.remove": "Remover",
|
|
194
|
+
"hikari::affixPicker.useCustom": "Usar \"{query}\"",
|
|
195
|
+
"hikari::affixPicker.removeConfirmTitle": "Remover item",
|
|
196
|
+
"hikari::affixPicker.removeConfirm": "Remover \"{label}\"? Isso não pode ser desfeito.",
|
|
197
|
+
"hikari::localizedInput.noMatches": "Nenhum idioma correspondente",
|
|
198
|
+
"hikari::messageBox.confirm": "Confirmar",
|
|
199
|
+
"hikari::messageBox.cancel": "Cancelar",
|
|
200
|
+
"hikari::messageBox.ok": "OK",
|
|
201
|
+
"hikari::messageBox.alertTitle": "Aviso",
|
|
202
|
+
"hikari::messageBox.confirmTitle": "Confirme",
|
|
203
|
+
"hikari::messageBox.promptTitle": "Entrada"
|
|
186
204
|
}
|
|
@@ -182,5 +182,23 @@
|
|
|
182
182
|
"hikari::zoomToolbar.reset": "Сбросить масштаб",
|
|
183
183
|
"hikari::zoomToolbar.zoomIn": "Увеличить",
|
|
184
184
|
"hikari::zoomToolbar.zoomOut": "Уменьшить"
|
|
185
|
-
}
|
|
185
|
+
},
|
|
186
|
+
"hikari::affixPicker.unset": "Не задано",
|
|
187
|
+
"hikari::affixPicker.title": "Параметры",
|
|
188
|
+
"hikari::affixPicker.search": "Поиск",
|
|
189
|
+
"hikari::affixPicker.empty": "Нет совпадений",
|
|
190
|
+
"hikari::affixPicker.selected": "Выбрано",
|
|
191
|
+
"hikari::affixPicker.selectedCount": "Выбрано: {count}",
|
|
192
|
+
"hikari::affixPicker.switchTo": "Переключиться на",
|
|
193
|
+
"hikari::affixPicker.remove": "Удалить",
|
|
194
|
+
"hikari::affixPicker.useCustom": "Использовать «{query}»",
|
|
195
|
+
"hikari::affixPicker.removeConfirmTitle": "Удалить запись",
|
|
196
|
+
"hikari::affixPicker.removeConfirm": "Удалить «{label}»? Это действие нельзя отменить.",
|
|
197
|
+
"hikari::localizedInput.noMatches": "Нет подходящего языка",
|
|
198
|
+
"hikari::messageBox.confirm": "Подтвердить",
|
|
199
|
+
"hikari::messageBox.cancel": "Отмена",
|
|
200
|
+
"hikari::messageBox.ok": "ОК",
|
|
201
|
+
"hikari::messageBox.alertTitle": "Уведомление",
|
|
202
|
+
"hikari::messageBox.confirmTitle": "Подтвердите",
|
|
203
|
+
"hikari::messageBox.promptTitle": "Ввод"
|
|
186
204
|
}
|