keystone_ui-preferences 0.8.0 → 0.10.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: f37c2cf73a8559ec91481efe3e542f12f3cd88363dce8de5396a3b0b70ed63dd
4
- data.tar.gz: d4335e98170923ea6920e602a99c6b55fb81be63236834f37b1ffbc4d1a5ebe1
3
+ metadata.gz: f764e1eedad8a815d7d8a0a92ff8f889d202a851f2c4bc063d7e1389867fb0f1
4
+ data.tar.gz: 4fd53a54a9c8b1e5609314e7bedfd3d6b6ed9ef0cc9a723e34a3bc6dbe72c0f4
5
5
  SHA512:
6
- metadata.gz: 799cef36ff33e44be4fb48c58604e129e81002205518c2d2dc5f59a09c3a1e4f097c6d0fe8d4cbe1bf276774542150c9e6573e72208700d20aca41a64d589e77
7
- data.tar.gz: 25617ce106f11bc189f115ad36ec2979884a90adb9f973bc703da55a11d8ab73a368f62fc15d76bc327cfd0c5333b5907125959ccd5a357146d89ede96b89fc5
6
+ metadata.gz: 9370930c96ddf66319f31e53f9ea08697367c72e6bce7782e216f83b7c3622857dad697d8241c76ca95e924069898300eb41f9d69dce1d507c818f690e59d8a5
7
+ data.tar.gz: aa97e42143a98e7d5f14858c0acd93eef460036dde1585092fca0b7da982a7957b69969fd33fc6638ee93d61ffa4dc8c4aa77b4601fda29865201bb19d922f04
data/README.md CHANGED
@@ -6,7 +6,7 @@ A page gives a `ui_data_table` a `key:`. This gem saves each signed-in person's
6
6
 
7
7
  ## Installation
8
8
 
9
- Add the gem and run its install generator, which copies a migration for its table and the template picker script the appearance section uses:
9
+ Add the gem and run its install generator, which copies a migration for its table and the appearance editor script the Appearance sections use:
10
10
 
11
11
  ```ruby
12
12
  gem "keystone_ui-preferences"
@@ -18,20 +18,22 @@ bin/rails generate keystone_ui:preferences:install
18
18
  bin/rails db:migrate
19
19
  ```
20
20
 
21
- The script lands at `app/javascript/controllers/keystone_ui/preferences/theme_settings_controller.js`. Register it in `app/javascript/controllers/index.js`:
21
+ The script lands at `app/javascript/controllers/keystone_ui/preferences/appearance_editor_controller.js`. Register it in `app/javascript/controllers/index.js`:
22
22
 
23
23
  ```js
24
- import ThemeSettingsController from "./keystone_ui/preferences/theme_settings_controller"
25
- application.register("keystone-ui--preferences--theme-settings", ThemeSettingsController)
24
+ import AppearanceEditorController from "./keystone_ui/preferences/appearance_editor_controller"
25
+ application.register("keystone-ui--preferences--appearance-editor", AppearanceEditorController)
26
26
  ```
27
27
 
28
- After updating the gem, run its update generator to copy any migrations added since it was installed, and the template picker script when the app does not have it yet:
28
+ After updating the gem, run its update generator to copy any migrations added since it was installed, and the appearance editor script when the app does not have it yet:
29
29
 
30
30
  ```bash
31
31
  bin/rails generate keystone_ui:preferences:update
32
32
  bin/rails db:migrate
33
33
  ```
34
34
 
35
+ An app that installed an earlier version has a template picker script at `app/javascript/controllers/keystone_ui/preferences/theme_settings_controller.js` and a theme editor script at `app/javascript/controllers/keystone_ui/preferences/theme_editor_controller.js`. The appearance editor script replaces both. Register the appearance editor script as shown above. Then remove both old scripts and the two lines in `app/javascript/controllers/index.js` that register them as `keystone-ui--preferences--theme-settings` and `keystone-ui--preferences--theme-editor`. The update generator copies the new script and leaves both old scripts in place, so removing them is the app's own step.
36
+
35
37
  Mount the engine in `config/routes.rb`. A table's Columns menu saves to it:
36
38
 
37
39
  ```ruby
@@ -182,7 +184,7 @@ SettingsHub.section :appearance, area: :user, title: "Appearance",
182
184
  runs: "KeystoneUi::Preferences::PickAppearance"
183
185
  ```
184
186
 
185
- The section looks and behaves as keystone_ui-colors' colour settings do. It offers Default, the five named templates from keystone_ui-looks, the six preset themes from keystone_ui-looks and Custom, with an accent, a surface and a text colour picker under them. The person's own saved choice is selected, or Default when they have none, and the pickers show the colours drawn for them now. Picking a template sets the pickers to that template's colours at once, and changing a picker selects Custom, through the template picker script the install generator copies. Without the script the section still saves, and the form saves with a full page load, so it works in a Hotwire Native web view.
187
+ The section looks and behaves as keystone_ui-colors' colour settings do. It offers Default, the five named templates from keystone_ui-looks, the six preset themes from keystone_ui-looks and Custom, with an accent, a surface and a text colour picker under them. The person's own saved choice is selected, or Default when they have none, and the pickers show the colours drawn for them now. Picking a template sets the pickers to that template's colours at once, and changing a picker selects Custom, through the appearance editor script the install generator copies. Without the script the section still saves, and the form saves with a full page load, so it works in a Hotwire Native web view.
186
188
 
187
189
  Saving a template keeps `{ "template_name": "forest" }` for that person under the `colours` key. Saving a preset theme keeps only its name, as `{ "preset_name": "harbor" }`. Saving Custom keeps `{ "accent": "#e11d48", "surface": "#f5e6c8", "text": "#3b2f1e" }`. Saving Default removes the person's `colours` row, so the account's colours, or the app's defaults, apply again. The next page the person opens is drawn in the saved colours.
188
190
 
