@celestia-island/hikari 0.55.122 → 0.55.123

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.122",
3
+ "version": "0.55.123",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -168,6 +168,135 @@
168
168
  width: 100%;
169
169
  }
170
170
 
171
+ /* ---- Responsive label collapse ---- */
172
+ /* A `collapseLabel` button carries icon + text at wide viewports and
173
+ collapses to the SAME icon-only square below the chosen breakpoint
174
+ (chest 2026-10-05 direction: the ad-hoc `hidden sm:inline` label-span
175
+ pattern left the button as a padded icon rectangle on phones — the
176
+ exact squat shape the icon-only contract exists to prevent). The
177
+ geometry mirrors the icon-only ladder above per size; the breakpoint
178
+ px values mirror the runtime useBreakpoint scale (sm 640 / md 768 /
179
+ lg 1024 / xl 1280) — a media query cannot read the TS constant, keep
180
+ the two tables in sync. The TS side suppresses the collapse class
181
+ together with `block` (a full-width button has nothing to collapse
182
+ into), so no block override is needed here. */
183
+ .hk-btn-label {
184
+ /* Inline-flex so multi-node labels (text + inline marks) stay on one
185
+ row and center against the glyph box. */
186
+ display: inline-flex;
187
+ align-items: center;
188
+ min-width: 0;
189
+ }
190
+
191
+ @mixin hk-btn-collapse-geometry($square) {
192
+ .hk-btn-label {
193
+ display: none;
194
+ }
195
+
196
+ /* The exact icon-only geometry (all-side padding zero, width locked),
197
+ mirrored per size from the ladder above. */
198
+ padding: 0;
199
+ box-sizing: border-box;
200
+ width: $square;
201
+ }
202
+
203
+ .hk-btn-label-collapse-sm.hk-btn-xs {
204
+ @media (max-width: 639.98px) {
205
+ @include hk-btn-collapse-geometry(1.375rem);
206
+ }
207
+ }
208
+
209
+ .hk-btn-label-collapse-sm.hk-btn-sm {
210
+ @media (max-width: 639.98px) {
211
+ @include hk-btn-collapse-geometry(1.75rem);
212
+ }
213
+ }
214
+
215
+ .hk-btn-label-collapse-sm.hk-btn-md {
216
+ @media (max-width: 639.98px) {
217
+ @include hk-btn-collapse-geometry(2.5rem);
218
+ }
219
+ }
220
+
221
+ .hk-btn-label-collapse-sm.hk-btn-lg {
222
+ @media (max-width: 639.98px) {
223
+ @include hk-btn-collapse-geometry(2.75rem);
224
+ }
225
+ }
226
+
227
+ .hk-btn-label-collapse-md.hk-btn-xs {
228
+ @media (max-width: 767.98px) {
229
+ @include hk-btn-collapse-geometry(1.375rem);
230
+ }
231
+ }
232
+
233
+ .hk-btn-label-collapse-md.hk-btn-sm {
234
+ @media (max-width: 767.98px) {
235
+ @include hk-btn-collapse-geometry(1.75rem);
236
+ }
237
+ }
238
+
239
+ .hk-btn-label-collapse-md.hk-btn-md {
240
+ @media (max-width: 767.98px) {
241
+ @include hk-btn-collapse-geometry(2.5rem);
242
+ }
243
+ }
244
+
245
+ .hk-btn-label-collapse-md.hk-btn-lg {
246
+ @media (max-width: 767.98px) {
247
+ @include hk-btn-collapse-geometry(2.75rem);
248
+ }
249
+ }
250
+
251
+ .hk-btn-label-collapse-lg.hk-btn-xs {
252
+ @media (max-width: 1023.98px) {
253
+ @include hk-btn-collapse-geometry(1.375rem);
254
+ }
255
+ }
256
+
257
+ .hk-btn-label-collapse-lg.hk-btn-sm {
258
+ @media (max-width: 1023.98px) {
259
+ @include hk-btn-collapse-geometry(1.75rem);
260
+ }
261
+ }
262
+
263
+ .hk-btn-label-collapse-lg.hk-btn-md {
264
+ @media (max-width: 1023.98px) {
265
+ @include hk-btn-collapse-geometry(2.5rem);
266
+ }
267
+ }
268
+
269
+ .hk-btn-label-collapse-lg.hk-btn-lg {
270
+ @media (max-width: 1023.98px) {
271
+ @include hk-btn-collapse-geometry(2.75rem);
272
+ }
273
+ }
274
+
275
+ .hk-btn-label-collapse-xl.hk-btn-xs {
276
+ @media (max-width: 1279.98px) {
277
+ @include hk-btn-collapse-geometry(1.375rem);
278
+ }
279
+ }
280
+
281
+ .hk-btn-label-collapse-xl.hk-btn-sm {
282
+ @media (max-width: 1279.98px) {
283
+ @include hk-btn-collapse-geometry(1.75rem);
284
+ }
285
+ }
286
+
287
+ .hk-btn-label-collapse-xl.hk-btn-md {
288
+ @media (max-width: 1279.98px) {
289
+ @include hk-btn-collapse-geometry(2.5rem);
290
+ }
291
+ }
292
+
293
+ .hk-btn-label-collapse-xl.hk-btn-lg {
294
+ @media (max-width: 1279.98px) {
295
+ @include hk-btn-collapse-geometry(2.75rem);
296
+ }
297
+ }
298
+
299
+
171
300
  /* ---- Primary ---- */
