lostcoords-ui 0.8.2 → 0.9.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +104 -66
  3. data/app/assets/tailwind/lostcoords/ui/components/shell.css +4 -0
  4. data/app/assets/tailwind/lostcoords/ui/components.css +1 -0
  5. data/app/assets/tailwind/lostcoords/ui/core.css +1 -1
  6. data/app/assets/tailwind/lostcoords/ui/prose.css +9 -0
  7. data/app/assets/tailwind/lostcoords/ui/shell.css +161 -0
  8. data/app/assets/tailwind/lostcoords/ui/typography.css +26 -0
  9. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +3 -3
  10. data/app/helpers/lostcoords/ui/layout_helper.rb +5 -3
  11. data/app/helpers/lostcoords/ui/navigation_helper.rb +29 -0
  12. data/app/helpers/lostcoords/ui/reading_helper.rb +23 -2
  13. data/app/helpers/lostcoords/ui/shell_helper.rb +64 -0
  14. data/app/helpers/lostcoords/ui/translation_helper.rb +1 -5
  15. data/app/helpers/lostcoords/ui/typography_helper.rb +9 -2
  16. data/app/javascript/lostcoords/ui/controllers/lc_app_header_controller.js +15 -0
  17. data/app/javascript/lostcoords/ui/controllers/lc_dock_controller.js +21 -0
  18. data/app/javascript/lostcoords/ui/controllers/lc_toc_controller.js +96 -0
  19. data/app/javascript/lostcoords/ui/index.js +6 -0
  20. data/config/locales/en.yml +9 -0
  21. data/config/locales/es.yml +9 -0
  22. data/config/locales/ja.yml +9 -0
  23. data/lib/lostcoords/ui/helpers.rb +1 -1
  24. data/lib/lostcoords/ui/icons.rb +1 -1
  25. data/lib/lostcoords/ui/parts.rb +87 -0
  26. data/lib/lostcoords/ui/version.rb +1 -1
  27. data/lib/lostcoords/ui.rb +12 -2
  28. data/lib/tasks/lostcoords_ui.rake +0 -1
  29. metadata +9 -6
  30. data/lib/lostcoords/ui/action_view.rb +0 -13
  31. data/lib/lostcoords/ui/rails.rb +0 -3
  32. data/lib/lostcoords/ui/resources.rb +0 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa5ef0cc7f548cfccfe27e49a88e68fea79cd6225b41ee8cb2ff75c744c055e5
4
- data.tar.gz: 1df3317a043db991a631bd76d94f90a453605e78cfdc716b40b7e99c8374db11
3
+ metadata.gz: 6c7fa09422f0fbfb2b1e2caa70338f1c842d12d900097efe8d9577c610041e46
4
+ data.tar.gz: 1b366575265b9d3662710ec2131fe7bfe64e7b2afcce0662d222189643aa1fbd
5
5
  SHA512:
6
- metadata.gz: 2469f7423e3a9efafa8ecba6de19a051d68b832e66b8e8859ee604d24102fb7b4594d3911e37a3afac037005c318447c07c6adb21e6ee6ca7794a8a1cc5489d0
7
- data.tar.gz: 94b26a83e133249ee797542f66fdd365f303f025da5189e8b6398390a3d2b10a4484e5d5f3dee3a15e91c44fa6431d368a9735751ab19e0b2c9d5975896469a8
6
+ metadata.gz: 27d701fa2951f3a60871a013ef2618a6dfb09ee9f932bf93b91b3aaae85371c349345e10fbfdfb92c6645e3b24769b2d8c4c55dd144389a05ee4a7cf41f69116
7
+ data.tar.gz: a1ca746f2c919e114d2a0dbf5995704cacc6389eec9b545c9b26bf3055dd1b8803d03a8adb689dda3463dac5cea925aa81934f61e441d24ff943366167100490
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # lostcoords-ui
2
2
 
3
- Render complete UI components from Rails or standalone ActionView ERB. Helpers include semantic HTML,
4
- accessible attributes and Stimulus wiring. Tailwind v4 supplies the styles. Rails integrates Propshaft
5
- and importmap automatically; standalone applications own their asset build.
3
+ Render complete UI components from Rails views. Helpers include semantic HTML, accessible attributes and
4
+ Stimulus wiring. Tailwind v4 supplies the styles. The Rails engine integrates Propshaft, importmap and
5
+ `tailwindcss-rails`.
6
6
 
7
7
  ```erb
8
8
  <%= lc_button "Save changes", type: :submit, icon: "check" %>
@@ -15,52 +15,33 @@ and importmap automatically; standalone applications own their asset build.
15
15
  <% end %>
16
16
  ```
17
17
 
18
- The supported stack is ActionView, ERB, Tailwind and Stimulus. Each host application's lockfile pins
19
- its gem version, so publishing a library update does not upgrade consumers.
18
+ The supported stack is Rails 8 or later with ERB, Tailwind and Stimulus. Each host application's
19
+ lockfile pins its gem version, so publishing a library update does not upgrade consumers.
20
20
 
21
21
  ## Install
22
22
 
23
23
  ```ruby
24
- gem "lostcoords-ui", "~> 0.8.0"
24
+ gem "lostcoords-ui", "~> 0.9.0"
25
25
  ```
26
26
 
27
- The engine adds its rendering helpers to host views, its JavaScript to the asset paths and its pins to
28
- the importmap. Translations live under `lostcoords.ui.*`.
27
+ The engine adds its rendering helpers to host views, its views and translations to the host's lookup
28
+ paths, its JavaScript to the asset paths and its pins to the importmap. Translations live under
29
+ `lostcoords.ui.*`; a host locale file overrides any of them.
29
30
 
30
- ### Standalone ActionView
31
+ ### Stylesheets
31
32
 
32
- ```ruby
33
- require "lostcoords/ui/action_view"
34
-
35
- class ViewContext < ActionView::Base
36
- include Lostcoords::Ui::Helpers
37
- end
33
+ Import the engine's stylesheet after your Tailwind imports. `tailwindcss-rails` generates this path from
34
+ the installed gem on every build:
38
35
 
39
- view = ViewContext.with_empty_template_cache.with_view_paths(Lostcoords::Ui.view_paths)
40
- view.lc_button("Save", icon: "check")
36
+ ```css
37
+ /* app/assets/tailwind/application.css */
38
+ @import "../builds/tailwind/lostcoords_ui.css";
41
39
  ```
42
40
 
43
- For an existing view context, include the helpers and append `Lostcoords::Ui.view_paths` to its
44
- lookup paths after application paths. The loader registers the library's translations without changing
45
- the default locale. If the host has its own locale handling, override private `lc_locale` in the view
46
- context; components do not call the host's `t` helper.
47
-
48
- Standalone applications compile Tailwind and register Stimulus controllers through their own asset
49
- pipeline. `Lostcoords::Ui.root` locates the gem's source assets. Rails applications receive automatic
50
- registration; use `require "lostcoords/ui/rails"` explicitly if Rails loads after the components.
51
-
52
- ### Standalone assets
53
-
54
- Import `app/assets/tailwind/lostcoords/ui/components.css` from the Bundler-resolved gem root
55
- into your Tailwind build. This entry includes component rules, default tokens and source scanning.
56
- It does not set body styles, heading defaults, global inline-code styles or document corner radii.
57
- Map the library's tokens to your app's theme and keep your Tailwind theme declarations after the import.
58
- The full `ui.css` entry includes those document defaults and remains the Rails default.
59
-
60
41
  Tailwind always ships component rules, used or not, and generates every utility the scanned helpers
61
42
  mention. For a smaller build, import `core.css` and the opt-in components you render instead of
62
- everything. Each entry carries the rules and scans the sources of its own component only. In Rails,
63
- every Tailwind build writes stable paths for them next to the engine's own import:
43
+ everything. Each entry carries the rules and scans the sources of its own component only. Every
44
+ Tailwind build writes stable paths for them next to the engine's own import:
64
45
 
65
46
  ```css
66
47
  /* app/assets/tailwind/application.css, instead of ../builds/tailwind/lostcoords_ui.css */