@@ -196,9 +198,17 @@ Shape and type settings are kept from the `:root` block only, as `{ "radius": {
196
198
 
197
199
  Each refusal below saves nothing and says why. A paste over 20,000 characters is refused before any of it is read. A paste containing `url(` or `@import` anywhere is refused. A paste with no shadcn colour role in a `:root` or `.dark` block is refused. A colour that fails the check is refused with a message naming the role and whether it is the light or dark value. A shape or type value that fails its check is refused with a message naming the setting, and so is a font list left with no font the app offers. A paste is refused with the same message as other colours while the account sets the colours for everyone.
198
200
 
199
- Under the paste box, a disclosure that starts closed lists every theme setting as its own field, named by its shadcn variable. Each colour role has a light and a dark keystone_ui colour picker, and the shadow colour has one, so a colour can be typed as hex, `rgb()`, `hsl()` or `oklch()`. Every other setting is a text field. The fields show the person's saved theme, so after a paste they hold the values that were read. Changing a field and pressing Save saves the fields as the person's theme, so a theme edited by hand stays the person's own. A field left blank leaves that setting out of the theme. Each value is checked and refused exactly as a pasted one is, with spaces around it removed first. The fields are saved only when they differ from the saved theme, so picking a template or changing the Accent, Surface or Text picker saves as before, and a paste sent in the same save wins over the fields. The disclosure needs no script, but the colour pickers need keystone_ui's colour picker script to take a typed colour.
201
+ After Look and Mode, the section lists every theme setting as its own field, named by its shadcn variable. The fields are grouped as tweakcn's editor groups them, and each group is a keystone_ui disclosure that is closed when the section opens, so Save sits a short way below Mode. A group holding a setting that was refused on Save opens, so the refusal shows under that setting. Colors holds the colour roles in sets, each with its foreground beside it: Page (`background` and `foreground`), Primary, Secondary, Accent, Muted, Card, Popover, Destructive, Border, input and ring, Chart (`chart-1` to `chart-5`) and the eight Sidebar roles. Typography holds the sans, serif, mono and heading fonts, the four font weights and the letter spacing. Other holds the radius, the spacing, the border width and the six shadow settings. Each colour role shows its name once, such as `--primary`, with a light and a dark keystone_ui colour picker side by side under it, labelled Light and Dark, and the name labels the pair for a screen reader. The shadow colour has one picker, so a colour can be typed as hex, `rgb()`, `hsl()` or `oklch()`. Every other setting is a text field. Each font field suggests the fonts in `theme_fonts` and then the system fonts the gem offers, and still takes a typed list of several fonts. The fields show the person's saved theme, so after a paste they hold the values that were read. Changing a field and pressing Save saves the fields as the person's theme, so a theme edited by hand stays the person's own. A field left blank leaves that setting out of the theme. Each value is checked and refused exactly as a pasted one is, with spaces around it removed first. The fields are saved only when they differ from the saved theme, so picking a template or changing the Accent, Surface or Text picker saves as before, and a paste sent in the same save wins over the fields. The grouped fields and the font suggestions need no script, but the colour pickers need keystone_ui's colour picker script to take a typed colour.
202
+
203
+ Each change to a theme field is drawn on the whole page before saving, through the appearance editor script the install generator copies. The script sets the setting's `--ks-*` variables on the page's root element, taking the list of which setting sets which variables from the same theme roles list the palette style tag writes from, so the page looks as it will after Save. A colour also sets the hover colour the style tag derives from `primary`, `secondary` and `destructive`, and a dark value sets only the `-dark` variables, so it shows only where the page is drawn dark. Colours, corner radius, spacing, border width, letter spacing, fonts, font weights and the shadow are drawn this way, and the shadow is drawn only once all six shadow settings hold a value, as the style tag writes it. A value the browser cannot draw leaves the page as it was, and it is still sent and checked on Save. Leaving the section without saving removes every variable the script set, so the next page, a Turbo visit included, is drawn in the saved theme. The script saves nothing and sends no request, so Save works as before, and without the script the fields still save.
204
+
205
+ Beside the fields the section shows a preview of keystone_ui's own components: a card, a primary, a secondary and a danger button, a form with a text field and a select, a table, a line chart and a sidebar navigation list. The preview is drawn twice, once on a side marked light and once on a side marked dark, from sample data the gem holds. Each unsaved change the appearance editor script draws on the page also shows in the preview, so a dark value changes only the dark side, including while the page itself is drawn dark. A chart colour redraws the preview's line chart and a sidebar colour changes its navigation list. The preview names no font of its own and loads nothing from another site, so it draws in the fonts the theme sets. The layout follows the width the section is given, not the window's, so a section in half of a two-column settings page is laid out by that half. Where the section is 56rem wide or wider the fields and the preview sit side by side. Where it is at least 32rem wide but narrower than 56rem, the preview sits under the fields and Save. Where it is narrower than 32rem, such as on a phone, the section shows an Edit tab and a Preview tab from keystone_ui's tab switcher, and picking a tab shows its panel through the appearance editor script. The widths come from a CSS container query, which needs Safari 16, Chrome 105 or Firefox 110 or later. The preview needs keystone_ui-styles 0.23.0 or later, whose dark styles leave a side marked light drawn light inside a dark page, and keystone_ui 0.47.0 or later, whose line chart takes light or dark from the side it is drawn on and redraws when the theme's variables change, and it needs keystone_ui's tab switcher and line chart scripts registered. The preview is shown only alongside the theme fields, so it is not shown while the account sets the colours for everyone.
206
+
207
+ The preset themes are option cards after the templates, each showing its light primary and secondary colours as swatches, and the person's saved preset is selected. Picking a preset card fills the theme fields with that preset's values, through the appearance editor script, which draws them on the whole page and the preview before saving, as it draws a field changed by hand. Once a preset is saved the fields show its values. Theme fields sent unchanged, holding the picked or the saved preset's values, save the picked card as before. Changing a field after picking a preset saves the fields as the person's own theme in place of the preset. Without the script, picking a card and pressing Save still saves the preset. A Reset button beside Save fills every theme field, the page and the preview from the picked preset card, or from the saved theme when no preset card is picked, and saves nothing. A setting the saved theme leaves out is emptied, and the page draws it from the saved theme again.
208
+
209
+ Once a theme field differs from the saved theme, or the picked colour card, the Custom accent, surface or text picker, the picked Mode, the picked Look or, in the account's section, either Members choose switch differs from what the page opened with, or the paste box holds text, the appearance editor script asks before the change is lost. The page opens with the saved colour card, colours, Mode, Look and switches selected, after a refused save as well, so picking another and then the saved one again asks nothing. A paste box that is empty or holds only spaces sends nothing to save, so it asks nothing. Following a link out of the section holds the Turbo visit and opens a question on the page, from keystone_ui's modal, offering Leave without saving and Stay. Leave without saving carries on to the page the link was going to, and Stay closes the question. Because the question is part of the page, it shows in a Hotwire Native web view, where Turbo asks the page before it hands a link to the app. Closing or reloading the page asks the browser to show its own leave-page warning, which a Hotwire Native web view does not show. The native back button and tab bar of a Hotwire Native app leave the page without asking it, so they leave with no question, and so does the browser's back button. Saving, resetting to the saved theme, or changing nothing leaves with no question. The question is part of every Appearance section, the person's and the account's, including one that offers no colours. The question needs keystone_ui's modal script registered as `modal`.
200
210
 
201
- The preset themes are option cards after the templates, each showing its light primary and secondary colours as swatches, and the person's saved preset is selected. Picking a preset card fills the theme fields with that preset's values, through the template picker script, and once a preset is saved the fields show its values. Theme fields sent unchanged, holding the picked or the saved preset's values, save the picked card as before. Changing a field after picking a preset saves the fields as the person's own theme in place of the preset. Without the script, picking a card and pressing Save still saves the preset.
211
+ After the theme fields an Export text area shows the fields' current values, saved or not, as a `:root` block with every light value and a `.dark` block with every dark value, in shadcn/ui's variable names, which is the shape the paste box reads. The appearance editor script writes it when the section opens and again on each change, in the browser, so it carries unsaved edits. The text area is read-only and has no name, so Save does not send it. A Copy button beside it copies the export through keystone_ui's clipboard script with no request to the server. Where the browser refuses to copy, as a Hotwire Native web view may, the button says to select the text, and the text area can be selected and copied by hand. A value holding a semicolon or a curly brace is left out, so it cannot end its block or add a setting. An exported theme pasted back into the paste box and saved keeps every value it held. Copying needs keystone_ui's clipboard script registered. The export is not offered in keystone's own `--ks-*` variable names.
202
212
 
203
213
  The `colours` key follows the account layouts above. While an account turns off members-choose for it, the section says the account sets the colours for everyone and offers no colours, and a save that sends colours anyway is refused with a message and saves nothing.
204
214
 
@@ -239,7 +249,7 @@ SettingsHub.section :account_appearance, area: :account, title: "Appearance",
239
249
  runs: "KeystoneUi::Preferences::PickAccountAppearance"
240
250
  ```
241
251
 
242
- The section looks and behaves as keystone_ui-colors' account picker does. It offers Default, the five named templates, the six preset themes and Custom with the three colour pickers, the paste box and the theme fields the person's section has, then the app's own look and every registered look, with the account's saved choices selected, or Default and the app's own look when it has none. Under them are two switches, one for whether members choose their own colours and one for whether members choose their own look. Both are on until an admin turns them off.
252
+ The section looks and behaves as keystone_ui-colors' account picker does. It offers Default, the five named templates, the six preset themes and Custom with the three colour pickers and the paste box the person's section has, then the app's own look and every registered look, with the account's saved choices selected, or Default and the app's own look when it has none. Under them are two switches, one for whether members choose their own colours and one for whether members choose their own look. Both are on until an admin turns them off. The theme fields come last, grouped into Colors, Typography and Other and folded closed as in the person's section. The section is the same theme editor as the person's. Each change to a field and each picked preset card is drawn on the whole page before saving. The preview is laid out by the section's width as in the person's section: beside the fields from 56rem, under them from 32rem, and under Edit and Preview tabs below that. A Reset button beside Save fills the fields from the picked preset card, or from the account's saved theme, and saves nothing. The Export text area and its Copy button carry the fields' current values. Leaving the section without saving draws the next page in the admin's own saved theme. While the app turns off account colours the section shows no preview, no tabs and no Reset button.
243
253
 
244
254
  Saving keeps the account's colours under the `colours` key, as `{ "template_name": "forest" }`, `{ "preset_name": "harbor" }`, `{ "accent": "#e11d48", "surface": "#f5e6c8", "text": "#3b2f1e" }` or a pasted or hand-edited theme, and its look under the `look` key, with each switch in that row's `members_choose` column. Saving Default keeps `{ "template_name": "default" }`, and saving the app's own look keeps an empty look, so neither removes the row its switch is on. The colours, a pasted theme, the theme fields and the look pass the same checks as a person's, the theme fields are saved only when they differ from the account's saved theme or the picked or saved preset, and a template, preset, colour, theme value or look that fails one is refused with a message, and nothing is saved. A save with no account is refused too.
245
255
 
@@ -0,0 +1,248 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const THEME_FIELD = /^theme\[([^\]]+)\]\[(light|dark)\]$/
4
+ const ENDS_DECLARATION = /[;{}]/
5
+ const QUICK_CHOICES = "[name='template_name'], [name='accent'], [name='surface'], [name='text'], [name='mode'], [name='look'], [name='members_choose'], [name='members_choose_look']"
6
+ const PROPERTY_FOR_KIND = { colour: "color", length: "width", letter_spacing: "letter-spacing", font_list: "font-family", weight: "font-weight" }
7
+ const SHADOW_SETTINGS = ["shadow-offset-x", "shadow-offset-y", "shadow-blur", "shadow-spread", "shadow-color", "shadow-opacity"]
8
+ const SHADOWS = ["--ks-shadow-surface", "--ks-shadow-overlay", "--ks-shadow-menu"]
9
+
10
+ export default class extends Controller {
11
+ static targets = ["accentPicker", "surfacePicker", "textPicker", "customRadio", "templateLabel"]
12
+
13
+ #setVariables = new Set()
14
+ #loadedChoices = []
15
+ #heldUrl = null
16
+ #leaveAllowed = false
17
+
18
+ connect() {
19
+ this.#loadedChoices = this.#quickChoices()
20
+ this.colorPickerTargets().forEach(picker => picker.addEventListener("change", () => this.customColorChanged()))
21
+ this.#writeExport()
22
+ this.#drawUnsavedFields()
23
+ }
24
+
25
+ confirmLeave(event) {
26
+ if (!this.#hasUnsavedChanges()) return
27
+
28
+ event.preventDefault()
29
+ this.#heldUrl = event.detail.url
30
+ this.element.querySelector("[data-leave-question] [data-modal-target='backdrop']").classList.remove("hidden")
31
+ }
32
+
33
+ warnBeforeUnload(event) {
34
+ if (this.#hasUnsavedChanges()) event.preventDefault()
35
+ }
36
+
37
+ save() {
38
+ this.#leaveAllowed = true
39
+ }
40
+
41
+ leave() {
42
+ this.#leaveAllowed = true
43
+ this.element.ownerDocument.defaultView.Turbo.visit(this.#heldUrl)
44
+ }
45
+
46
+ disconnect() {
47
+ this.#removeSetVariables()
48
+ }
49
+
50
+ apply({ target: { name, value } }) {
51
+ this.#writeExport()
52
+ const field = name.match(THEME_FIELD)
53
+ if (!field) return
54
+
55
+ const [, role, side] = field
56
+ if (SHADOW_SETTINGS.includes(role)) return this.#drawShadow()
57
+
58
+ const { kind, variables, hover } = this.#roles()[role]
59
+ const suffix = side === "dark" ? "-dark" : ""
60
+ if (value === "") {
61
+ [...variables, hover].filter(Boolean).forEach(variable => this.#remove(`${variable}${suffix}`))
62
+ return
63
+ }
64
+ if (!CSS.supports(PROPERTY_FOR_KIND[kind], value)) return
65
+
66
+ variables.forEach(variable => this.#set(`${variable}${suffix}`, value))
67
+ if (hover) this.#set(`${hover}${suffix}`, `color-mix(in oklab, ${value} 85%, white)`)
68
+ }
69
+
70
+ colorPickerTargets() {
71
+ return [
72
+ this.hasAccentPickerTarget && this.accentPickerTarget,
73
+ this.hasSurfacePickerTarget && this.surfacePickerTarget,
74
+ this.hasTextPickerTarget && this.textPickerTarget
75
+ ].filter(Boolean)
76
+ }
77
+
78
+ selectTemplate(event) {
79
+ const input = event.currentTarget
80
+ const label = input.closest("[data-template]")
81
+ const accentHex = input.dataset.accentHex
82
+ const surfaceHex = input.dataset.surfaceHex
83
+ const textHex = input.dataset.textHex
84
+
85
+ this.setPickerValue(this.accentPickerTarget, accentHex)
86
+ this.setPickerValue(this.surfacePickerTarget, surfaceHex)
87
+ this.setPickerValue(this.textPickerTarget, textHex)
88
+ this.#restoreSavedTheme()
89
+
90
+ this.highlightTemplate(label.dataset.template)
91
+ }
92
+
93
+ selectPreset(event) {
94
+ const input = event.currentTarget
95
+ const theme = JSON.parse(input.dataset.theme)
96
+
97
+ Object.entries(theme).forEach(([role, sides]) => {
98
+ Object.entries(sides).forEach(([side, value]) => this.setThemeField(`theme[${role}][${side}]`, value))
99
+ })
100
+
101
+ this.highlightTemplate(input.closest("[data-template]").dataset.template)
102
+ }
103
+
104
+ reset() {
105
+ const preset = this.element.querySelector("input[name='template_name'][data-theme]:checked")
106
+ this.#fillThemeFields(preset ? JSON.parse(preset.dataset.theme) : this.#savedTheme())
107
+ }
108
+
109
+ setThemeField(name, value) {
110
+ const field = this.element.querySelector(`[name="${name}"]`)
111
+ if (!field) return
112
+
113
+ const picker = field.closest("[data-controller~='color-picker']")
114
+ if (picker) {
115
+ this.setPickerValue(picker, value)
116
+ } else {
117
+ field.value = value
118
+ }
119
+ field.dispatchEvent(new Event("change", { bubbles: true }))
120
+ }
121
+
122
+ showPanel({ detail: { index } }) {
123
+ this.element.querySelectorAll("[data-editor-panel]").forEach((panel, place) => panel.toggleAttribute("data-shown", place === index))
124
+ }
125
+
126
+ customColorChanged() {
127
+ if (this.hasCustomRadioTarget) this.customRadioTarget.checked = true
128
+ this.#restoreSavedTheme()
129
+ this.highlightTemplate("custom")
130
+ }
131
+
132
+ highlightTemplate(selected) {
133
+ this.templateLabelTargets.forEach(label => {
134
+ const border = label.querySelector("span")
135
+ if (label.dataset.template === selected) {
136
+ border.classList.remove("border-transparent")
137
+ border.classList.add("border-accent-500")
138
+ } else {
139
+ border.classList.remove("border-accent-500")
140
+ border.classList.add("border-transparent")
141
+ }
142
+ })
143
+ }
144
+
145
+ setPickerValue(wrapper, hex) {
146
+ const input = wrapper.querySelector("input[type='hidden']")
147
+ const swatch = wrapper.querySelector("[data-color-picker-target='swatch']")
148
+ const hexLabel = wrapper.querySelector("[data-color-picker-target='hexLabel']")
149
+ const text = wrapper.querySelector("[data-color-picker-target='text']")
150
+
151
+ if (input) input.value = hex
152
+ if (swatch) swatch.style.backgroundColor = hex
153
+ if (hexLabel) hexLabel.textContent = hex
154
+ if (text) text.value = hex
155
+ }
156
+
157
+ #restoreSavedTheme() {
158
+ this.#fillThemeFields(this.#savedTheme())
159
+ this.#removeSetVariables()
160
+ }
161
+
162
+ #removeSetVariables() {
163
+ this.#setVariables.forEach(variable => this.#rootStyle().removeProperty(variable))
164
+ this.#setVariables.clear()
165
+ }
166
+
167
+ #fillThemeFields(theme) {
168
+ this.element.querySelectorAll("[name^='theme[']").forEach(({ name }) => {
169
+ const [, role, side] = name.match(THEME_FIELD)
170
+ this.setThemeField(name, theme[role]?.[side] ?? "")
171
+ })
172
+ }
173
+
174
+ #writeExport() {
175
+ const exported = this.element.querySelector("[data-theme-export]")
176
+ if (!exported) return
177
+
178
+ const roles = Object.keys(this.#roles())
179
+ const block = side => roles.flatMap(role => {
180
+ const field = this.element.querySelector(`[name="theme[${role}][${side}]"]`)
181
+ return field?.value && !ENDS_DECLARATION.test(field.value) ? [` --${role}: ${field.value};\n`] : []
182
+ }).join("")
183
+ const css = `:root {\n${block("light")}}\n\n.dark {\n${block("dark")}}\n`
184
+ exported.value = css
185
+ this.element.querySelector("[data-controller~='clipboard']").dataset.clipboardText = css
186
+ }
187
+
188
+ #drawShadow() {
189
+ const settings = SHADOW_SETTINGS.map(setting => this.element.querySelector(`[name="theme[${setting}][light]"]`)?.value)
190
+ const [x, y, blur, spread, colour, opacity] = settings
191
+ const shadow = `${x} ${y} ${blur} ${spread} color-mix(in srgb, ${colour} calc(${opacity} * 100%), transparent)`
192
+ if (settings.every(Boolean) && CSS.supports("box-shadow", shadow)) {
193
+ SHADOWS.forEach(variable => this.#set(variable, shadow))
194
+ } else {
195
+ SHADOWS.forEach(variable => this.#remove(variable))
196
+ }
197
+ }
198
+
199
+ #drawUnsavedFields() {
200
+ this.#unsavedFields().forEach(field => this.apply({ target: field }))
201
+ }
202
+
203
+ #unsavedFields() {
204
+ const saved = this.#savedTheme()
205
+ return [...this.element.querySelectorAll("[name^='theme[']")].filter(field => {
206
+ const [, role, side] = field.name.match(THEME_FIELD)
207
+ return field.value !== (saved[role]?.[side] ?? "")
208
+ })
209
+ }
210
+
211
+ #savedTheme() {
212
+ return JSON.parse(this.element.querySelector("[data-saved-theme]")?.dataset.savedTheme ?? "{}")
213
+ }
214
+
215
+ #hasUnsavedChanges() {
216
+ return !this.#leaveAllowed && (this.#unsavedFields().length > 0 || this.#quickChoicesChanged() || this.#pasteTyped())
217
+ }
218
+
219
+ #quickChoicesChanged() {
220
+ return this.#quickChoices().some((choice, place) => choice !== this.#loadedChoices[place])
221
+ }
222
+
223
+ #pasteTyped() {
224
+ return [...this.element.querySelectorAll("[name='pasted_theme']")].some(({ value }) => value.trim() !== "")
225
+ }
226
+
227
+ #quickChoices() {
228
+ return [...this.element.querySelectorAll(QUICK_CHOICES)].map(field => (["radio", "checkbox"].includes(field.type) ? field.checked : field.value))
229
+ }
230
+
231
+ #roles() {
232
+ return JSON.parse(this.element.querySelector("[data-theme-roles]").dataset.themeRoles)
233
+ }
234
+
235
+ #set(variable, value) {
236
+ this.#rootStyle().setProperty(variable, value)
237
+ this.#setVariables.add(variable)
238
+ }
239
+
240
+ #remove(variable) {
241
+ this.#rootStyle().removeProperty(variable)
242
+ this.#setVariables.delete(variable)
243
+ }
244
+
245
+ #rootStyle() {
246
+ return this.element.ownerDocument.documentElement.style
247
+ }
248
+ }
@@ -25,15 +25,15 @@ module KeystoneUi
25
25
  end