172
301
 
173
302
  .hk-btn-primary {
@@ -255,3 +255,134 @@ describe("HkButton attr fallthrough (inheritAttrs: false)", () => {
255
255
  expect(cls).toContain("extra-class");
256
256
  });
257
257
  });
258
+
259
+ describe("HkButton label prop + responsive collapse contract", () => {
260
+ it("renders the label prop in a .hk-btn-label span and keeps the button textual", () => {
261
+ const c = mount(h(HkButton, { icon: "gauge", label: "Test all" }));
262
+ const btn = button(c);
263
+ const label = c.querySelector(".hk-btn-label");
264
+ expect(label).not.toBeNull();
265
+ expect(label!.textContent).toBe("Test all");
266
+ // icon + label = a text button at wide viewports, never icon-only.
267
+ expect(btn.className).not.toContain("hk-btn-icon-only");
268
+ // The label prop is the accessible name unless one is given.
269
+ expect(btn.getAttribute("aria-label")).toBe("Test all");
270
+ });
271
+
272
+ it("prefers the label prop over the default slot and an explicit ariaLabel over the label", () => {
273
+ const c = mount(h(HkButton, { label: "From prop" }, () => "From slot"));
274
+ expect(c.querySelector(".hk-btn-label")!.textContent).toBe("From prop");
275
+ expect(c.textContent).not.toContain("From slot");
276
+
277
+ const named = mount(h(HkButton, { label: "Visible", ariaLabel: "Custom" }));
278
+ expect(button(named).getAttribute("aria-label")).toBe("Custom");
279
+ });
280
+
281
+ it("emits the collapse modifier class from collapseLabel", () => {
282
+ const c = mount(h(HkButton, { icon: "gauge", label: "Probe", collapseLabel: "sm" }));
283
+ expect(button(c).className).toContain("hk-btn-label-collapse-sm");
284
+ });
285
+
286
+ it("suppresses the collapse class with block or shortcut (nothing to collapse into)", () => {
287
+ const blocked = mount(
288
+ h(HkButton, { icon: "gauge", label: "Probe", collapseLabel: "sm", block: true }),
289
+ );
290
+ expect(button(blocked).className).not.toContain("hk-btn-label-collapse");
291
+
292
+ const withShortcut = mount(
293
+ h(HkButton, { icon: "gauge", label: "Probe", collapseLabel: "sm", shortcut: "Ctrl+B" }),
294
+ );
295
+ expect(button(withShortcut).className).not.toContain("hk-btn-label-collapse");
296
+ });
297
+
298
+ it("keeps label-prop buttons out of the icon-only square class", () => {
299
+ // The narrow-end square is the SCSS media query's job; the class
300
+ // must not flip at mount time or the wide viewport would square a
301
+ // labeled button.
302
+ const c = mount(h(HkButton, { icon: "gauge", label: "Probe", collapseLabel: "sm" }));
303
+ expect(button(c).className).not.toContain("hk-btn-icon-only");
304
+ });
305
+ });
306
+
307
+ // SCSS geometry contract for the responsive collapse (same textual style
308
+ // as the icon-only contract above — happy-dom does not lay out): every
309
+ // collapse breakpoint × size pair must exist, must collapse below the
310
+ // useBreakpoint px scale (sm 640 / md 768 / lg 1024 / xl 1280), and must
311
+ // land on the exact icon-only square width of its size.
312
+ describe("HkButton label-collapse SCSS contract", () => {
313
+ const scss = readFileSync(join(here, "HkButton.scss"), "utf-8");
314
+
315
+ const breakpoints: Array<[string, string]> = [
316
+ ["sm", "639.98px"],
317
+ ["md", "767.98px"],
318
+ ["lg", "1023.98px"],
319
+ ["xl", "1279.98px"],
320
+ ];
321
+ const squares: Array<[string, string]> = [
322
+ ["xs", "1.375rem"],
323
+ ["sm", "1.75rem"],
324
+ ["md", "2.5rem"],
325
+ ["lg", "2.75rem"],
326
+ ];
327
+
328
+ it("collapses every breakpoint × size pair to the icon-only square", () => {
329
+ for (const [bp, query] of breakpoints) {
330
+ for (const [size, square] of squares) {
331
+ const rule = scss.match(
332
+ new RegExp(
333
+ `\\.hk-btn-label-collapse-${bp}\\.hk-btn-${size}\\s*{\\s*@media \\(max-width: ${query}\\) {`,
334
+ ),
335
+ )?.[0];
336
+ expect(rule, `${bp}×${size} collapse rule must exist`).toBeTruthy();
337
+ }
338
+ }
339
+ });
340
+
341
+ it("hides the label and lands on the exact square inside each media block", () => {
342
+ // The per-pair rules @include one shared mixin; the geometry lives in
343
+ // its definition (the compiled CSS materializes it per media block).
344
+ const start = scss.indexOf("@mixin hk-btn-collapse-geometry($square) {");
345
+ expect(start, "collapse mixin must exist").toBeGreaterThan(-1);
346
+ // Slice to the mixin's closing brace (nested rules make a [^}] scan
347
+ // stop early, so index from the header to the documented tail line).
348
+ const end = scss.indexOf("width: $square;", start);
349
+ const mixin = scss.slice(start, end + "width: $square;".length);
350
+ expect(mixin).toContain(".hk-btn-label");
351
+ expect(mixin).toContain("display: none;");
352
+ expect(mixin).toContain("padding: 0;");
353
+ expect(mixin).toContain("box-sizing: border-box;");
354
+ expect(mixin).toContain("width: $square");
355
+
356
+ const smBlock = scss.match(
357
+ /\.hk-btn-label-collapse-sm\.hk-btn-sm\s*{\s*@media \(max-width: 639\.98px\) {([^}]*)}/,
358
+ )?.[1];
359
+ expect(smBlock, "sm×sm collapse body must exist").toBeTruthy();
360
+ expect(smBlock).toContain("@include hk-btn-collapse-geometry(1.75rem)");
361
+ });
362
+
363
+ it("mirrors the useBreakpoint scale breakpoints in order", () => {
364
+ // Keep the SCSS table honest against the runtime scale document —
365
+ // the px values appear in ascending breakpoint order.
366
+ const queries = [...scss.matchAll(/@media \(max-width: (\d+(?:\.\d+)?px)\)/g)].map(
367
+ (m) => m[1],
368
+ );
369
+ expect(queries).toEqual([
370
+ "639.98px",
371
+ "639.98px",
372
+ "639.98px",
373
+ "639.98px",
374
+ "767.98px",
375
+ "767.98px",
376
+ "767.98px",
377
+ "767.98px",
378
+ "1023.98px",
379
+ "1023.98px",
380
+ "1023.98px",
381
+ "1023.98px",
382
+ "1279.98px",
383
+ "1279.98px",
384
+ "1279.98px",
385
+ "1279.98px",
386
+ ]);
387
+ });
388
+ });
@@ -13,6 +13,14 @@ export type ButtonVariant =
13
13
  | "outline";