@@ -70,34 +51,17 @@ every Tailwind build writes stable paths for them next to the engine's own impor
70
51
  @import "../builds/tailwind/lostcoords_ui/components/menu.css";
71
52
  ```
72
53
 
73
- Outside Rails, import the same files from `app/assets/tailwind/lostcoords/ui/` in the gem.
74
- `base.css` holds the document defaults that `ui.css` adds; skip it when the host owns its base styles.
54
+ `base.css` holds the document defaults: body styles, heading defaults, global inline-code styles and
55
+ document corner radii. Skip it when the host owns its base styles, map the library's tokens to your
56
+ app's theme and keep your Tailwind theme declarations after the imports.
75
57
  `core.css` holds the tokens, buttons, badges, avatars, cards, alerts, form controls, choices,
76
58
  toggles, layout, typography and navigation. The opt-in entries are `dialog` (with the phone sheet),
77
59
  `menu` (menus and popovers), `toast`, `search`, `tooltip`, `collapsible`, `code-block`,
78
- `emoji-picker`, `table` and `prose`. A component whose entry is missing renders unstyled.
79
-
80
- Start one Stimulus application in the host, then register the controllers you use:
81
-
82
- ```javascript
83
- import { Application } from "@hotwired/stimulus"
84
- import { registerControllers } from "lostcoords/ui"
85
-
86
- const application = Application.start()
87
- registerControllers(application, { only: ["lc-copy", "lc-collapsible"] })
88
- ```
89
-
90
- Resolve `lostcoords/ui/*` to the gem's `app/javascript/lostcoords/ui/*` and `@hotwired/stimulus` to
91
- the host's installed package. `registerControllers` imports every controller, so a bundle carries
92
- them all; to bundle only some, import them directly:
93
- `import Dialog from "lostcoords/ui/controllers/lc_dialog_controller"` and
94
- `application.register("lc-dialog", Dialog)`. Build configuration belongs to the host. The entry does not start
95
- Stimulus or register anything on import. Omit `only` to register all library controllers. Register
96
- defaults before application overrides. Repeated calls on the same application preserve later
97
- overrides; unknown controller identifiers raise before registration.
60
+ `emoji-picker`, `table`, `prose` and `shell` (`lc_app_header`, `lc_docs_shell` and `lc_dock`; the dock's action
61
+ menu also needs `menu`). A component whose entry is missing renders unstyled.
98
62
 
99
63
  The gem's stylesheets write into the `components` and `modules` layers, so the host declares that layer order.
100
- The theme defines the `dark:` variant with `html[data-theme="dark"]`. `ui.css` carries its own `@source` lines, so Tailwind
64
+ The theme defines the `dark:` variant with `html[data-theme="dark"]`. Each entry carries its own `@source` lines, so Tailwind
101
65
  scans the gem's helpers, views and JavaScript wherever the gem is installed.
102
66
 
103
67
  ### Theming
@@ -194,6 +158,53 @@ lazyLoadControllersFrom("controllers", application)
194
158
  `eagerLoadControllersFrom` also works and downloads all of them on every page. A host file with the
195
159
  same name, such as `app/javascript/controllers/lc_dialog_controller.js`, replaces the gem's.
196
160
 
161
+ To register controllers by hand instead, import `registerControllers` from the `lostcoords/ui` pin:
162
+
163
+ ```javascript
164
+ import { registerControllers } from "lostcoords/ui"
165
+
166
+ registerControllers(application, { only: ["lc-copy", "lc-collapsible"] })
167
+ ```
168
+
169
+ Omit `only` to register every library controller. Unknown identifiers raise before anything is
170
+ registered, and repeated calls on the same application keep later overrides, so register defaults
171
+ before application overrides.
172
+
173
+ ## Changes in 0.9.0
174
+
175
+ The gem is Rails-only. It loads through its Rails engine, and the standalone ActionView mode is gone:
176
+
177
+ - **`require "lostcoords/ui/action_view"` and `require "lostcoords/ui/rails"` are removed.** Use
178
+ `require "lostcoords/ui"`, or let Bundler require the gem. Both load the engine.
179
+ - **`Lostcoords::Ui.view_paths`, `Lostcoords::Ui.load_translations!` and `Lostcoords::Ui.root` are removed.**
180
+ The engine adds the gem's views and translations to the host's lookup paths. Use
181
+ `Lostcoords::Ui::Engine.root` to locate the gem's files.
182
+ - **Components no longer call a private `lc_locale`.** They translate with `I18n.locale`. Set the locale
183
+ through Rails, for example with `I18n.with_locale` in a controller `around_action`.
184
+ - **The gem depends on `railties` instead of `activesupport`.** An app outside Rails moves to a Rails
185
+ application and the engine's asset setup above.
186
+
187
+ Rails apps that load the gem through Bundler need no edits.
188
+
189
+ New components and options:
190
+
191
+ - **`lc_toc` is an "On this page" list that follows the reader.** It marks the section scrolled to with
192
+ `aria-current="location"` and slides an indicator to it. Pass links with `toc.link "Usage", "#usage"`, or
193
+ rendered HTML with `lc_toc(headings: html)` to list its `h2` and `h3` that carry an id.
194
+ - **Headings and sections can link to themselves.** `lc_heading "Usage", id: "usage", anchor: true` and
195
+ `lc_section title: "Usage", id: "usage", anchor: true` wrap the title in a link with a `#` mark on hover.
196
+ `anchor: "section-id"` links a heading to an element around it. `lc_prose(anchors: true)` does the same for
197
+ rendered Markdown, whose headings Kiln gives ids.
198
+ - **Anchored targets clear sticky chrome.** `.lc-anchor-target` sets a scroll margin of `--lc-sticky-offset`,
199
+ which `lc_app_header` sets to `5rem` while it renders. Override the property if your header is taller.
200
+ - **`lc_app_header`, `lc_docs_shell` and `lc_dock` move from the Blocks examples into the gem**, in the new
201
+ `shell` stylesheet entry, with the `lc-app-header` and `lc-dock` controllers. `lc_app_header` yields
202
+ `header.brand`, `header.nav` and `header.actions`. `lc_docs_shell(navigation:, contents:)` lays out a sidebar,
203
+ the page and an `lc_toc`. `lc_dock` yields `dock.nav` and `dock.menu`, reserves its height at the end of the
204
+ page and hides while the on-screen keyboard is open. If you copied the Application header, Documentation
205
+ layout or Mobile navigation block, you can replace the copy with the helper and delete its stylesheet and
206
+ controller.
207
+
197
208
  ## Changes in 0.8.2
198
209
 
199
210
  Nothing needs an edit. New options, and one change you may see:
@@ -540,8 +551,31 @@ on the scroll container to keep its position across Turbo visits and reloads. Co
540
551
  `data-lc-scroll-keeper-key-value` share one position for the browser tab. Without a saved position,
541
552
  it scrolls the `aria-current="page"` link into view.
542
553
 
543
- This documentation site is built with these components and two copyable blocks: the application
544
- header and the documentation layout.
554
+ Page chrome lives in the `shell` stylesheet entry. `lc_app_header` is a sticky glass header,
555
+ `lc_docs_shell` lays out a documentation page, and `lc_toc` lists the page's sections and marks the one
556
+ the reader has scrolled to. This documentation site is built with them.
557
+
558
+ ```erb
559
+ <%= lc_app_header do |header| %>
560
+ <%= header.brand root_path, label: "Acme home" do %><%= image_tag "logo.svg", alt: "" %> Acme<% end %>
561
+ <%= header.nav class: "max-sm:hidden" do |nav| %>
562
+ <%= nav.link "Guides", guides_path, current: true %>
563
+ <% end %>
564
+ <%= header.actions do %><%= lc_icon_button "Search", icon: "magnifying-glass", size: :sm %><% end %>
565
+ <% end %>
566
+
567
+ <% contents = lc_toc do |toc| %>
568
+ <%= toc.link "Usage", "#usage" %>
569
+ <%= toc.link "Sizes", "#sizes", indent: true %>
570
+ <% end %>
571
+ <%= lc_docs_shell navigation: render("guides/sidebar"), contents: do %>
572
+ <%= lc_section title: "Usage", id: "usage", anchor: true do %>…<% end %>
573
+ <% end %>
574
+ ```
575
+
576
+ For rendered Markdown, such as a Kiln `.md` view, pass the HTML to both: `lc_prose(anchors: true) { html }`
577
+ links its headings, and `lc_toc(headings: html)` lists them. On phones, `lc_dock` floats `dock.nav` and a
578
+ `dock.menu` of quick actions at the bottom of the screen; render it last, since it reserves its height there.
545
579
 
546
580
  ```erb
547
581
  <%= lc_menu id: "member-actions", label: "Actions for #{member.name}", trigger: :icon do %>
@@ -935,11 +969,15 @@ Blocks live in the docs app. They add no rendering helpers or installable block
935
969
  ## Docs
936
970
 
937
971
  The docs app lives in `test/dummy`. Run `parcela up --detach` and open https://ui.localhost.
938
- Parcela starts Rails and the Tailwind watcher. `parcela down` stops this environment.
939
-
940
- `bin/rails lostcoords:ui:showcase` exports the home, component pages, blocks, token reference, machine-readable
941
- API and assets to `docs/` for GitHub Pages. CI checks that the export matches the source and that every
942
- public rendering helper has a runnable example.
972
+ Parcela starts Rails (with live reload through Hotwire Spark) and the Tailwind watcher.
973
+ `parcela down` stops this environment.
974
+
975
+ `bin/rails kiln:build` exports every docs route to `docs/` for GitHub Pages with
976
+ [Kiln](https://github.com/davafons/kiln): pages, blocks, the token reference, the machine-readable API and
977
+ assets. Param sources for the component and block pages live in `test/dummy/config/kiln.rb`, and a route
978
+ without one fails the build. `bin/rails kiln:serve` serves the export the way GitHub Pages does. CI runs
979
+ `bin/rails kiln:build` and fails when `docs/` changes, and checks that every public rendering helper has a
980
+ runnable example.
943
981
 
944
982
  ## Development
945
983
 
@@ -0,0 +1,4 @@
1
+ /* lc_app_header, lc_docs_shell and lc_dock. */
2
+ @import "../shell.css";
3
+ @source "../../../../../helpers/lostcoords/ui/shell_helper.rb";
4
+ @source "../../../../../javascript/lostcoords/ui/controllers/lc_{app_header,dock}_controller.js";
@@ -11,3 +11,4 @@
11
11
  @import "./components/emoji-picker.css";