26
26
 
27
27
  def editing?
28
- typed.any? && [ saved["theme"], preset_theme(saved["preset_name"]), preset_theme(@values[:template_name]) ].exclude?(typed)
28
+ typed.any? && [ saved["theme"], *preset_themes ].exclude?(typed)
29
29
  end
30
30
 
31
- def saved
32
- @saved ||= @saved_colours.call
31
+ def preset_themes
32
+ KeystoneUi::Looks::Themes.names.map { |name| KeystoneUi::Looks::Themes[name][:theme] }
33
33
  end
34
34
 
35
- def preset_theme(name)
36
- KeystoneUi::Looks::Themes[name][:theme] if KeystoneUi::Looks::Themes.names.map(&:to_s).include?(name.to_s)
35
+ def saved
36
+ @saved ||= @saved_colours.call
37
37
  end
38
38
 
39
39
  def typed
@@ -56,6 +56,31 @@ module KeystoneUi
56
56
  "shadow-offset-y" => { kind: :signed_length, variables: [] }
57
57
  }.freeze
58
58
 
59
+ GROUPS = {
60
+ "Colors" => {
61
+ "Page" => %w[background foreground],
62
+ "Primary" => %w[primary primary-foreground],
63
+ "Secondary" => %w[secondary secondary-foreground],
64
+ "Accent" => %w[accent accent-foreground],
65
+ "Muted" => %w[muted muted-foreground],
66
+ "Card" => %w[card card-foreground],
67
+ "Popover" => %w[popover popover-foreground],
68
+ "Destructive" => %w[destructive destructive-foreground],
69
+ "Border, input and ring" => %w[border input ring],
70
+ "Chart" => %w[chart-1 chart-2 chart-3 chart-4 chart-5],
71
+ "Sidebar" => %w[sidebar sidebar-foreground sidebar-primary sidebar-primary-foreground sidebar-accent sidebar-accent-foreground sidebar-border sidebar-ring]
72
+ },
73
+ "Typography" => {
74
+ "Fonts" => %w[font-sans font-serif font-mono font-heading],
75
+ "Font weights" => %w[font-weight-normal font-weight-medium font-weight-strong font-weight-heading],
76
+ "Letter spacing" => %w[letter-spacing]
77
+ },
78
+ "Other" => {
79
+ "Shape" => %w[radius spacing border-width],
80
+ "Shadow" => %w[shadow-color shadow-opacity shadow-blur shadow-spread shadow-offset-x shadow-offset-y]
81
+ }
82
+ }.freeze
83
+
59
84
  def self.names
