its-swiss 0.7.2 → 0.8.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f884ebf4471bd9c1bdc232a35cf327949466b3db98748a6bb008f985314e091
4
- data.tar.gz: d8a1e3b75d21f52e96850a57adf3dcbc72242638f9f7d62fe71126bf8ffa5272
3
+ metadata.gz: 609e2de8676ac502b9fd59c7c0eb8fa4a57e2c6ba5a0a7ab37b618f81b3203b4
4
+ data.tar.gz: 7fced9860618baa6456eb521f4b2a1e6fecb667f8adc3c9f550c990d067669c7
5
5
  SHA512:
6
- metadata.gz: 5157d8c0cd8ad7db0db4025199d76ce4888ed2ad85d4ffb69a4427c97539482f646f56fe1fb274c831cf9f4d4a99a02185e9eb230cbc6516294bb805f631f815
7
- data.tar.gz: 7a2ca8413c7cfdaa998a033bdeb9cae176cf7487cdab8af8162b784d01006f264c835a092a11f48bff1c549459dd61166259b6ca9f73771cd3241d0bbfc1430c
6
+ metadata.gz: 0fc7073ab2bf1bc8f39db1a2124b08e746d3811f71fbf38e0573af7fb6e59912e776e3f6bddce62802b08d9ae6528f5eb51d15d147904e0d4516f69db738df9c
7
+ data.tar.gz: 914e4ea3dee558b14821e3a0b3ce134220520b7be6d9d5d10727d2f8f6b173d025868aa30c4316b81e523ff7bfb8fb5a2563a2219a33b2686f9be2b8b0753fca
data/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
2
2
 
3
3
  Semver. Consumers pin `~> 0.1`.
4
4
 
5
+ ## 0.8.0 — 2026-09-07
6
+
7
+ The third consumer, and what it and the second found in common.
8
+
9
+ ### Added
10
+
11
+ - **A page head.** `page_head(title, lede:) { actions }` writes the one
12
+ shape every page opens with: the title in the page-title register, its
13
+ lede when it has one, and the actions in a run beside them, wrapping
14
+ under the title when the page is narrow. Pandatone, Stripeclub and
15
+ Badger had each drawn their own, and no two agreed on the size of the
16
+ title or where the buttons went. The document is titled from it unless
17
+ the view has titled itself.
18
+
19
+ - **Filters.** A block of registers that narrow a list, each a quiet label
20
+ and then the choices, in the same two positions every time — a search, a
21
+ tag, an order, a size are the same kind of control, and are built the
22
+ same way. `filter_register(label, choices, name:)` writes one; the choice
23
+ in force carries `aria-current`, which the CSS colours and weights, for
24
+ the reason the nav says it twice. `search_form(url, frame:, keep:)`
25
+ writes the first register: a search that narrows the list as you type,
26
+ into the Turbo Frame named, carrying the other choices so a search does
27
+ not drop them. Two slots go with it: `--filter-label`, the column the
28
+ labels occupy, and `--card`.
29
+
30
+ - **Cards.** `.cards` on a `.grid` lays a list out on the page's own
31
+ fields, each item `--card` fields wide, two lines between the rows.
32
+ `.card__link`, `.card__figure`, `.card__name`, `.card__meta` are the
33
+ parts. No box: the picture is the only filled area a card has.
34
+
35
+ - **A menu in the nav, and a subnav under the masthead.** `nav_menu(label,
36
+ current:) { links }` is a destination that opens into destinations — a
37
+ details element, so it opens with no script — and carries the accent and
38
+ the weight when where you are is inside it. The shell renders `:subnav`,
39
+ when an application fills it, as a shaded band under the masthead: a
40
+ second layer of destinations, inside the one you are in. A host that
41
+ carries several tools puts the tools in the menu and each tool's own
42
+ sections in the band.
43
+
44
+ - **A live search controller.** `its_swiss/live_search_controller.js`,
45
+ pinned by the engine like the clipboard, submits a search form as you
46
+ type and takes away the button it makes redundant. The host registers it
47
+ as `its-swiss-live-search`, beside the clipboard.
48
+
49
+ ### Changed
50
+
51
+ - The micro register and the quiet colour take `.filter__label` and
52
+ `.card__meta`.
53
+
5
54
  ## 0.7.2 — 2026-09-04
6
55
 
7
56
  ### Changed
data/README.md CHANGED
@@ -34,7 +34,7 @@ current one at `/<version>.html` for anything that needs to pin.
34
34
  | In the gem | Stays in the application |
35
35
  | --- | --- |
36
36
  | Tokens, reset, base typography | The grid itself — which blocks span which fields |
37
- | Masthead, nav, footer, table, form, button, definition list, pagination | Domain components |
37
+ | Masthead, nav, page head, footer, table, form, button, definition list, pagination, filters, cards | Domain components |
38
38
  | A base layout shell with `content_for` slots | Page layouts beyond the shell |
39
39
  | View transition names and durations | Which pages transition to which |
40
40
  | The value scale and the accent slot | Any hue, any palette knowledge |