12
12
  @import "./components/table.css";
13
13
  @import "./components/prose.css";
14
+ @import "./components/shell.css";
@@ -5,7 +5,7 @@
5
5
  @import "./theme.css";
6
6
  @source "../../../../helpers/lostcoords/ui/{badge,button,card,choice,copy_button,description_list,feedback,form,glass,icon,layout,list_group,navigation,primitives,render_attributes,structures,tailwind,translation,typography}_helper.rb";
7
7
  @source "../../../../javascript/lostcoords/ui/helpers/*.js";
8
- @source "../../../../javascript/lostcoords/ui/controllers/lc_{copy,kbd,nav,scroll_keeper,selection,shortcut,tabs,toggle}_controller.js";
8
+ @source "../../../../javascript/lostcoords/ui/controllers/lc_{copy,kbd,nav,scroll_keeper,selection,shortcut,tabs,toc,toggle}_controller.js";
9
9
  @source "../../../../../lib/lostcoords/ui/parts.rb";
10
10
 
11
11
  @import "./glass.css";
@@ -64,3 +64,12 @@
64
64
  @apply lc-inline-code;
65
65
  }
66
66
  }
67
+
68
+ @layer utilities {
69
+ /* A heading's link to itself keeps the heading's look, not a link's. */
70
+ .lc-prose .lc-anchor {
71
+ color: inherit;
72
+ font-weight: inherit;
73
+ text-decoration: none;
74
+ }
75
+ }
@@ -0,0 +1,161 @@
1
+ /* A sticky lc_app_header covers the top of the page; anchors and the docs
2
+ sidebars start below it. Override --lc-sticky-offset if your header is taller. */
3
+ :root:has(.lc-app-header) {
4
+ --lc-sticky-offset: 5rem;
5
+ }
6
+
7
+ @layer components {
8
+ .lc-app-header__glass {
9
+ opacity: 0;
10
+ transition: opacity 300ms ease;
11
+ }
12
+
13
+ .lc-app-header[data-scrolled] .lc-app-header__glass {
14
+ opacity: 1;
15
+ }
16
+
17
+ /* The sidebar scrolls on its own without a visible scrollbar. */
18
+ .lc-docs-shell__sidebar {
19
+ scrollbar-width: none;
20
+ }
21
+
22
+ .lc-docs-shell__sidebar::-webkit-scrollbar {
23
+ display: none;
24
+ }
25
+
26
+ .lc-dock {
27
+ --lc-dock-edge: max(1rem, env(safe-area-inset-left));
28
+ --lc-dock-gap: max(1rem, env(safe-area-inset-right));
29
+ --lc-dock-bottom: max(0.75rem, env(safe-area-inset-bottom));
30
+ --lc-dock-size: 4rem;
31
+ }
32
+
33
+ /* Holds the dock's height at the end of the page, so the last content scrolls clear of it. */
34
+ .lc-dock__space {
35
+ block-size: calc(var(--lc-dock-bottom) + var(--lc-dock-size) + 1rem);
36
+ }
37
+
38
+ .lc-dock__menu {
39
+ position: fixed;
40
+ z-index: calc(var(--lc-z-sticky) + 1);
41
+ inset-inline-end: var(--lc-dock-gap);
42
+ bottom: var(--lc-dock-bottom);
43
+ }
44
+
45
+ .lc-dock__trigger,
46
+ .lc-dock__action {
47
+ display: grid;
48
+ width: var(--lc-dock-size);
49
+ height: var(--lc-dock-size);
50
+ place-items: center;
51
+ padding: 0;
52
+ color: var(--primary);
53
+ cursor: pointer;
54
+ }
55
+
56
+ .lc-dock__trigger {
57
+ position: relative;
58
+ z-index: 2;
59
+ }
60
+
61
+ .lc-dock__trigger svg {
62
+ transition: rotate 300ms var(--lc-ease-sheet, ease);
63
+ }
64
+
65
+ .lc-dock__trigger[aria-expanded="true"] svg {
66
+ rotate: 135deg;
67
+ }
68
+
69
+ .lc-dock__actions {
70
+ visibility: hidden;
71
+ pointer-events: none;
72
+ }
73
+
74
+ .lc-dock__actions[data-open] {
75
+ visibility: visible;
76
+ }
77
+
78
+ .lc-dock__scrim {
79
+ position: fixed;
80
+ inset: 0;
81
+ border: 0;
82
+ background: color-mix(in oklab, var(--foreground) 18%, transparent);
83
+ opacity: 0;
84
+ -webkit-backdrop-filter: blur(calc(var(--spacing) * 2));
85
+ backdrop-filter: blur(calc(var(--spacing) * 2));
86
+ transition: opacity 220ms ease;
87
+ }
88
+
89
+ .lc-dock__actions[data-open] .lc-dock__scrim {
90
+ pointer-events: auto;
91
+ opacity: 1;
92
+ }
93
+
94
+ .lc-dock__action {
95
+ position: absolute;
96
+ z-index: 1;
97
+ inset: 0;
98
+ width: calc(var(--lc-dock-size) - 0.5rem);
99
+ height: calc(var(--lc-dock-size) - 0.5rem);
100
+ margin: 0.25rem;
101
+ color: var(--foreground);
102
+ opacity: 0;
103
+ transform: scale(0.4);
104
+ transition: transform 300ms ease, opacity 150ms ease;
105
+ }
106
+
107
+ .lc-dock__actions[data-open] .lc-dock__action {
108
+ pointer-events: auto;
109
+ opacity: 1;
110
+ transform: translateY(calc(var(--lc-dock-action-index, 1) * -4.5rem)) scale(1);
111
+ transition: transform 420ms cubic-bezier(0.34, 1.45, 0.64, 1), opacity 160ms ease;
112
+ transition-delay: calc((var(--lc-dock-action-index, 1) - 1) * 40ms);
113
+ }
114
+
115
+ .lc-dock__action-label {
116
+ position: absolute;
117
+ inset-inline-end: calc(100% + 0.75rem);
118
+ width: max-content;
119
+ max-width: calc(100vw - 7rem);
120
+ padding: 0.5rem 0.875rem;
121
+ border-radius: var(--radius-pill);
122
+ background: var(--background);
123
+ box-shadow: var(--lc-shadow);
124
+ color: var(--foreground);
125
+ font-size: 0.875rem;
126
+ font-weight: 600;
127
+ }
128
+
129
+ .lc-dock[data-keyboard] :is(.lc-dock__nav, .lc-dock__menu, .lc-dock__space) {
130
+ display: none;
131
+ }
132
+
133
+ @media (prefers-reduced-motion: reduce) {
134
+ .lc-app-header__glass,
135
+ .lc-dock__trigger svg,
136
+ .lc-dock__scrim,
137
+ .lc-dock__action,
138
+ .lc-dock__actions[data-open] .lc-dock__action {
139
+ transition: none;
140
+ }
141
+ }
142
+ }
143
+
144
+ /* The nav is an lc_nav_list, whose relative position is a utility, so the dock's
145
+ placement sits in the utilities layer too. */
146
+ @layer utilities {
147
+ .lc-dock__nav {
148
+ position: fixed;
149
+ z-index: var(--lc-z-sticky);
150
+ inset-inline: var(--lc-dock-edge) var(--lc-dock-gap);
151
+ bottom: var(--lc-dock-bottom);
152
+ }
153
+
154
+ .lc-dock:has(.lc-dock__menu) .lc-dock__nav {
155
+ inset-inline-end: calc(var(--lc-dock-gap) + var(--lc-dock-size) + 0.75rem);
156
+ }
157
+
158
+ .lc-dock[data-keyboard] .lc-dock__nav {
159
+ display: none;
160
+ }
161
+ }
@@ -12,6 +12,32 @@
12
12
  overflow-wrap: anywhere;