60
85
  ROLES.keys
61
86
  end
@@ -5,16 +5,18 @@
5
5
  offers_colours = KeystoneUi::Preferences.configuration.account_colors
6
6
  offers_look = KeystoneUi::Preferences.configuration.account_looks && KeystoneUi.configuration.looks.any?
7
7
  drawn = KeystoneUi::Preferences::AppearanceChoice.new(person: account, account: nil).colours
8
+ sent = request.get? ? {} : local_assigns[:selection].to_h
8
9
  %>
9
10
 
10
11
  <% if !offers_colours && !offers_look %>
11
12
  <%= ui_alert(message: "This app does not let accounts choose their colours or look.") %>
12
13
  <% else %>
14
+ <% editor = capture do %>
13
15
  <%= ui_panel(padding: :lg) do %>
14
- <%= form_with(url: submit_url, method: :patch, data: { turbo: false, controller: "keystone-ui--preferences--theme-settings" }) do %>
16
+ <%= form_with(url: submit_url, method: :patch, data: { turbo: false }) do %>
15
17
  <div style="display: flex; flex-direction: column; gap: 2rem;">
16
18
  <% if offers_colours %>
17
- <%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn, pastes: true, presets: true, own_theme_hint: "The account's current choice is its own theme." } %>
19
+ <%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn, pastes: true, presets: true, own_theme_hint: "The account's current choice is its own theme.", id_prefix: "account-appearance", sent_paste: sent[:pasted_theme] } %>
18
20
  <% end %>