14
14
  export type ButtonSize = "xs" | "sm" | "md" | "lg";
15
15
 
16
+ /** Breakpoints below which a `collapseLabel` button hides its label and
17
+ * collapses to the icon-only square. Mirrors the runtime useBreakpoint
18
+ * scale (sm 640 / md 768 / lg 1024 / xl 1280) — the SCSS side repeats
19
+ * the px values because a media query cannot read a TS constant; keep
20
+ * the two in sync. "xs" is deliberately absent: collapse-at-xs means
21
+ * "always collapsed", which is just the icon prop + label-less button. */
22
+ export type ButtonLabelCollapse = "sm" | "md" | "lg" | "xl";
23
+
16
24
  export default defineComponent({
17
25
  name: "HkButton",
18
26
  inheritAttrs: false,
@@ -26,6 +34,24 @@ export default defineComponent({
26
34
  shortcut: { type: String, default: undefined },
27
35
  icon: { type: String, default: undefined },
28
36
  suffix: { type: String, default: undefined },
37
+ /** Plain-text label, rendered in a `.hk-btn-label` span after the
38
+ * glyph. Pairs with `collapseLabel`: at wide viewports the button
39
+ * reads icon + text; below the chosen breakpoint the label hides
40
+ * and the button collapses to the icon-only square. When set, the
41
+ * default slot is ignored (one label source per button) and the
42
+ * accessible name falls back to this text, so the button keeps its
43
+ * name at breakpoints where the visible label is display:none. */
44
+ label: { type: String, default: undefined },
45
+ /** Breakpoint below which the label hides and the button collapses
46
+ * to the icon-only square (see `ButtonLabelCollapse`). Requires
47
+ * `label`; ignored together with `block` (a full-width button has
48
+ * nothing to collapse into) and with `shortcut` (the shortcut chip
49
+ * is an extra child the square width would visibly clip — the same
50
+ * exclusion the icon-only contract applies). */
51
+ collapseLabel: {
52
+ type: String as PropType<ButtonLabelCollapse>,
53
+ default: undefined,
54
+ },
29
55
  },
30
56
  emits: {
31
57
  click: (_e: MouseEvent) => true,
@@ -40,6 +66,9 @@ export default defineComponent({
40
66
  props.block ? "hk-btn-block" : "",
41
67
  props.loading ? "hk-btn-loading" : "",
42
68
  props.shortcut ? "hk-btn-has-shortcut" : "",
69
+ props.collapseLabel && props.label && !props.block && !props.shortcut
70
+ ? `hk-btn-label-collapse-${props.collapseLabel}`
71
+ : "",
43
72
  ]);
44
73
 
45
74
  return () => {
@@ -51,9 +80,13 @@ export default defineComponent({
51
80
  // reactive, so a cached flag would go stale when a parent toggles
52
81
  // the label slot without touching the icon props. A shortcut chip
53
82
  // is excluded: it renders as an extra flex child the fixed square
54
- // width would visibly clip.
83
+ // width would visibly clip. A `label` prop counts as text (the
84
+ // responsive collapse handles the narrow end via CSS instead).
55
85
  const iconOnly =
56
- !slots.default && Boolean(props.icon || props.suffix) && !props.shortcut;
86
+ !slots.default &&
87
+ props.label === undefined &&
88
+ Boolean(props.icon || props.suffix) &&
89
+ !props.shortcut;
57
90
 
58
91
  return (
59
92
  <button
@@ -62,7 +95,7 @@ export default defineComponent({
62
95
  disabled={props.disabled || props.loading}
63
96
  class={[buttonClass.value, iconOnly ? "hk-btn-icon-only" : "", attrs.class]}
64
97
  style={attrs.style || undefined}
65
- aria-label={props.ariaLabel}
98
+ aria-label={props.ariaLabel ?? props.label}
66
99
  aria-busy={props.loading || undefined}
67
100
  onClick={(e) => emit("click", e)}
68
101
  >
@@ -116,7 +149,11 @@ export default defineComponent({
116
149
  <HIcon name={props.icon} size={16} />
117
150
  </span>
118
151
  ) : null}
119
- {slots.default?.()}
152
+ {props.label !== undefined && props.label !== "" ? (
153
+ <span class="hk-btn-label">{props.label}</span>
154
+ ) : (
155
+ slots.default?.()
156
+ )}
120
157
  {props.suffix ? (
121
158
  <span class="hk-btn-suffix">
122
159
  <HIcon name={props.suffix} size={16} />
@@ -8,7 +8,7 @@
8
8
  // badges, nav tags) resolve without the wildcard import. Unknown names fall
9
9
  // back to `Info`, matching the previous `|| LucideIcons.Info` behavior.
10
10
 
11
- import { Activity, ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Ban, Battery, Bell, Bot, Box, Braces, Brain, Cable, Calendar, Camera, ChartBarBig as BarChart3, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle, CircleAlert as AlertCircle, CircleCheck as CheckCircle, CircleCheckBig as CheckCircle2, CircleDot, Clock, Copy, Cpu, Database, Dot, Download, Ellipsis as MoreHorizontal, ExternalLink, Eye, FileArchive, FileCode, FileImage, FileText, Filter, Flag, Flame, FolderOpen, Gauge, Globe, HardDrive, History, Image, Info, Kanban, Key, KeyRound, Layers, LayoutDashboard, LoaderCircle as Loader2, LogOut, Lock, Maximize, Maximize2, MessageSquare, Mic, MicOff, Minimize, Minus, Monitor, Network, Package, Pause, Pencil, Play, Plug, Plus, Radio, RefreshCw, RotateCw, ScanSearch, Save, Search, Send, Server, Settings, Share2, Shield, Sparkles, Star, Table, Tag, Thermometer, Trash2, TriangleAlert as AlertTriangle, Upload, Video, Volume2, VolumeX, Webhook, Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut } from "lucide-vue-next";
11
+ import { Activity, ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Ban, Battery, Bell, Bot, Box, Braces, Brain, Cable, Calendar, Camera, ChartBarBig as BarChart3, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle, CircleAlert as AlertCircle, CircleCheck as CheckCircle, CircleCheckBig as CheckCircle2, CircleDot, Clock, Copy, Cpu, Database, Dot, Download, Ellipsis as MoreHorizontal, ExternalLink, Eye, FileArchive, FileCode, FileImage, FileText, Filter, Flag, Flame, FolderOpen, Gauge, Globe, HardDrive, History, Image, Info, Kanban, Key, KeyRound, Layers, LayoutDashboard, LoaderCircle as Loader2, LogOut, Lock, Maximize, Maximize2, MessageSquare, Mic, MicOff, Minimize, Minus, Monitor, Network, Package, Pause, Pencil, Play, Plug, Plus, Radio, RefreshCw, RotateCw, ScanSearch, Save, Search, Send, Server, Settings, Share2, Shield, SlidersHorizontal, Sparkles, Star, Table, Tag, Thermometer, Trash2, TriangleAlert as AlertTriangle, Upload, Video, Volume2, VolumeX, Webhook, Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut } from "lucide-vue-next";
12
12
  import type { Component } from "vue";
13
13
 
14
14
 
@@ -109,7 +109,7 @@ const REGISTRY: Record<string, Component> = {
109
109
  Network, Package, Pause, Pencil, Play, Plug, Plus, Radio, RefreshCw,
110
110
  RotateCw,
111
111
  ScanSearch, Save, Search, Send, Server, Settings, Share2, Shield,
112
- Sparkles, Star, Table, Tag, Thermometer, Trash2, Upload, Video, Volume2,
112
+ SlidersHorizontal, Sparkles, Star, Table, Tag, Thermometer, Trash2, Upload, Video, Volume2,
113
113
  VolumeX, Webhook, Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut,
114
114
  };
115
115