@@ -71,6 +71,8 @@ holding all of them. Nothing here has a default the gem could pick honestly.
71
71
  | `--columns`, `--gutter` | How many fields this problem has |
72
72
  | `--line` | The baseline: the interval everything vertical registers to |
73
73
  | `--ratio` | A picture's aspect ratio, per figure — the library cannot read one |
74
+ | `--card` | How many fields a card takes, and at which widths |
75
+ | `--filter-label` | The column a filter's label occupies, so every register's choices start on one line |
74
76
  | `--face-150`, `--face-200`, `--face-100` | Read, not set: the face a register names for its ratio of leading to size |
75
77
 
76
78
  ### The baseline
@@ -313,6 +315,59 @@ it stops on a field line:
313
315
  --measure: calc(var(--field) * 3 + var(--gutter) * 2);
314
316
  ```
315
317
 
318
+ ### The page head
319
+
320
+ What a page is and what can be done to it, in one shape on every page: the
321
+ title in the page-title register, its lede when it has one, and the actions in
322
+ a run beside them, wrapping under the title when the page is narrow.
323
+
324
+ ```erb
325
+ <%= page_head "Palettes", lede: "Every palette in the library." do %>
326
+ <%= link_to "New palette", new_palette_path, class: "button button--primary" %>
327
+ <% end %>
328
+ ```
329
+
330
+ The document is titled from it unless the view has already set `:title`.
331
+
332
+ ### Filters and cards
333
+
334
+ What narrows a list, and the list. A search that filters as you type, and a
335
+ register for each other way of narrowing it — a tag, an order, a size — in the
336
+ same two positions every time: a quiet label, then the choices, the one in
337
+ force carrying `aria-current`, which the CSS colours and weights.
338
+
339
+ ```erb
340
+ <div class="filters">
341
+ <%= search_form palettes_path, frame: "palettes", keep: { tag: params[:tag], sort: params[:sort] } %>
342
+ <%= filter_register "Tagged", [ [ "All", palettes_path, params[:tag].blank? ],
343
+ *tags.map { |t| [ t, palettes_path(tag: t), params[:tag] == t ] } ],
344
+ name: "tag" %>
345
+ </div>
346
+
347
+ <%= turbo_frame_tag "palettes", target: "_top" do %>
348
+ <ul class="grid cards">
349
+ <li class="card">
350
+ <a class="card__link" href="…">
351
+ <figure class="card__figure figure" style="--ratio: 1.5"><img src="…" alt=""></figure>
352
+ <span class="card__name">Brand Core</span>
353
+ </a>
354
+ <span class="card__meta">4 swatches</span>
355
+ </li>
356
+ </ul>
357
+ <% end %>
358
+ ```
359
+
360
+ The search form belongs outside the frame it fills, so only the results are
361
+ replaced and the field keeps its cursor. `keep:` carries the other choices as
362
+ hidden fields, so a search does not drop the tag or the order you were reading
363
+ in. The button is there for a browser that runs no script; the controller
364
+ takes it away once it has connected.
365
+
366
+ A card is `--card` fields wide on the page's own grid — say how many once, and
367
+ again at each width that changes it. The picture is a `.figure` with its
368
+ `--ratio` declared, so its box is whole lines at any width and the name under
369
+ it stays on the grid.
370
+
316
371
  ## The layout shell
317
372
 
318
373
  The installer writes `app/views/layouts/application.html.erb` as a layout
@@ -345,6 +400,7 @@ Slots, all optional:
345
400
  | `:head` | Anything else that belongs in `<head>` |
346
401
  | `:mark` | The wordmark. No mark and no nav means no masthead at all |
347
402
  | `:nav` | The destinations |
403
+ | `:subnav` | A second layer of destinations, inside the one you are in: a shaded band under the masthead |
348
404
  | `:main_class` | What the page's main region is, if it is a grid |
349
405
  | `:footer` | Whatever belongs after the page |
350
406
 
@@ -359,9 +415,13 @@ beyond the shell is the application's, for the same reason its grid is.
359
415
  | `its_swiss_stylesheet_tags` | The seven links, tracked for Turbo |
360
416
  | `its_swiss_typeface(regular:, bold:)` | The application's typeface, declared under the library's face names |
361
417
  | `nav_link_to(name, url, current:)` | A destination, with `aria-current` when you are at it |
418
+ | `nav_menu(label, current:) { links }` | A destination that opens into destinations, with no script |
362
419
  | `copy_button(value)` | A value that copies itself |
363
420
  | `its_swiss_form_with(...)` | `form_with`, already holding the library's builder |
364
421
  | `its_swiss_page_numbers(page, pages)` | Which numbers a run of them shows, elided |
422
+ | `page_head(title, lede:) { actions }` | The one shape every page opens with |
423
+ | `filter_register(label, choices, name:)` | One register of a filter block |
424
+ | `search_form(url, frame:, keep:)` | A search that narrows a list as you type |
365
425
 
366
426
  ### Pagination
367
427
 
@@ -376,6 +436,24 @@ a page, a total, and something that turns a number into a URL:
376
436
  Long runs are elided around the current page. `window:` (default 2) sets how
377
437
  many neighbours show; `label:` names the `<nav>` for a screen reader.
378
438
 
439
+ ### JavaScript
440
+
441
+ Two Stimulus controllers, pinned by the engine so an application that upgrades
442
+ the gem gets the new file without touching its importmap. They are outside
443
+ `controllers/`, so an application registers them by hand, once:
444
+
445
+ ```js
446
+ // app/javascript/controllers/index.js
447
+ import ItsSwissClipboardController from "its_swiss/clipboard_controller"
448
+ import ItsSwissLiveSearchController from "its_swiss/live_search_controller"
449
+ application.register("its-swiss-clipboard", ItsSwissClipboardController)
450
+ application.register("its-swiss-live-search", ItsSwissLiveSearchController)
451
+ ```
452
+
453
+ `copy_button` and `search_form` write the `data-controller` attributes; a page
454
+ without the registrations still works, with the value selectable and the
455
+ search's button on the page.
456
+
379
457
  ## The form builder
380
458
 
381
459
  One shape for every field: a label, a control, and — when there is something to
@@ -464,7 +542,8 @@ ladder; the names are the library's rather than the application's.
464
542
  | `.masthead__nav` | `.nav` |
465
543
  | `.channels` | `.pairs` |
466
544
  | `.form`, `.field`, `.button*`, `.copy`, `.errors`, `.hint`, `.empty` | unchanged |
467
- | `--columns-dense`, `--card`, `--card-wide`, `.swatch*`, `.tag*`, `.filter*` | stay in Pandatone |
545
+ | `.tag*`, `.filter*`, `.page-head`, `live-search` | `.filter*`, `.page-head`, `.cards`, `its-swiss-live-search` in the gem since 0.8.0 |
546
+ | `--columns-dense`, `--card-wide`, `.swatch*` | stay in Pandatone |
468
547
 
469
548
  Two behavioural differences to know about:
470
549
 
@@ -0,0 +1,31 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Submits a search form as you type, into the Turbo Frame it targets.
4
+ //
5
+ // The filtering stays on the server, in the same scope the button uses: one
6
+ // definition of what a search matches, not a Ruby one and a drifting
7
+ // JavaScript copy. The form sits outside its frame so only the results are
8
+ // replaced and the field keeps its focus and its cursor.
9
+ //
10
+ // The button goes when this connects, because from that moment it is a second
11
+ // way to do the thing that has just been done. It is hidden here rather than
12
+ // left out of the markup for the browser that never runs this: without the
13
+ // controller the button is the only way to search, so it has to be on the
14
+ // page for the page to work.
15
+ export default class extends Controller {
16
+ static targets = [ "submit" ]
17
+ static values = { delay: { type: Number, default: 200 } }
18
+
19
+ connect() {
20
+ if (this.hasSubmitTarget) this.submitTarget.hidden = true
21
+ }
22
+
23
+ search() {
24
+ clearTimeout(this.timeout)
25
+ this.timeout = setTimeout(() => this.element.requestSubmit(), this.delayValue)
26
+ }
27
+
28
+ disconnect() {
29
+ clearTimeout(this.timeout)
30
+ }
31
+ }
@@ -70,6 +70,91 @@
70
70
  font-weight: 700;
71
71
  }
72
72
 
73
+ /* A menu in the nav: a destination that opens into destinations. A
74
+ details element, so it opens and closes with no script, and a summary
75
+ set like the other destinations with a small mark that says it opens.
76
+ The list floats under it on paper with a keyline, the one floating box
77
+ in the style — a menu that pushed the page down would not be a menu. */
78
+ .menu {
79
+ position: relative;
80
+ }
81
+
82
+ .menu > summary {
83
+ list-style: none;
84
+ cursor: pointer;
85
+ white-space: nowrap;
86
+ }
87
+
88
+ .menu > summary::-webkit-details-marker { display: none; }
89
+
90
+ .menu > summary::after {
91
+ content: " \25BE";
92
+ color: var(--ink-quiet);
93
+ }
94
+
95
+ .menu > summary:hover,
96
+ .menu[open] > summary { color: var(--ink); }
97
+
98
+ /* Where you are, when where you are is inside the menu: said twice, the
99
+ way the nav says it. */
100
+ .menu--current > summary {
101
+ color: var(--accent);
102
+ font-weight: 700;
103
+ }
104
+
105
+ /* Out of the flow, but still on the grid: a line of padding above and
106
+ below, and the keyline drawn inside, so every link in it sits on a
107
+ line of the page under it. */
108
+ .menu__list {
109
+ position: absolute;
110
+ inset-inline-start: calc(-1 * var(--space-2));
111
+ top: 100%;
112
+ z-index: 1;
113
+ display: flex;
114
+ flex-direction: column;
115
+ min-width: max-content;
116
+ padding: var(--line) var(--space-2);
117
+ background: var(--paper);
118
+ box-shadow: inset 0 0 0 var(--rule-hair) var(--rule);
119
+ }
120
+
121
+ .menu__list a {
122
+ display: block;
123
+ text-decoration: none;
124
+ }
125
+
126
+ .menu__list a:hover { color: var(--ink); }
127
+
128
+ /* --- Subnav -------------------------------------------------------------
129
+ A second layer of destinations, inside the one you are in: what a tool
130
+ has beside what the application has. A shaded band under the masthead,
131
+ three lines tall the way the masthead is, with the same run of links
132
+ the nav has and the same marking of where you are. Rendered by the
133
+ shell when the application fills :subnav, and not at all otherwise. */
134
+ /* The rule is drawn inside the band rather than under it, so the band and
135
+ the page inside it are both three whole lines. */
136
+ .subnav {
137
+ background: var(--paper-shaded);
138
+ box-shadow: inset 0 calc(-1 * var(--rule-hair)) 0 var(--rule);
139
+ }
140
+
141
+ .subnav > .page {
142
+ display: flex;
143
+ flex-wrap: wrap;
144
+ align-items: end;
145
+ gap: var(--line) var(--space-3);
146
+ padding-block: var(--line);
147
+ }
148
+
149
+ .subnav a { text-decoration: none; }
150
+
151
+ .subnav a:hover { color: var(--ink); }
152
+
153
+ .subnav [aria-current="page"] {
154
+ color: var(--accent);
155
+ font-weight: 700;
156
+ }
157
+
73
158
  /* --- Footer -------------------------------------------------------------
74
159
  A hairline, the micro register, and whatever the application has to say
75
160
  after the page. It carries no links of its own: what belongs down there
@@ -89,6 +174,38 @@
89
174
  line-height: var(--line);
90
175
  }
91
176
 
177
+ /* --- Page head ----------------------------------------------------------
178
+ What a page is and what can be done to it. The title and, when it has
179
+ one, its lede on the left; the actions on the right, wrapping under
180
+ the title when the page is narrow. Everything after it starts two
181
+ lines down, which is the gap a section gets.
182
+
183
+ One shape for every page, so a reader arriving from another application
184
+ set in this style already knows where the name is and where the buttons
185
+ are. The title is the page-title register: a page has one, and it is
186
+ the largest thing on it. */
187
+
188
+ .page-head {
189
+ display: flex;
190
+ flex-wrap: wrap;
191
+ justify-content: space-between;
192
+ align-items: flex-start;
193
+ gap: var(--line) var(--space-3);
194
+ margin-block-end: var(--line-2);
195
+ }
196
+
197
+ /* The title takes the measure and the actions take what they need. A
198
+ basis rather than a share, so a long name wraps inside the measure
199
+ instead of pushing the actions off the line. */
200
+ .page-head__title {
201
+ flex: 1 1 var(--measure);
202
+ min-width: 0;
203
+ }
204
+
205
+ /* The actions shrink to their widest button and wrap inside it, rather
206
+ than holding their one-line width and pushing a phone's page sideways. */
207
+ .page-head__actions { flex: 0 1 auto; }
208
+
92
209
  /* --- Rules --------------------------------------------------------------
93
210
  The one horizontal mark in the style, at the two weights tokens.css
94
211
  declares. */
@@ -490,6 +607,137 @@
490
607
 
491
608
  .pagination__gap { color: var(--ink-quiet); }
492
609
 
610
+ /* --- Filters ------------------------------------------------------------
611
+ What narrows a list. One shape for every way of narrowing it — a
612
+ search, a tag, an order, a size — because they are the same kind of
613
+ control: pick one of a handful. A register is a quiet label and then
614
+ the choices, in the same two positions every time, so filtering and
615
+ ordering read as the registers they are rather than as two rows of
616
+ identical words. Ruled above and below: the block belongs to the list
617
+ under it, not to the page head above. */
618
+
619
+ .filters {
620
+ display: flex;
621
+ flex-wrap: wrap;
622
+ align-items: flex-start;
623
+ gap: var(--line) var(--space-3);
624
+ /* The rules are drawn inside the box, so the block stays whole lines. */
625
+ padding-block: calc(var(--line) - var(--rule-hair));
626
+ border-block: var(--rule-hair) solid var(--rule);
627
+ margin-block-end: var(--line);
628
+ }
629
+
630
+ .filter {
631
+ display: flex;
632
+ flex-wrap: wrap;
633
+ align-items: flex-start;
634
+ flex-basis: 100%;
635
+ gap: var(--line) var(--space-2);
636
+ }
637
+
638
+ /* The label column. A width off the ladder, so every register's choices
639
+ start on the same line down the page rather than a few pixels apart
640
+ because one word is longer than another. An application with longer
641
+ labels sets --filter-label once. */
642
+ .filter__label,
643
+ .filters .form--inline label { min-width: var(--filter-label); }
644
+
645
+ .filter__choices {
646
+ display: flex;
647
+ flex-wrap: wrap;
648
+ gap: var(--line) var(--space-2);
649
+ }
650
+
651
+ /* The choices are the flex items themselves, the way the nav's
652
+ destinations are: blockified, so each is a line box that is the tap
653
+ target and can be trimmed where the faces are not honoured. A list
654
+ item between them would be a second box to correct. */
655
+ .filter__choices a {
656
+ color: var(--ink-quiet);
657
+ text-decoration: none;
658
+ }
659
+
660
+ .filter__choices a:hover { color: var(--ink); }
661
+
662
+ /* Which one is chosen, said twice, for the reason the nav says it twice. */
663
+ .filter__choices [aria-current] {
664
+ color: var(--accent);
665
+ font-weight: 700;
666
+ }
667
+
668
+ /* The search is the first register, built like the others rather than as
669
+ a stacked field above them: the label in the label column, the control
670
+ where the choices start. Label and control meet on the under edge — the
671
+ label's baseline is its under edge, and the control's text stands on
672
+ the rule that closes it — so the two read as one line. The button is
673
+ there for a browser that runs no script and wraps under the field at a
674
+ phone's width; it goes when the live search connects, because from then
675
+ on typing has already done what it does. */
676
+ .filters .form--inline {
677
+ flex: 1 1 var(--measure);
678
+ max-width: var(--measure);
679
+ }
680
+
681
+ .filters .form--inline .field--inline {
682
+ flex: 1 1 auto;
683
+ align-items: flex-end;
684
+ min-width: 0;
685
+ }
686
+
687
+ /* A basis of nothing, so the control takes what the row has left rather
688
+ than the twenty characters a browser gives a text control by default. */
689
+ .filters .form--inline .field--inline input {
690
+ flex: 1 1 0;
691
+ width: auto;
692
+ min-width: 0;
693
+ }
694
+
695
+ /* --- Cards --------------------------------------------------------------
696
+ A list of things, each a picture over its name and a line about it,
697
+ laid out on the page's own fields: a card is --card fields wide, and
698
+ the application says how many, once, at each width it cares about. Two
699
+ lines between the rows, because a row of cards is a row of sections.
700
+
701
+ No box. A card is the thing itself, and the picture is the only filled
702
+ area it has; what separates one from the next is the gutter. */
703
+
704
+ .cards { row-gap: var(--line-2); }
705
+
706
+ .cards > * {
707
+ --span: var(--card);
708
+ min-width: 0;
709
+ }
710
+
711
+ /* The picture and the name are one link, so a card costs one tab stop and
712
+ announces its destination once. */
713
+ .card__link {
714
+ display: block;
715
+ color: inherit;
716
+ text-decoration: none;
717
+ }
718
+
719
+ .card__link:hover .card__name {
720
+ text-decoration: underline;
721
+ text-underline-offset: 0.2em;
722
+ }
723
+
724
+ /* The picture is a .figure with its --ratio declared, so its box is a
725
+ whole number of lines at any width and the name under it stays on the
726
+ grid; what this adds is the line between them. */
727
+ .card__figure { margin-block-end: var(--line); }
728
+
729
+ /* Blocks, so each is a line of its own and can be trimmed where the faces
730
+ are not honoured. */
731
+ .card__name,
732
+ .card__meta { display: block; }
733
+
734
+ .card__name {
735
+ font-family: var(--face-150), var(--font-family);
736
+ font-size: var(--size-2);
737
+ line-height: var(--line);
738
+ font-weight: 700;
739
+ }
740
+
493
741
  /* --- What a page has to say --------------------------------------------
494
742
  Three registers: it worked, it was refused, there is nothing here. The
495
743
  refusal is the only one that interrupts, and it interrupts with a rule
@@ -181,6 +181,14 @@
181
181
  );
182
182
  --measure: calc(var(--field) * 3 + var(--gutter) * 2);
183
183
 
184
+ /* --- Lists -------------------------------------------------------------
185
+ Two more slots an index fills. How many fields a card takes, which is
186
+ the application's to say and to change at each width it cares about;
187
+ and the column a filter's label occupies, so the choices of every
188
+ register start on one line down the page. */
189
+ --card: 1;
190
+ --filter-label: var(--space-8);
191
+
184
192
  /* --- Transitions -------------------------------------------------------
185
193
  The page crossfade is short enough to read as a replacement rather than
186
194
  an animation. A morph runs longer, because the thing travelling has to
@@ -81,7 +81,9 @@
81
81
  caption,
82
82
  legend,
83
83
  label,
84
- .field label {
84
+ .field label,
85
+ .filter__label,
86
+ .card__meta {
85
87
  font-family: var(--face-200), var(--font-family);
86
88
  font-size: var(--size-1);
87
89
  line-height: var(--line);
@@ -93,7 +95,11 @@
93
95
  picking a gray. */
94
96
  .quiet,
95
97
  .hint,
98
+ .filter__label,
99
+ .card__meta,
96
100
  .nav a,
101
+ .menu > summary,
102
+ .subnav a,
97
103
  .footer,
98
104
  .pairs dt,
99
105
  .button--quiet,
@@ -119,6 +125,8 @@
119
125
  inherited, because a button does not inherit a font and a nav may sit
120
126
  inside a register that is not the body's. */
121
127
  .nav a,
128
+ .menu > summary,
129
+ .subnav a,
122
130
  .button {
123
131
  font-family: var(--face-150), var(--font-family);
124
132
  font-size: var(--size-2);
@@ -220,11 +228,16 @@
220
228
  .page-title,
221
229
  h1, h2, h3, h4,
222
230
  p, dt, th, td, figcaption, caption, legend, label,
223
- li:not(:has(> :is(p, ul, ol, dl, div, figure, table, pre, blockquote))),
231
+ li:not(:has(> :is(p, ul, ol, dl, div, figure, table, pre, blockquote, .card__link))),
224
232
  dd:not(:has(> :is(p, ul, ol, dl, div, figure, table, pre, blockquote))),
225
233
  .micro, .hint, .lede, .empty, .masthead__mark, .field__error, .errors__title,
234
+ .filter__label, .card__name, .card__meta,
226
235
  /* Flex items are blockified, so the text in a row is trimmable too. */
227
236
  .nav a,
237
+ .menu > summary,
238
+ .menu__list a,
239
+ .subnav a,
240
+ .filter__choices a,
228
241
  .pagination a,
229
242
  .pagination [aria-current],
230
243
  .pagination__gap,
@@ -80,6 +80,21 @@ module ItsSwiss
80
80
  link_to(name, url, **options, &block)
81
81
  end
82
82
 
83
+ # A destination in the nav that opens into destinations: a details
84
+ # element with the label as its summary and the block as its list, so it
85
+ # opens and closes with no script. current: when one of the destinations
86
+ # inside is where you are, the summary carries the accent and the weight
87
+ # the way a current link does.
88
+ #
89
+ # <%= nav_menu "Tools", current: in_a_tool? do %>
90
+ # <%= nav_link_to "Pandatone", "/pandatone", current: in_engine?(pandatone) %>
91
+ # <% end %>
92
+ def nav_menu(label, current: false, &block)
93
+ tag.details(class: token_list("menu", ("menu--current" if current))) do
94
+ safe_join([ tag.summary(label), tag.div(capture(&block), class: "menu__list") ])
95
+ end
96
+ end
97
+
83
98
  # A value on screen exists to be taken somewhere else, so it is a button
84
99
  # that copies itself. The value stays visible text inside it, which is
85
100
  # what keeps it usable when the clipboard is not available at all.
@@ -133,6 +148,75 @@ module ItsSwiss
133
148
  end
134
149
  private :its_swiss_face
135
150
 
151
+ # What a page is and what can be done to it: the title, a lede when the
152
+ # page has one, and the actions in the block. One shape for every page,
153
+ # and the document is titled from it unless the view has titled itself.
154
+ #
155
+ # <%= page_head "Palettes", lede: "Every palette in the library." do %>
156
+ # <%= link_to "New palette", new_palette_path, class: "button button--primary" %>
157
+ # <% end %>
158
+ def page_head(title, lede: nil, &block)
159
+ content_for(:title, strip_tags(title.to_s)) unless content_for?(:title)
160
+ actions = capture(&block) if block
161
+
162
+ tag.header(class: "page-head") do
163
+ safe_join([
164
+ tag.div(class: "page-head__title") do
165
+ safe_join([ tag.h1(title, class: "page-title"), (tag.p(lede, class: "lede") if lede) ].compact)
166
+ end,
167
+ (tag.div(actions, class: "page-head__actions run") if actions.present?)
168
+ ].compact)
169
+ end
170
+ end
171
+
172
+ # One register of a filter block: a quiet label, then the choices, each
173
+ # a name, a URL and whether it is the one in force. Filtering and
174
+ # ordering are the same kind of control — pick one of a handful — so
175
+ # they are built the same way and told apart by their label.
176
+ #
177
+ # <%= filter_register "Tagged", [ [ "All", colors_path, params[:tag].blank? ],
178
+ # *tags.map { |t| [ t, colors_path(tag: t), params[:tag] == t ] } ],
179
+ # name: "tag" %>
180
+ #
181
+ # The one in force carries aria-current, which the CSS colours and
182
+ # weights, and a test can find without reading a class.
183
+ def filter_register(label, choices, name: nil)
184
+ tag.div(class: "filter", data: { filter: name }) do
185
+ safe_join([
186
+ tag.span(label, class: "filter__label"),
187
+ tag.div(class: "filter__choices") do
188
+ safe_join(choices.map { |text, url, current| link_to(text, url, aria: { current: ("true" if current) }) })
189
+ end
190
+ ])
191
+ end
192
+ end
193
+
194
+ # A search that narrows a list as you type, into the Turbo Frame named.
195
+ # The form belongs outside that frame, so only the results are replaced
196
+ # and the field keeps its cursor; the button is for a browser that runs
197
+ # no script, and the controller takes it away once it has connected.
198
+ #
199
+ # <%= search_form palettes_path, frame: "palettes", keep: { tag: params[:tag], sort: params[:sort] } %>
200
+ #
201
+ # keep: the other choices on the page, carried as hidden fields so a
202
+ # search does not drop the tag or the order you were reading in. The
203
+ # host registers the controller as its-swiss-live-search.
204
+ def search_form(url, frame:, param: :q, label: "Search", value: nil, keep: {}, delay: nil, **options)
205
+ value = params[param] if value.nil? && respond_to?(:params)
206
+ data = { controller: "its-swiss-live-search", action: "input->its-swiss-live-search#search",
207
+ turbo_frame: frame, its_swiss_live_search_delay_value: delay }.compact
208
+
209
+ form_with(url: url, method: :get, class: token_list("form form--inline", options[:class]), data: data) do
210
+ safe_join([
211
+ *keep.compact_blank.map { |name, kept| hidden_field_tag(name, kept, id: nil) },
212
+ tag.div(class: "field field--inline") do
213
+ safe_join([ label_tag(param, label), search_field_tag(param, value, autocomplete: "off") ])
214
+ end,
215
+ submit_tag(label, name: nil, class: "button", data: { its_swiss_live_search_target: "submit" })
216
+ ])
217
+ end
218
+ end
219
+
136
220
  # form_with, already holding the library's builder. An application that
137
221
  # wants its own builder still can; this is the shorthand for the case
138
222
  # where it does not.
@@ -0,0 +1,38 @@
1
+ <section data-specimen="filters">
2
+ <h3>Filters and cards</h3>
3
+ <p class="hint">
4
+ What narrows a list, and the list. A search that filters as you type, and
5
+ a register for each other way of narrowing it: the same two positions
6
+ every time, a quiet label and the choices, the one in force carrying the
7
+ accent and the weight. Under it, cards on the page's own fields — this
8
+ page says a card is two fields wide.
9
+ </p>
10
+
11
+ <div class="filters">
12
+ <%= search_form "#filters", frame: "specimen-results", value: "", keep: { tag: "print" } %>
13
+ <%= filter_register "Tagged",
14
+ [ [ "All", "#filters", false ], [ "print", "#filters", true ], [ "screen", "#filters", false ] ],
15
+ name: "tag" %>
16
+ <%= filter_register "Sort by",
17
+ [ [ "Name", "#filters", true ], [ "Newest", "#filters", false ], [ "Most used", "#filters", false ] ],
18
+ name: "sort" %>
19
+ </div>
20
+
21
+ <p class="hint">3 of 12 palettes</p>
22
+
23
+ <ul class="grid cards" style="--card: 2">
24
+ <% [ [ "Brand Core", "4 swatches", 1 ], [ "Press", "6 swatches", 2 ], [ "Screen", "3 swatches", 3 ] ].each do |name, meta, value| %>
25
+ <li class="card">
26
+ <a class="card__link" href="#filters">
27
+ <figure class="card__figure figure" style="--ratio: 1.5">
28
+ <svg viewBox="0 0 3 2" role="img" aria-label="A block of value <%= value %>">
29
+ <rect width="3" height="2" fill="var(--value-<%= value %>)"/>
30
+ </svg>
31
+ </figure>
32
+ <span class="card__name"><%= name %></span>
33
+ </a>
34
+ <span class="card__meta"><%= meta %></span>
35
+ </li>
36
+ <% end %>
37
+ </ul>
38
+ </section>
@@ -5,11 +5,30 @@
5
5
  <nav class="nav">
6
6
  <a href="#masthead" aria-current="page">Where you are</a>
7
7
  <a href="#masthead">Somewhere else</a>
8
- <a href="#masthead">And another</a>
8
+ <%= nav_menu "A menu", current: false do %>
9
+ <a href="#masthead">One of several</a>
10
+ <a href="#masthead">Another of them</a>
11
+ <a href="#masthead">And a third</a>
12
+ <% end %>
13
+ <%= nav_menu "Where you also are", current: true do %>
14
+ <a href="#masthead" aria-current="page">Inside the menu</a>
15
+ <a href="#masthead">Beside it</a>
16
+ <% end %>
9
17
  </nav>
10
18
  </header>
19
+ <nav class="subnav" aria-label="Inside this section">
20
+ <div class="page">
21
+ <a href="#masthead">The section's mark</a>
22
+ <a href="#masthead" aria-current="page">Where you are in it</a>
23
+ <a href="#masthead">Elsewhere in it</a>
24
+ </div>
25
+ </nav>
11
26
  <p class="hint">
12
27
  The current destination carries the accent and the weight. Two signals,
13
- because colour alone is not one.
28
+ because colour alone is not one. A menu is a destination that opens into
29
+ destinations, and carries both when where you are is inside it. The
30
+ subnav is a second layer, inside the one you are in: a shaded band under
31
+ the masthead, rendered by the shell when the application fills
32
+ <code>:subnav</code>.
14
33
  </p>
15
34
  </section>
@@ -0,0 +1,15 @@
1
+ <section data-specimen="page-head">
2
+ <h3>Page head</h3>
3
+ <p class="hint">
4
+ What a page is and what can be done to it: the title and its lede on the
5
+ left, the actions on the right, wrapping under when the page is narrow.
6
+ One shape for every page, so the name and the buttons are always where
7
+ they were on the last one.
8
+ </p>
9
+ <%= page_head "Palettes", lede: "Every palette in the library, including the ones no colour has claimed yet." do %>
10
+ <a href="#page-head" class="button button--primary">New palette</a>
11
+ <a href="#page-head" class="button">Import from a file</a>
12
+ <a href="#page-head" class="button">New from this</a>
13
+ <a href="#page-head" class="button button--danger">Delete palette</a>
14
+ <% end %>
15
+ </section>
@@ -9,11 +9,13 @@
9
9
  <%= render "its_swiss/specimen/type" %>
10
10
  <%= render "its_swiss/specimen/grid" %>
11
11
  <%= render "its_swiss/specimen/masthead" %>
12
+ <%= render "its_swiss/specimen/page_head" %>
12
13
  <%= render "its_swiss/specimen/buttons" %>
13
14
  <%= render "its_swiss/specimen/form" %>
14
15
  <%= render "its_swiss/specimen/figure" %>
15
16
  <%= render "its_swiss/specimen/table" %>
16
17
  <%= render "its_swiss/specimen/pairs" %>
18
+ <%= render "its_swiss/specimen/filters" %>
17
19
  <%= render "its_swiss/specimen/pagination" %>
18
20
  <%= render "its_swiss/specimen/messages" %>
19
21
  <%= render "its_swiss/specimen/footer" %>
@@ -9,6 +9,7 @@
9
9
  :head anything else that belongs in <head>
10
10
  :mark the wordmark. No mark and no nav means no masthead at all
11
11
  :nav the destinations
12
+ :subnav a second layer of destinations, inside the one you are in
12
13
  :main_class what the page's main region is, if it is a grid
13
14
  :footer whatever belongs after the page
14
15
 
@@ -48,6 +49,10 @@
48
49
 
49
50
  <%= render "its_swiss/shared/masthead" %>
50
51
 
52
+ <% if content_for?(:subnav) %>
53
+ <nav class="subnav" aria-label="Inside this section"><div class="page"><%= content_for(:subnav) %></div></nav>
54
+ <% end %>
55
+
51
56
  <main id="main" class="page <%= content_for(:main_class) %>">
52
57
  <%= render "its_swiss/shared/flash" %>
53
58
  <%= yield %>
data/config/importmap.rb CHANGED
@@ -1,5 +1,7 @@
1
- # The library's whole JavaScript surface. A typographic system does not need
2
- # script to set type; this is here because a value that exists to be taken
3
- # somewhere else should be a button that copies itself, and that cannot be
4
- # done in CSS.
1
+ # The library's whole JavaScript surface: two controllers. A typographic
2
+ # system does not need script to set type; these are here because a value
3
+ # that exists to be taken somewhere else should be a button that copies
4
+ # itself, and a search should narrow the list as you type, and neither can
5
+ # be done in CSS.
5
6
  pin "its_swiss/clipboard_controller", to: "its_swiss/clipboard_controller.js"
7
+ pin "its_swiss/live_search_controller", to: "its_swiss/live_search_controller.js"
@@ -1,3 +1,3 @@
1
1
  module ItsSwiss
2
- VERSION = "0.7.2"
2
+ VERSION = "0.8.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: its-swiss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bobby Meyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-04 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -105,6 +105,7 @@ files:
105
105
  - LICENSE
106
106
  - README.md
107
107
  - app/assets/javascripts/its_swiss/clipboard_controller.js
108
+ - app/assets/javascripts/its_swiss/live_search_controller.js
108
109
  - app/assets/stylesheets/its-swiss.css
109
110
  - app/assets/stylesheets/its_swiss/components.css
110
111
  - app/assets/stylesheets/its_swiss/faces.css
@@ -122,11 +123,13 @@ files:
122
123
  - app/views/its_swiss/shared/_pagination.html.erb
123
124
  - app/views/its_swiss/specimen/_buttons.html.erb
124
125
  - app/views/its_swiss/specimen/_figure.html.erb
126
+ - app/views/its_swiss/specimen/_filters.html.erb
125
127
  - app/views/its_swiss/specimen/_footer.html.erb
126
128
  - app/views/its_swiss/specimen/_form.html.erb
127
129
  - app/views/its_swiss/specimen/_grid.html.erb
128
130
  - app/views/its_swiss/specimen/_masthead.html.erb
129
131
  - app/views/its_swiss/specimen/_messages.html.erb
132
+ - app/views/its_swiss/specimen/_page_head.html.erb
130
133
  - app/views/its_swiss/specimen/_pagination.html.erb
131
134
  - app/views/its_swiss/specimen/_pairs.html.erb
132
135
  - app/views/its_swiss/specimen/_table.html.erb