19
21
 
20
22
  <% if offers_look %>
@@ -32,10 +34,20 @@
32
34
  <% end %>
33
35
  <% end %>
34
36
 
35
- <div>
37
+ <% if offers_colours %>
38
+ <%= render partial: "keystone_ui/preferences/settings/theme_settings", locals: { saved: saved, id_prefix: "account-appearance", sent_theme: sent[:theme], sent_paste: sent[:pasted_theme] } %>
39
+ <% end %>
40
+
41
+ <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
36
42
  <%= ui_button(label: "Save", type: :submit) %>
43
+ <% if offers_colours %>
44
+ <%= ui_button(label: "Reset", variant: :secondary, type: :button, data: { action: "keystone-ui--preferences--appearance-editor#reset" }) %>
45
+ <% end %>
37
46
  </div>
38
47
  </div>
39
48
  <% end %>
40
49
  <% end %>
41
50
  <% end %>
51
+
52
+ <%= render partial: "keystone_ui/preferences/settings/editor_layout", locals: { editor: editor, previews: offers_colours } %>
53
+ <% end %>
@@ -5,6 +5,7 @@
5
5
  appearance = KeystoneUi::Preferences::AppearanceChoice.new(person: person, account: account)
6
6
  drawn = appearance.colours
7
7
  saved = KeystoneUi::Preferences::ComponentPreference.find_by(owner: person, component_key: "colours")&.value || { "template_name" => "default" }
8
+ sent = request.get? ? {} : local_assigns[:selection].to_h
8
9
  saved_look = KeystoneUi::Preferences::ComponentPreference.find_by(owner: person, component_key: "look")&.value.to_h["look"]
9
10
  %>
10
11
 
@@ -12,29 +13,40 @@
12
13
  <%= ui_alert(message: "Your account sets the colours for everyone.") %>
13
14
  <% end %>
14
15
 
15
- <%= ui_panel(padding: :lg) do %>
16
- <%= form_with(url: submit_url, method: :patch, data: { turbo: false, controller: "keystone-ui--preferences--theme-settings" }) do %>
17
- <div style="display: flex; flex-direction: column; gap: 2rem;">
18
- <% if chooses_colours %>
19
- <%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn, pastes: true, presets: true } %>
20
- <% end %>
16
+ <% editor = capture do %>
17
+ <%= ui_panel(padding: :lg) do %>
18
+ <%= form_with(url: submit_url, method: :patch, data: { turbo: false }) do %>
19
+ <div style="display: flex; flex-direction: column; gap: 2rem;">
20
+ <% if chooses_colours %>
21
+ <%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn, pastes: true, presets: true, sent_paste: sent[:pasted_theme] } %>
22
+ <% end %>
21
23
 
22
- <% if appearance.person_chooses_look? && KeystoneUi.configuration.looks.any? %>
23
- <%= render partial: "keystone_ui/preferences/settings/look_choices", locals: { saved_look: saved_look } %>
24
- <% end %>
24
+ <% if appearance.person_chooses_look? && KeystoneUi.configuration.looks.any? %>
25
+ <%= render partial: "keystone_ui/preferences/settings/look_choices", locals: { saved_look: saved_look } %>
26
+ <% end %>
27
+
28
+ <%= ui_section(title: "Mode", spacing: :sm) do %>
29
+ <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
30
+ <% KeystoneUi::ThemeChoice::MODES.each do |mode| %>
31
+ <% next if mode == "custom" && !(chooses_colours || appearance.custom_offered?) %>
32
+ <%= ui_radio_card(name: "mode", value: mode, label: mode.capitalize, checked: appearance.mode == mode) %>
33
+ <% end %>
34
+ </div>
35
+ <% end %>
36
+
37
+ <% if chooses_colours %>
38
+ <%= render partial: "keystone_ui/preferences/settings/theme_settings", locals: { saved: saved, sent_theme: sent[:theme], sent_paste: sent[:pasted_theme] } %>
39
+ <% end %>
25
40
 
26
- <%= ui_section(title: "Mode", spacing: :sm) do %>
27
41
  <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
28
- <% KeystoneUi::ThemeChoice::MODES.each do |mode| %>
29
- <% next if mode == "custom" && !(chooses_colours || appearance.custom_offered?) %>
30
- <%= ui_radio_card(name: "mode", value: mode, label: mode.capitalize, checked: appearance.mode == mode) %>
42
+ <%= ui_button(label: "Save", type: :submit) %>
43
+ <% if chooses_colours %>
44
+ <%= ui_button(label: "Reset", variant: :secondary, type: :button, data: { action: "keystone-ui--preferences--appearance-editor#reset" }) %>
31
45
  <% end %>
32
46
  </div>
33
- <% end %>
34
-
35
- <div>
36
- <%= ui_button(label: "Save", type: :submit) %>
37
47
  </div>
38
- </div>
48
+ <% end %>
39
49
  <% end %>
40
50
  <% end %>
51
+
52
+ <%= render partial: "keystone_ui/preferences/settings/editor_layout", locals: { editor: editor, previews: chooses_colours } %>
@@ -3,7 +3,6 @@
3
3
  palette_hex = ->(colour, palette) { colour.to_s.start_with?("#") ? colour.to_s : palette.call(colour)[500] }
4
4
  templates = { "default" => { accent: configuration.default_accent, surface: configuration.default_surface, text: configuration.default_text, label: "Default" } }
5
5
  KeystoneUi::Looks::Templates.all.each { |name, template| templates[name.to_s] = template }
6
- saved_preset = KeystoneUi::Looks::Themes.all.find { |name, _preset| name.to_s == saved["preset_name"] }&.last
7
6
  %>
8
7
 
9
8
  <%= ui_section(title: "Theme", spacing: :sm) do %>
@@ -16,13 +15,13 @@
16
15
  value: name,
17
16
  selected: saved["template_name"] == name,
18
17
  input_data: {
19
- action: "keystone-ui--preferences--theme-settings#selectTemplate",
18
+ action: "keystone-ui--preferences--appearance-editor#selectTemplate",
20
19
  "accent-hex": accent_hex,
21
20
  "surface-hex": surface_hex,
22
21
  "text-hex": template[:text]
23
22
  },