13
13
  }
14
14
 
15
+ /* Headings and sections a link can point to. --lc-sticky-offset is the height
16
+ of sticky chrome, such as lc_app_header, so the target lands below it. */
17
+ :where(.lc-anchor-target) {
18
+ scroll-margin-block-start: calc(var(--lc-sticky-offset, 0rem) + var(--lc-anchor-gap, 1.5rem));
19
+ }
20
+
21
+ /* A heading that links to itself. The mark shows on hover and keyboard focus. */
22
+ :where(.lc-anchor) {
23
+ color: inherit;
24
+ text-decoration: none;
25
+ border-radius: var(--radius-sm);
26
+ }
27
+
28
+ .lc-anchor::after {
29
+ content: "#" / "";
30
+ margin-inline-start: 0.4em;
31
+ color: var(--muted-foreground);
32
+ opacity: 0;
33
+ transition: opacity var(--lc-motion-quick) ease;
34
+ }
35
+
36
+ .lc-anchor:hover::after,
37
+ .lc-anchor:focus-visible::after {
38
+ opacity: 1;
39
+ }
40
+
15
41
  :where(.lc-eyebrow) {
16
42
  font-family: var(--font-eyebrow);
17
43
  text-transform: uppercase;
@@ -15,9 +15,9 @@ module Lostcoords::Ui
15
15
  picker_class: picker_class, trigger_class: trigger_class, popover_class: popover_class, attributes: attributes
16
16
  end
17
17
 
18
- SEARCH_NAMES_PATH = Lostcoords::Ui.root.join("config/emoji_search_names.yml")
19
- SEARCH_NAMES_JA_PATH = Lostcoords::Ui.root.join("config/emoji_search_names_ja.yml")
20
- SEARCH_ALIASES_PATH = Lostcoords::Ui.root.join("config/emoji_search_aliases.yml")
18
+ SEARCH_NAMES_PATH = Lostcoords::Ui::Engine.root.join("config/emoji_search_names.yml")
19
+ SEARCH_NAMES_JA_PATH = Lostcoords::Ui::Engine.root.join("config/emoji_search_names_ja.yml")
20
+ SEARCH_ALIASES_PATH = Lostcoords::Ui::Engine.root.join("config/emoji_search_aliases.yml")
21
21
 
22
22
  # Representative glyph for each category's tab in the picker navigation.
23
23
  CATEGORY_ICONS = {
@@ -51,11 +51,13 @@ module Lostcoords::Ui
51
51
  tag.div(**lc_attributes({ class: lc_container_class(size: size) }, attributes), &block)
52
52
  end
53
53
 
54
- def lc_section(title:, level: 2, actions: nil, **attributes, &block)
54
+ # anchor: true links the heading to the section's id:, for pages readers link into.
55
+ def lc_section(title:, level: 2, actions: nil, anchor: false, **attributes, &block)
55
56
  raise ArgumentError, "title must not be blank" if title.blank?
57
+ raise ArgumentError, "anchor: true needs an id" if anchor && attributes[:id].blank?
56
58
 
57
- tag.section(**lc_attributes({ class: lc_stack_class(gap: :heading) }, attributes)) do
58
- heading = lc_heading(title, level: level, size: :heading)
59
+ tag.section(**lc_attributes({ class: lc_stack_class(gap: :heading, extra: ("lc-anchor-target" if anchor)) }, attributes)) do
60
+ heading = lc_heading(title, level: level, size: :heading, anchor: (attributes[:id] if anchor))
59
61
  action_content = actions.respond_to?(:call) ? capture(&actions) : actions
60
62
  header = lc_section_header { safe_join([ heading, action_content ].compact) }
61
63
  safe_join([ header, lc_stack(&block) ])
@@ -84,6 +84,35 @@ module Lostcoords::Ui
84
84
  end
85
85
  end
86
86
 
87
+ TOC_INDICATOR = "pointer-events-none absolute -start-px top-0 h-0 w-0.5 rounded-pill bg-primary opacity-0 " \
88
+ "transition-[translate,height,opacity] duration-300 ease-(--lc-ease-out-expo) motion-reduce:transition-none"
89
+
90
+ # An "On this page" list that marks the section the reader has scrolled to with aria-current="location".
91
+ # Yields a builder: `toc.link "Usage", "#usage"`, with indent: true for a subsection. headings: takes
92
+ # rendered HTML, such as Markdown output, and lists its headings that carry an id, nesting the deeper level.
93
+ def lc_toc(label: lc_t("lostcoords.ui.toc.label"), headings: nil, levels: 2..3, **attributes, &block)
94
+ lc_label!(label, :label)
95
+ raise ArgumentError, "lc_toc takes headings: or a block, not both" if headings && block
96
+ raise ArgumentError, "lc_toc needs headings: or a block" unless headings || block
97
+ levels = Array(levels)
98
+ raise ArgumentError, "levels must be heading levels from 1 to 6" unless levels.any? && levels.all? { |level| (1..6).cover?(level) }
99
+
100
+ toc = Parts::Toc.new(self)
101
+ links = if block
102
+ capture(toc, &block)
103
+ else
104
+ found = Nokogiri::HTML5.fragment(headings.to_s).css(levels.map { |level| "h#{level}[id]" }.join(", "))
105
+ safe_join(found.map { |heading| toc.link(heading.text.strip, "##{heading['id']}", indent: heading.name.delete("h").to_i > levels.min) })
106
+ end
107
+
108
+ tag.nav(**lc_attributes({ class: "relative", aria: { label: label }, data: { slot: "toc", controller: "lc-toc" } }, attributes)) do
109
+ safe_join([
110
+ tag.span(class: TOC_INDICATOR, aria: { hidden: true }, data: { slot: "toc-indicator", lc_toc_target: "indicator" }),
111
+ tag.ul(links, class: "m-0 flex list-none flex-col border-s border-border p-0")
112
+ ])
113
+ end
114
+ end
115
+
87
116
  # Previous and next links between pages in a sequence. Each side takes [title, url] or nil.
88
117
  # compact: true shows icon buttons, for a page header.
89
118
  def lc_pager(previous: nil, next: nil, label: lc_t("lostcoords.ui.pager.label"), compact: false, **attributes)
@@ -8,8 +8,29 @@ module Lostcoords::Ui
8
8
  lc_tw(PROSE_BASE, extra)
9
9
  end
10
10
 
11
- def lc_prose(**attributes, &block)
12
- tag.div(**lc_attributes({ class: lc_prose_class }, attributes), &block)
11
+ # anchors: true links every h2, h3 and h4 that has an id to itself, as rendered Markdown's headings
12
+ # do. Pass the same HTML to lc_toc(headings:) for an "On this page" list.
13
+ def lc_prose(anchors: false, **attributes, &block)
14
+ content = capture(&block) if block
15
+ content = lc_anchor_headings(content) if anchors && content
16
+ tag.div(content, **lc_attributes({ class: lc_prose_class }, attributes))
13
17
  end
18
+
19
+ private
20
+ def lc_anchor_headings(html)
21
+ fragment = Nokogiri::HTML5.fragment(html.to_s)
22
+ fragment.css("h2[id], h3[id], h4[id]").each do |heading|
23
+ next if heading.at_css("a.lc-anchor")
24
+
25
+ heading["class"] = [ heading["class"], "lc-anchor-target" ].compact.join(" ")
26
+ link = Nokogiri::XML::Node.new("a", fragment.document)
27
+ link["href"] = "##{heading['id']}"
28
+ link["class"] = "lc-anchor"
29
+ link["data-slot"] = "heading-anchor"
30
+ link.children = heading.children
31
+ heading.add_child(link)
32
+ end
33
+ fragment.to_html.html_safe
34
+ end
14
35
  end
15
36
  end
@@ -0,0 +1,64 @@
1
+ module Lostcoords::Ui
2
+ # Page chrome: the sticky application header, the documentation layout and the phone dock.
3
+ module ShellHelper
4
+ DOCS_SHELL = "lc-docs-shell mx-auto flex w-full max-w-360 gap-10 px-5 sm:px-8 lg:px-10"
5
+ DOCS_SHELL_ASIDE = "sticky top-(--lc-sticky-offset,0rem) hidden h-[calc(100dvh-var(--lc-sticky-offset,0rem))] shrink-0 overflow-y-auto"
6
+
7
+ # A sidebar, the page and an optional "On this page" column. navigation: is the sidebar, usually
8
+ # lc_nav_list; it also fills a collapsible menu above the page below lg. contents: is usually lc_toc
9
+ # and shows from xl. main: adds attributes to the <main>, such as a max width for reading pages.
10
+ def lc_docs_shell(navigation:, contents: nil, menu_label: lc_t("lostcoords.ui.docs_shell.menu"), sidebar_label: lc_t("lostcoords.ui.docs_shell.sidebar"), main: {}, **attributes, &block)
11
+ lc_label!(menu_label, :menu_label)
12
+ lc_label!(sidebar_label, :sidebar_label)
13
+ raise ArgumentError, "lc_docs_shell needs navigation:" if navigation.blank?
14
+
15
+ chevron = lc_icon("chevron-down", options: { class: "size-4 transition-transform duration-(--lc-motion-quick) group-open/menu:rotate-180", "aria-hidden": true })
16
+ menu = tag.details(class: "group/menu relative z-raised rounded-2xl lc-squircle bg-muted lg:hidden", data: { slot: "docs-shell-menu" }) do
17
+ safe_join([
18
+ tag.summary(safe_join([ menu_label, chevron ]), class: "flex min-h-12 cursor-pointer list-none items-center justify-between gap-content px-4 text-label [&::-webkit-details-marker]:hidden"),
19
+ tag.div(navigation, class: "max-h-[60dvh] overflow-y-auto p-3 pt-0")
20
+ ])
21
+ end
22
+ page = tag.main(**lc_attributes({ id: "main", class: "isolate min-w-0 flex-1 py-5 lg:py-10", data: { slot: "docs-shell-main" } }, main)) do
23
+ safe_join([ menu, tag.div(capture(&block), class: "pt-6 lg:pt-0") ])
24
+ end
25
+
26
+ tag.div(**lc_attributes({ class: DOCS_SHELL, data: { slot: "docs-shell" } }, attributes)) do
27
+ safe_join([
28
+ tag.aside(navigation, tabindex: 0, aria: { label: sidebar_label }, class: lc_tw(DOCS_SHELL_ASIDE, "lc-docs-shell__sidebar w-52 py-8 pe-4 lg:block"),
29
+ data: { slot: "docs-shell-sidebar", controller: "lc-scroll-keeper", lc_scroll_keeper_key_value: "lc-docs-shell" }),
30
+ page,
31
+ (tag.aside(contents, class: lc_tw(DOCS_SHELL_ASIDE, "w-44 py-12 ps-4 xl:block"), data: { slot: "docs-shell-contents" }) if contents.present?)
32
+ ].compact)
33
+ end
34
+ end
35
+
36
+ # A sticky header whose glass fades in once the page scrolls. Yields a builder: header.brand,
37
+ # header.nav and header.actions render in that order along the bar. bar: adds attributes to the
38
+ # bar, such as a max width. While it renders, --lc-sticky-offset clears it for anchors and sidebars.
39
+ def lc_app_header(bar: {}, **attributes, &block)
40
+ raise ArgumentError, "lc_app_header needs a block" unless block
41
+
42
+ content = capture(Parts::AppHeader.new(self), &block)
43
+ tag.header(**lc_attributes({ class: "lc-app-header sticky top-0 z-sticky px-3 pt-3 pb-4 sm:px-6",
44
+ data: { slot: "app-header", controller: "lc-app-header", action: "scroll@window->lc-app-header#scroll" } }, attributes)) do
45
+ tag.div(**lc_attributes({ class: "relative isolate mx-auto flex min-h-16 w-full max-w-page items-center gap-related px-3 py-2 sm:gap-content sm:px-5", data: { slot: "app-header-bar" } }, bar)) do
46
+ safe_join([ tag.span(class: lc_glass_class(thick: true, extra: "lc-app-header__glass pointer-events-none absolute inset-0 -z-(--lc-z-raised)"), aria: { hidden: true }), content ])
47
+ end
48
+ end
49
+ end
50
+
51
+ # A floating bar at the bottom of a phone screen: dock.nav for primary destinations and dock.menu
52
+ # for a button whose actions fan out above it. It reserves its height at the end of the page, so
53
+ # render it last, and it hides while the on-screen keyboard is open.
54
+ def lc_dock(**attributes, &block)
55
+ raise ArgumentError, "lc_dock needs a block" unless block
56
+
57
+ content = capture(Parts::Dock.new(self), &block)
58
+ tag.div(**lc_attributes({ class: "lc-dock", data: { slot: "dock", controller: "lc-dock",
59
+ action: "resize@window->lc-dock#keyboard focusin@document->lc-dock#keyboard focusout@document->lc-dock#keyboard" } }, attributes)) do
60
+ safe_join([ content, tag.div(class: "lc-dock__space", aria: { hidden: true }) ])
61
+ end
62
+ end
63
+ end
64
+ end
@@ -3,11 +3,7 @@ module Lostcoords::Ui
3
3
  private
4
4
 
5
5
  def lc_t(key, **options)
6
- I18n.translate(key, **options, locale: lc_locale)
7
- end
8
-
9
- def lc_locale
10
- I18n.locale
6
+ I18n.translate(key, **options)
11
7
  end
12
8
  end
13
9
  end
@@ -24,10 +24,17 @@ module Lostcoords::Ui
24
24
  lc_tw(KBD_BASE, extra)
25
25
  end
26
26
 
27
- def lc_heading(content = nil, level: 2, size: :heading, trim: false, **attributes, &block)
27
+ # anchor: true links the heading to itself, so readers can copy a link to it; it needs id:.
28
+ # anchor: "section-id" links it to an element around it instead, such as its <section>.
29
+ def lc_heading(content = nil, level: 2, size: :heading, trim: false, anchor: false, **attributes, &block)
28
30
  raise ArgumentError, "Invalid level: #{level.inspect}. Expected an integer from 1 to 6" unless (1..6).include?(level) && level.is_a?(Integer)
29
31
 
30
- tag.public_send("h#{level}", content, **lc_attributes({ class: lc_heading_class(size: size, trim: trim) }, attributes), &block)
32
+ target = anchor == true ? attributes[:id].to_s : anchor.presence&.to_s
33
+ raise ArgumentError, "anchor: true needs an id" if anchor == true && target.blank?
34
+
35
+ content = capture(&block) if block
36
+ content = tag.a(content, href: "##{target}", class: "lc-anchor", data: { slot: "heading-anchor" }) if target
37
+ tag.public_send("h#{level}", content, **lc_attributes({ class: lc_heading_class(size: size, trim: trim, extra: ("lc-anchor-target" if anchor == true)) }, attributes))
31
38
  end
32
39
 
33
40
  # as: picks the element, such as :span inside a row or :h3 for a heading set in a text role.
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Fades the header's glass in once the page scrolls.
4
+ export default class extends Controller {
5
+ connect() {
6
+ this.scroll()
7
+ }
8
+
9
+ // An open modal pins the body, so scrollY reads 0 until it closes; the header keeps the
10
+ // state it had rather than fading the glass out behind the dialog.
11
+ scroll() {
12
+ if (document.body.dataset.modalScrollLock === "true") return
13
+ this.element.toggleAttribute("data-scrolled", window.scrollY > 8)
14
+ }
15
+ }
@@ -0,0 +1,21 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Hides the dock while the on-screen keyboard is open, so it doesn't cover the field being typed in.
4
+ export default class extends Controller {
5
+ connect() {
6
+ this.viewportHandler = () => this.keyboard()
7
+ window.visualViewport?.addEventListener("resize", this.viewportHandler)
8
+ }
9
+
10
+ disconnect() {
11
+ window.visualViewport?.removeEventListener("resize", this.viewportHandler)
12
+ }
13
+
14
+ keyboard() {
15
+ const typing = document.activeElement?.matches(
16
+ "input, textarea, select, [contenteditable]:not([contenteditable=false])",
17
+ )
18
+ const inset = window.innerHeight - (window.visualViewport?.height ?? window.innerHeight)
19
+ this.element.toggleAttribute("data-keyboard", Boolean(typing) && inset > 150)
20
+ }
21
+ }
@@ -0,0 +1,96 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Marks the link to the section the reader has scrolled to with aria-current="location" and slides
4
+ // the indicator to it. A section is current once its top passes its own scroll margin, which is
5
+ // where a click on its link puts it.
6
+ export default class extends Controller {
7
+ static targets = ["link", "indicator"]
8
+
9
+ connect() {
10
+ this.schedule = () => {
11
+ this.frame ||= requestAnimationFrame(() => {
12
+ this.frame = null
13
+ this.update()
14
+ })
15
+ }
16
+ addEventListener("scroll", this.schedule, { passive: true })
17
+ addEventListener("resize", this.schedule, { passive: true })
18
+ this.update({ animate: false })
19
+ }
20
+
21
+ disconnect() {
22
+ removeEventListener("scroll", this.schedule)
23
+ removeEventListener("resize", this.schedule)
24
+ cancelAnimationFrame(this.frame)
25
+ this.frame = null
26
+ }
27
+
28
+ // The last sections can't scroll up to the top, so at the end of the page the clicked one stays current.
29
+ select(event) {
30
+ this.chosen = event.currentTarget
31
+ }
32
+
33
+ update({ animate = true } = {}) {
34
+ const sections = this.linkTargets.flatMap((link) => {
35
+ const target = document.getElementById(decodeURIComponent(link.hash.slice(1)))
36
+ return target ? [{ link, target, top: target.getBoundingClientRect().top }] : []
37
+ })
38
+ let current = null
39
+ for (const section of sections) {
40
+ const margin = Number.parseFloat(getComputedStyle(section.target).scrollMarginTop) || 0
41
+ if (section.top <= margin + 4) current = section.link
42
+ }
43
+
44
+ const root = document.scrollingElement ?? document.documentElement
45
+ if (root.scrollTop > 0 && root.scrollTop + innerHeight >= root.scrollHeight - 2) {
46
+ const visible = sections.filter((section) => section.top < innerHeight)
47
+ const chosen = visible.find((section) => section.link === this.chosen)
48
+ current = (chosen ?? visible.at(-1))?.link ?? current
49
+ }
50
+ if (current === this.current && !animate) return this.place(current, { animate })
51
+ if (current === this.current) return
52
+
53
+ this.current = current
54
+ for (const link of this.linkTargets) {
55
+ if (link === current) link.setAttribute("aria-current", "location")
56
+ else link.removeAttribute("aria-current")
57
+ }
58
+ this.place(current, { animate })
59
+ this.reveal(current)
60
+ }
61
+
62
+ place(link, { animate }) {
63
+ if (!this.hasIndicatorTarget) return
64
+
65
+ const style = this.indicatorTarget.style
66
+ style.transition = animate ? "" : "none"
67
+ if (link) {
68
+ style.translate = `0 ${link.offsetTop}px`
69
+ style.height = `${link.offsetHeight}px`
70
+ }
71
+ style.opacity = link ? "1" : "0"
72
+ if (!animate) {
73
+ this.indicatorTarget.offsetWidth
74
+ style.transition = ""
75
+ }
76
+ }
77
+
78
+ // Keeps the current link inside a scrolling sidebar without scrolling the page.
79
+ reveal(link) {
80
+ if (!link) return
81
+
82
+ let box = this.element.parentElement
83
+ while (box && box !== document.body) {
84
+ const { overflowY } = getComputedStyle(box)
85
+ if ((overflowY === "auto" || overflowY === "scroll") && box.scrollHeight > box.clientHeight)
86
+ break
87
+ box = box.parentElement
88
+ }
89
+ if (!box || box === document.body) return
90
+
91
+ const linkBox = link.getBoundingClientRect()
92
+ const frame = box.getBoundingClientRect()
93
+ if (linkBox.top < frame.top) box.scrollTop -= frame.top - linkBox.top
94
+ else if (linkBox.bottom > frame.bottom) box.scrollTop += linkBox.bottom - frame.bottom
95
+ }
96
+ }
@@ -1,7 +1,9 @@
1
+ import AppHeader from "lostcoords/ui/controllers/lc_app_header_controller"
1
2
  import CodeBlock from "lostcoords/ui/controllers/lc_code_block_controller"
2
3
  import Collapsible from "lostcoords/ui/controllers/lc_collapsible_controller"
3
4
  import Copy from "lostcoords/ui/controllers/lc_copy_controller"
4
5
  import Dialog from "lostcoords/ui/controllers/lc_dialog_controller"
6
+ import Dock from "lostcoords/ui/controllers/lc_dock_controller"
5
7
  import EmojiPicker from "lostcoords/ui/controllers/lc_emoji_picker_controller"
6
8
  import Kbd from "lostcoords/ui/controllers/lc_kbd_controller"
7
9
  import Menu from "lostcoords/ui/controllers/lc_menu_controller"
@@ -15,14 +17,17 @@ import Shortcut from "lostcoords/ui/controllers/lc_shortcut_controller"
15
17
  import Tabs from "lostcoords/ui/controllers/lc_tabs_controller"
16
18
  import Toast from "lostcoords/ui/controllers/lc_toast_controller"
17
19
  import ToastRegion from "lostcoords/ui/controllers/lc_toast_region_controller"
20
+ import Toc from "lostcoords/ui/controllers/lc_toc_controller"
18
21
  import Toggle from "lostcoords/ui/controllers/lc_toggle_controller"
19
22
  import Tooltip from "lostcoords/ui/controllers/lc_tooltip_controller"
20
23
 
21
24
  const controllers = {
25
+ "lc-app-header": AppHeader,
22
26
  "lc-collapsible": Collapsible,
23
27
  "lc-code-block": CodeBlock,
24
28
  "lc-copy": Copy,
25
29
  "lc-dialog": Dialog,
30
+ "lc-dock": Dock,
26
31
  "lc-emoji-picker": EmojiPicker,
27
32
  "lc-kbd": Kbd,
28
33
  "lc-menu": Menu,
@@ -34,6 +39,7 @@ const controllers = {
34
39
  "lc-sheet": Sheet,
35
40
  "lc-shortcut": Shortcut,
36
41
  "lc-tabs": Tabs,
42
+ "lc-toc": Toc,
37
43
  "lc-toast": Toast,
38
44
  "lc-toast-region": ToastRegion,
39
45
  "lc-toggle": Toggle,
@@ -20,6 +20,15 @@ en:
20
20
  label: "Actions"
21
21
  popover:
22
22
  trigger: "Open popover"
23
+ toc:
24
+ label: "On this page"
25
+ docs_shell:
26
+ menu: "Menu"
27
+ sidebar: "Documentation sidebar"
28
+ app_header:
29
+ navigation: "Main"
30
+ dock:
31
+ close: "Close %{label}"
23
32
  breadcrumb:
24
33
  label: "Breadcrumb"
25
34
  code_block:
@@ -20,6 +20,15 @@ es:
20
20
  label: "Acciones"
21
21
  popover:
22
22
  trigger: "Abrir panel"
23
+ toc:
24
+ label: "En esta página"
25
+ docs_shell:
26
+ menu: "Menú"
27
+ sidebar: "Barra lateral de la documentación"
28
+ app_header:
29
+ navigation: "Principal"
30
+ dock:
31
+ close: "Cerrar %{label}"
23
32
  breadcrumb:
24
33
  label: "Ruta de navegación"
25
34
  code_block:
@@ -20,6 +20,15 @@ ja:
20
20
  label: "操作"
21
21
  popover:
22
22
  trigger: "パネルを開く"
23
+ toc:
24
+ label: "このページの内容"
25
+ docs_shell:
26
+ menu: "メニュー"
27
+ sidebar: "ドキュメントのサイドバー"
28
+ app_header:
29
+ navigation: "メイン"
30
+ dock:
31
+ close: "%{label}を閉じる"
23
32
  breadcrumb:
24
33
  label: "パンくずリスト"
25
34
  code_block:
@@ -1,6 +1,6 @@
1
1
  module Lostcoords::Ui
2
2
  module Helpers
3
- paths = Dir[Lostcoords::Ui.root.join("app/helpers/lostcoords/ui/*_helper.rb")].sort_by do |path|
3
+ paths = Dir[Lostcoords::Ui::Engine.root.join("app/helpers/lostcoords/ui/*_helper.rb")].sort_by do |path|
4
4
  [ %w[tailwind_helper.rb button_helper.rb].index(File.basename(path)) || 2, path ]
5
5
  end
6
6
  paths.each do |path|
@@ -8,7 +8,7 @@ module Lostcoords::Ui
8
8
 
9
9
  def self.fetch(name, variant)
10
10
  LOCK.synchronize do
11
- @catalog ||= JSON.parse(File.read(Lostcoords::Ui.root.join("vendor/heroicons/icons.json")), freeze: true).fetch("icons")
11
+ @catalog ||= JSON.parse(File.read(Lostcoords::Ui::Engine.root.join("vendor/heroicons/icons.json")), freeze: true).fetch("icons")
12
12
  source = @catalog.fetch(variant.to_s).fetch(name.to_s)
13
13
  template = TEMPLATES[source] ||= begin
14
14
  document = Nokogiri::XML(source) { |config| config.strict.nonet }
@@ -195,6 +195,93 @@ module Lostcoords::Ui
195
195
  end
196
196
  end
197
197
 
198
+ class Toc < Base
199
+ LINK = "relative block py-tight ps-content pe-tight text-small text-muted-foreground no-underline transition-colors " \
200
+ "duration-(--lc-motion-quick) hover:text-foreground aria-[current=location]:font-medium aria-[current=location]:text-foreground"
201
+
202
+ # url: an in-page anchor such as "#usage". indent: true nests the entry under the one above it.
203
+ def link(label, url, indent: false, **attributes)
204
+ view.send(:lc_label!, label, :label)
205
+ raise ArgumentError, "toc link needs an anchor URL, such as \"#usage\"" unless url.to_s.match?(/#./)
206
+
207
+ view.tag.li(data: { slot: "toc-item" }) do
208
+ view.link_to(label, url, **merge({ class: view.lc_tw(LINK, ("ps-[calc(var(--spacing-content)*2)]" if indent)),
209
+ data: { slot: "toc-link", lc_toc_target: "link", action: "lc-toc#select" } }, attributes))
210
+ end
211
+ end
212
+ end
213
+
214
+ class AppHeader < Base
215
+ # The home link. label: names it for screen readers, since the block is usually a logo.
216
+ def brand(url = "/", label:, **attributes, &block)
217
+ view.send(:lc_label!, label, :label)
218
+ raise ArgumentError, "header brand needs a block" unless block
219
+
220
+ view.link_to(url, **merge({ class: "flex min-h-11 shrink-0 items-center gap-related pe-2 font-semibold tracking-tight text-inherit no-underline",
221
+ aria: { label: label }, data: { slot: "app-header-brand" } }, attributes), &block)
222
+ end
223
+
224
+ # A row of links with a sliding indicator; yields the lc_nav_list builder. Hide it on phones
225
+ # with class: "max-sm:hidden" when a dock or menu carries the same links.
226
+ def nav(label: view.send(:lc_t, "lostcoords.ui.app_header.navigation"), **attributes, &block)
227
+ view.lc_nav_list(label: label, layout: :row, indicator: true, **attributes, &block)
228
+ end
229
+
230
+ # Controls at the end of the bar, such as search, a theme switch or an account menu.
231
+ def actions(**attributes, &block)
232
+ part(:div, "app-header-actions", "ms-auto flex shrink-0 items-center gap-related", attributes, &block)
233
+ end
234
+ end
235
+
236
+ class Dock < Base
237
+ # The primary destinations, icon above label; yields the lc_nav_list builder.
238
+ def nav(label:, **attributes, &block)
239
+ view.lc_nav_list(label: label, layout: :dock, indicator: true,
240
+ **merge({ class: view.lc_glass_class(shape: :panel, thick: true, extra: "lc-dock__nav rounded-pill") }, attributes), &block)
241
+ end
242
+
243
+ # A round button at the end of the dock whose actions fan out above it. Yields a builder:
244
+ # `menu.action "New note", icon: "pencil-square"`.
245
+ def menu(label:, icon: "plus", id: nil, **attributes, &block)
246
+ view.send(:lc_label!, label, :label)
247
+ raise ArgumentError, "dock menu needs a block" unless block
248
+
249
+ id ||= "#{label.parameterize.presence || 'dock'}-menu"
250
+ actions = view.capture(DockMenu.new(view), &block)
251
+ view.tag.div(**merge({ class: "lc-dock__menu", data: { slot: "dock-menu", controller: "lc-menu", lc_menu_lift_value: false } }, attributes)) do
252
+ view.safe_join([
253
+ view.tag.button(view.lc_icon(icon, options: { class: "size-7", "aria-hidden": true }), type: "button",
254
+ class: view.lc_glass_class(shape: :disc, thick: true, extra: "lc-dock__trigger"), aria: { label: label, haspopup: "menu", expanded: false, controls: id },
255
+ data: { slot: "dock-menu-trigger", lc_menu_target: "trigger", action: "lc-menu#toggle" }),
256
+ view.tag.div(id: id, class: "lc-dock__actions", role: "menu", aria: { label: label }, data: { lc_menu_target: "popover" }) do
257
+ view.safe_join([
258
+ view.tag.button(type: "button", class: "lc-dock__scrim", tabindex: -1, aria: { label: view.send(:lc_t, "lostcoords.ui.dock.close", label: label) }, data: { action: "lc-menu#close" }),
259
+ actions
260
+ ])
261
+ end
262
+ ])
263
+ end
264
+ end
265
+ end
266
+
267
+ class DockMenu < Base
268
+ def initialize(view)
269
+ super
270
+ @count = 0
271
+ end
272
+
273
+ # An action that fans out above the dock button. Pass data: { action: "…" } for your controller;
274
+ # the menu closes after it runs.
275
+ def action(label, icon:, **attributes)
276
+ view.send(:lc_label!, label, :label)
277
+ @count += 1
278
+ defaults = { class: view.lc_glass_class(shape: :disc, thick: true, extra: "lc-dock__action"), data: { slot: "dock-action", action: "lc-menu#close" } }
279
+ view.tag.button(**merge(defaults, attributes), type: "button", role: "menuitem", style: "--lc-dock-action-index: #{@count}") do
280
+ view.safe_join([ view.tag.span(label, class: "lc-dock__action-label"), view.lc_icon(icon, options: { class: "size-6", "aria-hidden": true }) ])
281
+ end
282
+ end
283
+ end
284
+
198
285
  class Panel < Base
199
286
  include CustomTrigger
200
287
 
@@ -1,5 +1,5 @@
1
1
  module Lostcoords
2
2
  module Ui
3
- VERSION = "0.8.2"
3
+ VERSION = "0.9.0"
4
4
  end
5
5
  end
data/lib/lostcoords/ui.rb CHANGED
@@ -1,2 +1,12 @@
1
- require "lostcoords/ui/action_view"
2
- require "lostcoords/ui/rails" if defined?(::Rails::Engine)
1
+ require "rails/engine"
2
+ require "action_view"
3
+ require "active_support/core_ext/hash"
4
+ require "active_support/core_ext/object/blank"
5
+ require "tailwind_merge"
6
+ require "lostcoords/ui/version"
7
+ require "lostcoords/ui/engine"
8
+ require "lostcoords/ui/icons"
9
+ require "lostcoords/ui/parts"
10
+ require "lostcoords/ui/form_builder"
11
+ require "lostcoords/ui/helpers"
12
+ require "lostcoords/ui/stylesheets"
@@ -15,7 +15,6 @@ namespace :lostcoords do
15
15
  end
16
16
 
17
17
  # Runs with tailwindcss-rails' own engines step, before every tailwindcss:build and tailwindcss:watch.
18
- # Apps without tailwindcss-rails import the gem's stylesheets by path instead.
19
18
  if defined?(Tailwindcss::Engines)
20
19
  namespace :tailwindcss do
21
20
  task :engines do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lostcoords-ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lost Coordinates
@@ -24,7 +24,7 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '8.0'
26
26
  - !ruby/object:Gem::Dependency
27
- name: activesupport
27
+ name: railties
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
@@ -99,6 +99,7 @@ files:
99
99
  - app/assets/tailwind/lostcoords/ui/components/menu.css
100
100
  - app/assets/tailwind/lostcoords/ui/components/prose.css
101
101
  - app/assets/tailwind/lostcoords/ui/components/search.css
102
+ - app/assets/tailwind/lostcoords/ui/components/shell.css
102
103
  - app/assets/tailwind/lostcoords/ui/components/table.css
103
104
  - app/assets/tailwind/lostcoords/ui/components/toast.css
104
105
  - app/assets/tailwind/lostcoords/ui/components/tooltip.css
@@ -113,6 +114,7 @@ files:
113
114
  - app/assets/tailwind/lostcoords/ui/prose.css
114
115
  - app/assets/tailwind/lostcoords/ui/search.css
115
116
  - app/assets/tailwind/lostcoords/ui/sheet.css
117
+ - app/assets/tailwind/lostcoords/ui/shell.css
116
118
  - app/assets/tailwind/lostcoords/ui/squircle.css
117
119
  - app/assets/tailwind/lostcoords/ui/table.css
118
120
  - app/assets/tailwind/lostcoords/ui/theme.css
@@ -145,6 +147,7 @@ files:
145
147
  - app/helpers/lostcoords/ui/render_attributes_helper.rb
146
148
  - app/helpers/lostcoords/ui/search_helper.rb
147
149
  - app/helpers/lostcoords/ui/sheet_helper.rb
150
+ - app/helpers/lostcoords/ui/shell_helper.rb
148
151
  - app/helpers/lostcoords/ui/structures_helper.rb
149
152
  - app/helpers/lostcoords/ui/table_helper.rb
150
153
  - app/helpers/lostcoords/ui/tailwind_helper.rb
@@ -152,10 +155,12 @@ files:
152
155
  - app/helpers/lostcoords/ui/tooltip_helper.rb
153
156
  - app/helpers/lostcoords/ui/translation_helper.rb
154
157
  - app/helpers/lostcoords/ui/typography_helper.rb
158
+ - app/javascript/lostcoords/ui/controllers/lc_app_header_controller.js
155
159
  - app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js
156
160
  - app/javascript/lostcoords/ui/controllers/lc_collapsible_controller.js
157
161
  - app/javascript/lostcoords/ui/controllers/lc_copy_controller.js
158
162
  - app/javascript/lostcoords/ui/controllers/lc_dialog_controller.js
163
+ - app/javascript/lostcoords/ui/controllers/lc_dock_controller.js
159
164
  - app/javascript/lostcoords/ui/controllers/lc_emoji_picker_controller.js
160
165
  - app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js
161
166
  - app/javascript/lostcoords/ui/controllers/lc_menu_controller.js
@@ -169,6 +174,7 @@ files:
169
174
  - app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js
170
175
  - app/javascript/lostcoords/ui/controllers/lc_toast_controller.js
171
176
  - app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js
177
+ - app/javascript/lostcoords/ui/controllers/lc_toc_controller.js
172
178
  - app/javascript/lostcoords/ui/controllers/lc_toggle_controller.js
173
179
  - app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js
174
180
  - app/javascript/lostcoords/ui/helpers/catalog_picker.js
@@ -200,7 +206,6 @@ files:
200
206
  - config/locales/es.yml
201
207
  - config/locales/ja.yml
202
208
  - lib/lostcoords/ui.rb
203
- - lib/lostcoords/ui/action_view.rb
204
209
  - lib/lostcoords/ui/builders.rb
205
210
  - lib/lostcoords/ui/components.rb
206
211
  - lib/lostcoords/ui/engine.rb
@@ -208,8 +213,6 @@ files:
208
213
  - lib/lostcoords/ui/helpers.rb
209
214
  - lib/lostcoords/ui/icons.rb
210
215
  - lib/lostcoords/ui/parts.rb
211
- - lib/lostcoords/ui/rails.rb
212
- - lib/lostcoords/ui/resources.rb
213
216
  - lib/lostcoords/ui/stylesheets.rb
214
217
  - lib/lostcoords/ui/tokens.rb
215
218
  - lib/lostcoords/ui/version.rb
@@ -241,5 +244,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
241
244
  requirements: []
242
245
  rubygems_version: 4.0.6
243
246
  specification_version: 4
244
- summary: ActionView UI components with Tailwind CSS and Stimulus.
247
+ summary: Rails UI components with Tailwind CSS and Stimulus.
245
248
  test_files: []
@@ -1,13 +0,0 @@
1
- require "bigdecimal"
2
- require "action_view"
3
- require "active_support/core_ext/hash"
4
- require "active_support/core_ext/object/blank"
5
- require "tailwind_merge"
6
- require_relative "resources"
7
- require_relative "icons"
8
- require_relative "parts"
9
- require_relative "form_builder"
10
- require_relative "helpers"
11
- require_relative "stylesheets"
12
-
13
- Lostcoords::Ui.load_translations!
@@ -1,3 +0,0 @@
1
- require "rails/engine"
2
- require "lostcoords/ui/action_view"
3
- require "lostcoords/ui/engine"
@@ -1,17 +0,0 @@
1
- require "pathname"
2
- require_relative "version"
3
-
4
- module Lostcoords::Ui
5
- def self.root
6
- @root ||= Pathname.new(File.expand_path("../../..", __dir__)).freeze
7
- end
8
-
9
- def self.view_paths
10
- [ root.join("app/views").to_s ]
11
- end
12
-
13
- def self.load_translations!
14
- paths = (Dir[root.join("config/locales/*.yml")] + I18n.load_path).uniq
15
- I18n.load_path = paths unless I18n.load_path == paths
16
- end
17
- end