24
23
  label_data: {
25
- "keystone-ui--preferences--theme-settings-target": "templateLabel",
24
+ "keystone-ui--preferences--appearance-editor-target": "templateLabel",
26
25
  template: name
27
26
  }
28
27
  ) do %>
@@ -38,11 +37,11 @@
38
37
  value: name.to_s,
39
38
  selected: saved["preset_name"] == name.to_s,
40
39
  input_data: {
41
- action: "keystone-ui--preferences--theme-settings#selectPreset",
40
+ action: "keystone-ui--preferences--appearance-editor#selectPreset",
42
41
  theme: preset[:theme].to_json
43
42
  },
44
43
  label_data: {
45
- "keystone-ui--preferences--theme-settings-target": "templateLabel",
44
+ "keystone-ui--preferences--appearance-editor-target": "templateLabel",
46
45
  template: name.to_s
47
46
  }
48
47
  ) do %>
@@ -57,10 +56,10 @@
57
56
  value: "",
58
57
  selected: saved["template_name"].blank? && !saved.key?("theme") && !saved.key?("preset_name"),
59
58
  input_data: {
60
- "keystone-ui--preferences--theme-settings-target": "customRadio"
59
+ "keystone-ui--preferences--appearance-editor-target": "customRadio"
61
60
  },
62
61
  label_data: {
63
- "keystone-ui--preferences--theme-settings-target": "templateLabel",
62
+ "keystone-ui--preferences--appearance-editor-target": "templateLabel",
64
63
  template: "custom"
65
64
  }
66
65
  ) do %>
@@ -71,25 +70,29 @@
71
70
  <p class="ks-hint"><%= local_assigns.fetch(:own_theme_hint, "Your current choice is your own theme.") %></p>
72
71
  <% end %>
73
72
  <% if local_assigns[:pastes] %>
74
- <%= ui_form_field(attribute: "pasted_theme", label: "Paste a theme", type: :textarea, hint: "A :root block and a .dark block in shadcn/ui's variable names, as tweakcn and shadcn's theme pages export them.") %>
75
- <%= render partial: "keystone_ui/preferences/settings/theme_settings", locals: { theme: saved_preset ? saved_preset[:theme] : saved["theme"].to_h } %>
73
+ <% id_prefix = local_assigns.fetch(:id_prefix, "appearance") %>
74
+ <div class="<%= Keystone::Ui::FormFieldComponent::WRAPPER_CLASSES %>">
75
+ <%= label_tag "#{id_prefix}-pasted-theme", "Paste a theme", class: Keystone::Ui::FormFieldComponent::LABEL_CLASSES %>
76
+ <%= text_area_tag "pasted_theme", local_assigns[:sent_paste], id: "#{id_prefix}-pasted-theme", rows: 3, class: Keystone::Ui::TextareaComponent::BASE_CLASSES %>
77
+ <p class="<%= Keystone::Ui::FormFieldComponent::HINT_CLASSES %>">A :root block and a .dark block in shadcn/ui's variable names, as tweakcn and shadcn's theme pages export them.</p>
78
+ </div>
76
79
  <% end %>
77
80
  <% end %>
78
81
 
79
82
  <%= ui_section(title: "Accent Color", spacing: :sm) do %>
80
- <div data-keystone-ui--preferences--theme-settings-target="accentPicker">
83
+ <div data-keystone-ui--preferences--appearance-editor-target="accentPicker">
81
84
  <%= ui_color_picker(name: "accent", value: palette_hex.call(drawn[:accent], KeystoneUi::Looks::Palettes.method(:accent))) %>
82
85
  </div>
83
86
  <% end %>
84
87
 
85
88
  <%= ui_section(title: "Surface", spacing: :sm) do %>
86
- <div data-keystone-ui--preferences--theme-settings-target="surfacePicker">
89
+ <div data-keystone-ui--preferences--appearance-editor-target="surfacePicker">
87
90
  <%= ui_color_picker(name: "surface", value: palette_hex.call(drawn[:surface], KeystoneUi::Looks::Palettes.method(:surface))) %>
88
91
  </div>
89
92
  <% end %>
90
93
 
91
94
  <%= ui_section(title: "Text Color", spacing: :sm) do %>
92
- <div data-keystone-ui--preferences--theme-settings-target="textPicker">
95
+ <div data-keystone-ui--preferences--appearance-editor-target="textPicker">
93
96
  <%= ui_color_picker(name: "text", value: drawn[:text]) %>
94
97
  </div>
95
98
  <% end %>
@@ -0,0 +1,35 @@
1
+ <div data-appearance-editor data-controller="keystone-ui--preferences--appearance-editor" data-action="tab-switcher:change->keystone-ui--preferences--appearance-editor#showPanel submit->keystone-ui--preferences--appearance-editor#save turbo:before-visit@document->keystone-ui--preferences--appearance-editor#confirmLeave beforeunload@window->keystone-ui--preferences--appearance-editor#warnBeforeUnload">
2
+ <% if previews %>
3
+ <style>
4
+ [data-editor-panel]:not([data-shown]) { display: none; }
5
+ [data-editor-space] { container-type: inline-size; }
6
+ @container (min-width: 32rem) {
7
+ [data-editor-space] .ks-tab-bar { display: none; }
8
+ [data-editor-panels] { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.5rem; }
9
+ [data-editor-panel]:not([data-shown]) { display: block; }
10
+ }
11
+ @container (min-width: 56rem) {
12
+ [data-editor-panels] { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
13
+ }
14
+ </style>
15
+ <div data-editor-space>
16
+ <%= ui_tab_switcher(tabs: %w[Edit Preview]) do %>
17
+ <div data-editor-panels>
18
+ <div data-editor-panel data-shown><%= editor %></div>
19
+ <div data-editor-panel><%= render partial: "keystone_ui/preferences/settings/theme_preview" %></div>
20
+ </div>
21
+ <% end %>
22
+ </div>
23
+ <% else %>
24
+ <%= editor %>
25
+ <% end %>
26
+ <div data-leave-question>
27
+ <%= ui_modal(title: "Leave without saving?", size: :sm) do %>
28
+ <p>Your changes are not saved yet.</p>
29
+ <div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem;">
30
+ <%= ui_button(label: "Leave without saving", variant: :danger, type: :button, data: { action: "keystone-ui--preferences--appearance-editor#leave" }) %>
31
+ <%= ui_button(label: "Stay", variant: :secondary, type: :button, data: { action: "modal#close" }) %>
32
+ </div>
33
+ <% end %>
34
+ </div>
35
+ </div>
@@ -0,0 +1,34 @@
1
+ <div data-theme-preview style="display: flex; flex-direction: column; gap: 1.5rem;">
2
+ <% %w[light dark].each do |side| %>
3
+ <div data-theme="<%= side %>">
4
+ <%= ui_panel do %>
5
+ <div style="display: flex; flex-direction: column; gap: 1.5rem;">
6
+ <%= ui_card(title: "Quarterly review", summary: "Revenue grew in every region this quarter.", link: "#", cta: "Open") %>
7
+ <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
8
+ <%= ui_button(label: "Save", type: :button) %>
9
+ <%= ui_button(label: "Cancel", variant: :secondary, type: :button) %>
10
+ <%= ui_button(label: "Delete", variant: :danger, type: :button) %>
11
+ </div>
12
+ <div style="display: flex; flex-direction: column; gap: 1rem;">
13
+ <%= ui_form_field(attribute: "preview_name", label: "Name", value: "Northwind Traders") %>
14
+ <%= ui_form_field(attribute: "preview_region", label: "Region", type: :select, options: [ [ "North", "north" ], [ "South", "south" ] ], value: "north", required: true) %>
15
+ </div>
16
+ <%= ui_data_table(
17
+ items: [ { customer: "Northwind Traders", region: "North", total: "$12,400" }, { customer: "Harbor Supply", region: "South", total: "$8,950" }, { customer: "Fern & Co", region: "North", total: "$4,120" } ],
18
+ columns: [ { customer: "Customer" }, { region: "Region" }, { total: "Total" } ]
19
+ ) %>
20
+ <%= ui_line_chart(
21
+ labels: %w[Jan Feb Mar Apr May Jun],
22
+ series: [ { name: "This year", data: [ 12, 19, 15, 24, 22, 30 ] }, { name: "Last year", data: [ 10, 14, 13, 17, 19, 21 ] } ],
23
+ height: :sm
24
+ ) %>
25
+ <nav class="ks-sidebar" aria-label="Sample navigation">
26
+ <% [ "Dashboard", "Customers", "Reports" ].each_with_index do |label, index| %>
27
+ <a href="#" class="<%= [ Keystone::Ui::NavigationComponent::SIDEBAR_TAB_CLASSES, (Keystone::Ui::NavigationComponent::ACTIVE_CLASS if index.zero?) ].compact.join(" ") %>"><%= label %></a>
28
+ <% end %>
29
+ </nav>
30
+ </div>
31
+ <% end %>
32
+ </div>
33
+ <% end %>
34
+ </div>
@@ -1,17 +1,61 @@
1
- <%= ui_disclosure do |disclosure| %>
2
- <% disclosure.with_summary { "Change each theme setting" } %>
3
- <div style="display: flex; flex-wrap: wrap; gap: 1rem;">
4
- <% KeystoneUi::Preferences::ThemeRoles::ROLES.each do |name, role| %>
5
- <% (role[:kind] == :colour ? %w[light dark] : %w[light]).each do |side| %>
6
- <% field_name = "theme[#{name}][#{side}]" %>
7
- <% value = theme.dig(name, side).to_s %>
8
- <% label = role[:kind] == :colour ? "--#{name}, #{side}" : "--#{name}" %>
9
- <% if %i[colour shadow_colour].include?(role[:kind]) %>
10
- <%= ui_color_picker(name: field_name, value: value, label: label) %>
11
- <% else %>
12
- <%= ui_form_field(attribute: field_name, label: label, value: value) %>
13
- <% end %>
1
+ <% saved_preset = KeystoneUi::Looks::Themes.all.find { |name, _preset| name.to_s == saved["preset_name"] }&.last %>
2
+ <% theme = saved_preset ? saved_preset[:theme] : saved["theme"].to_h %>
3
+ <% shown = local_assigns[:sent_theme] || theme %>
4
+ <% check = KeystoneUi::Preferences::CheckedTheme.new(local_assigns[:sent_paste].present? ? {} : local_assigns[:sent_theme].to_h) %>
5
+ <% id_prefix = local_assigns.fetch(:id_prefix, "appearance") %>
6
+ <% fonts_id = "#{id_prefix}-theme-fonts" %>
7
+ <% roles = KeystoneUi::Preferences::ThemeRoles::ROLES.to_h { |name, role| [ name, role.merge(hover: KeystoneUi::Preferences::ThemeCss::HOVERS[name]).compact ] } %>
8
+ <div data-action="change->keystone-ui--preferences--appearance-editor#apply input->keystone-ui--preferences--appearance-editor#apply" data-theme-roles="<%= roles.to_json %>" data-saved-theme="<%= theme.to_json %>" style="display: flex; flex-direction: column; gap: 2rem;">
9
+ <% KeystoneUi::Preferences::ThemeRoles::GROUPS.each do |group, sets| %>
10
+ <%= ui_disclosure(open: sets.values.flatten.include?(check.failing_setting)) do |disclosure| %>
11
+ <% disclosure.with_summary do %><h2 class="<%= Keystone::Ui::SectionComponent::TITLE_CLASSES %>"><%= group %></h2><% end %>
12
+ <div style="display: flex; flex-direction: column; gap: 1.5rem; padding-top: 1rem;">
13
+ <% sets.each do |set, names| %>
14
+ <fieldset style="border: 0; padding: 0; margin: 0; min-width: 0;">
15
+ <legend class="<%= Keystone::Ui::FormFieldComponent::LABEL_CLASSES %>"><%= set %></legend>
16
+ <div style="display: flex; flex-wrap: wrap; gap: 1rem;">
17
+ <% names.each do |name| %>
18
+ <% role = KeystoneUi::Preferences::ThemeRoles::ROLES.fetch(name) %>
19
+ <% if role[:kind] == :colour %>
20
+ <% name_id = "#{id_prefix}-theme-#{name}-name" %>
21
+ <div role="group" aria-labelledby="<%= name_id %>" style="display: flex; flex-direction: column; gap: 0.5rem;">
22
+ <span id="<%= name_id %>" class="<%= Keystone::Ui::FormFieldComponent::LABEL_CLASSES %>">--<%= name %></span>
23
+ <div style="display: flex; flex-wrap: wrap; gap: 1rem;">
24
+ <% end %>
25
+ <% (role[:kind] == :colour ? %w[light dark] : %w[light]).each do |side| %>
26
+ <% field_name = "theme[#{name}][#{side}]" %>
27
+ <% value = shown.dig(name, side).to_s %>
28
+ <% label = role[:kind] == :colour ? side.capitalize : "--#{name}" %>
29
+ <% if %i[colour shadow_colour].include?(role[:kind]) && (value.empty? || value.match?(KeystoneUi::Preferences::CheckedTheme::COLOUR)) %>
30
+ <%= ui_color_picker(name: field_name, value: value, label: label) %>
31
+ <% else %>
32
+ <% field_id = "#{id_prefix}-#{field_name.parameterize}" %>
33
+ <div class="<%= Keystone::Ui::FormFieldComponent::WRAPPER_CLASSES %>">
34
+ <%= label_tag field_id, label, class: Keystone::Ui::FormFieldComponent::LABEL_CLASSES %>
35
+ <%= text_field_tag field_name, value, id: field_id, class: Keystone::Ui::InputComponent::BASE_CLASSES, list: (fonts_id if role[:kind] == :font_list) %>
36
+ </div>
37
+ <% end %>
38
+ <% end %>
39
+ <% if role[:kind] == :colour %></div><% end %>
40
+ <% if name == check.failing_setting %>
41
+ <p class="<%= Keystone::Ui::FormFieldComponent::ERROR_CLASSES %>"><%= check.refusal.message %></p>
42
+ <% end %>
43
+ <% if role[:kind] == :colour %></div><% end %>
44
+ <% end %>
45
+ </div>
46
+ </fieldset>
14
47
  <% end %>
48
+ </div>
15
49
  <% end %>
50
+ <% end %>
51
+ <div class="<%= Keystone::Ui::FormFieldComponent::WRAPPER_CLASSES %>">
52
+ <%= label_tag "#{id_prefix}-theme-export", "Export", class: Keystone::Ui::FormFieldComponent::LABEL_CLASSES %>
53
+ <%= content_tag :textarea, nil, id: "#{id_prefix}-theme-export", readonly: true, rows: 8, class: Keystone::Ui::TextareaComponent::BASE_CLASSES, data: { theme_export: "" } %>
54
+ <div><%= ui_copy_button(text: "", error_message: "Select the text to copy it") %></div>
16
55
  </div>
17
- <% end %>
56
+ <datalist id="<%= fonts_id %>">
57
+ <% (KeystoneUi::Preferences.configuration.theme_fonts + KeystoneUi::Preferences::CheckedTheme::SYSTEM_FONTS).each do |font| %>
58
+ <option value="<%= font %>"></option>
59
+ <% end %>
60
+ </datalist>
61
+ </div>
@@ -11,7 +11,7 @@ module KeystoneUi
11
11
 
12
12
  source_root File.expand_path("templates", __dir__)
13
13
 
14
- desc "Installs KeystoneUi::Preferences: copies its migration and its template picker script."
14
+ desc "Installs KeystoneUi::Preferences: copies its migration and its appearance editor script."
15
15
 
16
16
  def copy_migration
17
17
  migration_template(
@@ -20,9 +20,9 @@ module KeystoneUi
20
20
  )
21
21
  end
22
22
 
23
- def copy_template_picker_script
24
- script = File.expand_path("../../../../../app/javascript/keystone_ui/preferences/theme_settings_controller.js", __dir__)
25
- create_file "app/javascript/controllers/keystone_ui/preferences/theme_settings_controller.js", File.read(script)
23
+ def copy_appearance_editor_script
24
+ script = File.expand_path("../../../../../app/javascript/keystone_ui/preferences/appearance_editor_controller.js", __dir__)
25
+ create_file "app/javascript/controllers/keystone_ui/preferences/appearance_editor_controller.js", File.read(script)
26
26
  end
27
27
  end
28
28
  end
@@ -11,7 +11,7 @@ module KeystoneUi
11
11
 
12
12
  source_root File.expand_path("templates", __dir__)
13
13
 
14
- desc "Updates KeystoneUi::Preferences: copies migrations added since it was installed and the template picker script when the app has none."
14
+ desc "Updates KeystoneUi::Preferences: copies migrations added since it was installed and the appearance editor script when the app has none."
15
15
 
16
16
  def copy_remove_extra_owner_index_migration
17
17
  migration_template(
@@ -27,11 +27,11 @@ module KeystoneUi
27
27
  )
28
28
  end
29
29
 
30
- def copy_template_picker_script
31
- copy = "app/javascript/controllers/keystone_ui/preferences/theme_settings_controller.js"
30
+ def copy_appearance_editor_script
31
+ copy = "app/javascript/controllers/keystone_ui/preferences/appearance_editor_controller.js"
32
32
  return if File.exist?(File.join(destination_root, copy))
33
33
 
34
- create_file copy, File.read(File.expand_path("../../../../../app/javascript/keystone_ui/preferences/theme_settings_controller.js", __dir__))
34
+ create_file copy, File.read(File.expand_path("../../../../../app/javascript/keystone_ui/preferences/appearance_editor_controller.js", __dir__))
35
35
  end
36
36
  end
37
37
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module KeystoneUi
4
4
  module Preferences
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keystone_ui-preferences
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.43.0
18
+ version: 0.47.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.43.0
25
+ version: 0.47.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: keystone_ui-looks
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: 0.18.0
46
+ version: 0.23.0
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: 0.18.0
53
+ version: 0.23.0
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: railties
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -107,7 +107,7 @@ files:
107
107
  - app/controllers/concerns/keystone_ui/preferences/component_preferences.rb
108
108
  - app/controllers/keystone_ui/preferences/application_controller.rb
109
109
  - app/controllers/keystone_ui/preferences/component_preferences_controller.rb
110
- - app/javascript/keystone_ui/preferences/theme_settings_controller.js
110
+ - app/javascript/keystone_ui/preferences/appearance_editor_controller.js
111
111
  - app/models/keystone_ui/preferences/appearance_choice.rb
112
112
  - app/models/keystone_ui/preferences/application_record.rb
113
113
  - app/models/keystone_ui/preferences/checked_theme.rb
@@ -130,9 +130,11 @@ files:
130
130
  - app/views/keystone_ui/preferences/settings/_account_layouts.html.erb
131
131
  - app/views/keystone_ui/preferences/settings/_appearance.html.erb
132
132
  - app/views/keystone_ui/preferences/settings/_colour_choices.html.erb
133
+ - app/views/keystone_ui/preferences/settings/_editor_layout.html.erb
133
134
  - app/views/keystone_ui/preferences/settings/_look_choices.html.erb
134
135
  - app/views/keystone_ui/preferences/settings/_navigation_order.html.erb
135
136
  - app/views/keystone_ui/preferences/settings/_navigation_placement.html.erb
137
+ - app/views/keystone_ui/preferences/settings/_theme_preview.html.erb
136
138
  - app/views/keystone_ui/preferences/settings/_theme_settings.html.erb
137
139
  - config/routes.rb
138
140
  - lib/generators/keystone_ui/preferences/install/install_generator.rb
@@ -1,84 +0,0 @@
1
- import { Controller } from "@hotwired/stimulus"
2
-
3
- export default class extends Controller {
4
- static targets = ["accentPicker", "surfacePicker", "textPicker", "customRadio", "templateLabel"]
5
-
6
- connect() {
7
- this.colorPickerTargets().forEach(picker => picker.addEventListener("change", () => this.customColorChanged()))
8
- }
9
-
10
- colorPickerTargets() {
11
- return [
12
- this.hasAccentPickerTarget && this.accentPickerTarget,
13
- this.hasSurfacePickerTarget && this.surfacePickerTarget,
14
- this.hasTextPickerTarget && this.textPickerTarget
15
- ].filter(Boolean)
16
- }
17
-
18
- selectTemplate(event) {
19
- const input = event.currentTarget
20
- const label = input.closest("[data-template]")
21
- const accentHex = input.dataset.accentHex
22
- const surfaceHex = input.dataset.surfaceHex
23
- const textHex = input.dataset.textHex
24
-
25
- this.setPickerValue(this.accentPickerTarget, accentHex)
26
- this.setPickerValue(this.surfacePickerTarget, surfaceHex)
27
- this.setPickerValue(this.textPickerTarget, textHex)
28
-
29
- this.highlightTemplate(label.dataset.template)
30
- }
31
-
32
- selectPreset(event) {
33
- const input = event.currentTarget
34
- const theme = JSON.parse(input.dataset.theme)
35
-
36
- Object.entries(theme).forEach(([role, sides]) => {
37
- Object.entries(sides).forEach(([side, value]) => this.setThemeField(`theme[${role}][${side}]`, value))
38
- })
39
-
40
- this.highlightTemplate(input.closest("[data-template]").dataset.template)
41
- }
42
-
43
- setThemeField(name, value) {
44
- const field = this.element.querySelector(`[name="${name}"]`)
45
- if (!field) return
46
-
47
- const picker = field.closest("[data-controller~='color-picker']")
48
- if (picker) {
49
- this.setPickerValue(picker, value)
50
- } else {
51
- field.value = value
52
- }
53
- }
54
-
55
- customColorChanged() {
56
- if (this.hasCustomRadioTarget) this.customRadioTarget.checked = true
57
- this.highlightTemplate("custom")
58
- }
59
-
60
- highlightTemplate(selected) {
61
- this.templateLabelTargets.forEach(label => {
62
- const border = label.querySelector("span")
63
- if (label.dataset.template === selected) {
64
- border.classList.remove("border-transparent")
65
- border.classList.add("border-accent-500")
66
- } else {
67
- border.classList.remove("border-accent-500")
68
- border.classList.add("border-transparent")
69
- }
70
- })
71
- }
72
-
73
- setPickerValue(wrapper, hex) {
74
- const input = wrapper.querySelector("input[type='hidden']")
75
- const swatch = wrapper.querySelector("[data-color-picker-target='swatch']")
76
- const hexLabel = wrapper.querySelector("[data-color-picker-target='hexLabel']")
77
- const text = wrapper.querySelector("[data-color-picker-target='text']")
78
-
79
- if (input) input.value = hex
80
- if (swatch) swatch.style.backgroundColor = hex
81
- if (hexLabel) hexLabel.textContent = hex
82
- if (text) text.value = hex
83
- }
84
- }