satis 2.3.2 → 2.3.4

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: 19da9e46f948a004f97fccfcd36011958214e1ac1e201bb2d5e9e321d10b00c4
4
- data.tar.gz: 06642dbfe953bca966068d4c069dac4ed9192493c0cc5b720eaec151d397a4b9
3
+ metadata.gz: 1147f697a21c86c3c7be91cea47d00ad1268982caab19980237cb3307d93942b
4
+ data.tar.gz: aa11863e3b931c90131628e0759aad0fd02babcd4a624b5dcc70f3901dacc55a
5
5
  SHA512:
6
- metadata.gz: 76587fce50793eae7a826212ee649f1ba08f000534e596aad4ca81462c0e2f5b091cb18c66b202e686e6d4a00bf471e9eae7ca270e9d2167f4deed3b0dea2480
7
- data.tar.gz: '09748b7347ff26f83307e18cf2c317303357325e2aa73c8c3b3874236e8755fb8ab0e7c736b607104fe21430580407f9914a1168a1b63235114f0789cd793d1d'
6
+ metadata.gz: 2a4916e01ff498ca219949cc08fd1368aeb2b634aae0f118c2a8c1076d7c982a623e5d018c1ab6612fb12d97df346e27965cd53feb94262e2650dd7d0efbf522
7
+ data.tar.gz: 6eb0f7f75857a5255899dbb91055f41bef75dbc1dcfd5aed71dd67e485e3c98ee77dae9860bfb7a1b0f1d31b75d5c96b1747541ab87c22a3f88ae3c7df1b8af6
@@ -5,6 +5,14 @@
5
5
  @apply px-4 py-5 sm:px-6 dark:bg-gray-900 bg-white dark:border-gray-700 dark:text-gray-300;
6
6
  }
7
7
 
8
+ &__header--compact {
9
+ @apply px-3 py-3 sm:px-4;
10
+ }
11
+
12
+ &[data-satis-card-collapsible-value="true"] .sts-card__header {
13
+ @apply cursor-pointer select-none;
14
+ }
15
+
8
16
  &__tabs {
9
17
  @apply bg-white px-4 border-b border-gray-200 sm:px-5 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-gray-300;
10
18
 
@@ -1,38 +1,45 @@
1
- .sts-card data-controller="satis-tabs" data-satis-tabs-persist-value=persist data-satis-tabs-key-value=key id=identifier
1
+ .sts-card data-controller="satis-card satis-tabs" data-satis-tabs-persist-value=persist data-satis-tabs-key-value=key data-satis-card-collapsible-value=collapsible data-satis-card-collapsed-value=collapsed data-satis-card-identifier-value=identifier id=identifier
2
2
  - if header?
3
- .sts-card__header class="#{tabs? ? '' : 'border-b border-gray-200'}"
3
+ .sts-card__header class=header_classes data-action="#{collapsible ? 'click->satis-card#toggle' : ''}"
4
4
  .-ml-4.-mt-4.flex.justify-between.items-center.flex-wrap.sm:flex-nowrap
5
5
  - if icon
6
6
  .ml-4.mt-4.flex-shrink-0.text-primary-600.dark:text-gray-300
7
7
  i class=icon
8
8
  .ml-4.mt-4.flex-1
9
- h3.text-lg.leading-6.font-medium.text-gray-900.dark:text-white
9
+ h3 class=title_classes
10
10
  = title
11
11
  - if description.present?
12
12
  p.mt-1.text-sm.text-gray-500.dark:text-gray-500
13
13
  = description
14
14
 
15
15
  - if actions.present? || initial_actions.present?
16
- .ml-4.mt-4.flex-shrink-0
16
+ .ml-4.mt-4.flex-shrink-0 data-satis-card-target="actions"
17
17
  .grid.grid-flow-row.gap-1.sm:grid-flow-col
18
18
  - initial_actions.each do |action|
19
19
  = action
20
20
  - actions.each do |action|
21
21
  = action
22
22
 
23
+ - if collapsible
24
+ .ml-4.mt-4.flex-shrink-0
25
+ button.text-gray-400.hover:text-gray-600.dark:hover:text-gray-200.transition-transform.duration-200 type="button" data-satis-card-target="collapseIcon"
26
+ i.fas.fa-chevron-down
27
+
23
28
  - if menu
24
29
  .ml-4.mt-2.flex-shrink-0
25
30
  = render(Satis::Menu::Component.new(menu))
26
31
 
27
- - if tabs?
28
- = render Satis::Tabs::Component.new(custom_link: custom_tabs_link).tap {|c| c.original_view_context = view_context } do |t|
29
- - tabs.each_with_index do |ta, i|
30
- - t.with_tab ta.name, id: ta.id, icon: ta.icon, padding: ta.padding, badge: ta.badge, menu: ta.menu, dirty: ta.dirty, title: ta.title, responsive: ta.responsive, selected_tab_index: ta.selected_tab_index do
31
- = ta
32
+ div data-satis-card-target="body"
33
+ - if tabs?
34
+ = render Satis::Tabs::Component.new(custom_link: custom_tabs_link).tap {|c| c.original_view_context = view_context } do |t|
35
+ - tabs.each_with_index do |ta, i|
36
+ - t.with_tab ta.name, id: ta.id, icon: ta.icon, padding: ta.padding, badge: ta.badge, menu: ta.menu, dirty: ta.dirty, title: ta.title, responsive: ta.responsive, selected_tab_index: ta.selected_tab_index do
37
+ = ta
32
38
 
33
- - else
34
- div class="#{content_padding ? 'px-6 py-6' : ''}"
35
- = content
39
+ - else
40
+ div class=content_classes style=content_style
41
+ = content
36
42
 
37
43
  - if footer
38
- = footer
44
+ div data-satis-card-target="footer"
45
+ = footer
@@ -7,7 +7,8 @@ module Satis
7
7
  renders_many :tabs, Tab::Component
8
8
  renders_one :footer
9
9
 
10
- attr_reader :identifier, :icon, :description, :menu, :content_padding, :header_background_color, :initial_actions, :persist, :key
10
+ attr_reader :identifier, :icon, :description, :menu, :content_padding, :header_background_color, :initial_actions, :persist, :key,
11
+ :collapsible, :collapsed, :height, :min_height, :max_height, :padding, :compact
11
12
  attr_writer :scope
12
13
 
13
14
  def initialize(identifier = nil,
@@ -23,7 +24,14 @@ module Satis
23
24
  scope: [],
24
25
  actions: [],
25
26
  key: nil,
26
- persist: true)
27
+ persist: true,
28
+ collapsible: false,
29
+ collapsed: false,
30
+ height: nil,
31
+ min_height: nil,
32
+ max_height: nil,
33
+ padding: nil,
34
+ compact: false)
27
35
  super
28
36
 
29
37
  if identifier.blank?
@@ -43,6 +51,13 @@ module Satis
43
51
  @key = key
44
52
  @custom_tabs_link = custom_tabs_link
45
53
  @scope = scope.present? ? scope : identifier
54
+ @collapsible = collapsible
55
+ @collapsed = collapsed
56
+ @height = height
57
+ @min_height = min_height
58
+ @max_height = max_height
59
+ @padding = padding
60
+ @compact = compact
46
61
  end
47
62
 
48
63
  # def key
@@ -70,6 +85,38 @@ module Satis
70
85
  def header?
71
86
  icon.present? || title.present? || description.present? || menu
72
87
  end
88
+
89
+ def content_style
90
+ styles = []
91
+ styles << "height: #{height}" if height
92
+ styles << "min-height: #{min_height}" if min_height
93
+ styles << "max-height: #{max_height}; overflow-y: auto" if max_height
94
+ styles.join('; ')
95
+ end
96
+
97
+ def content_classes
98
+ classes = []
99
+ if padding
100
+ classes << padding
101
+ elsif content_padding
102
+ classes << (compact ? 'px-4 py-3' : 'px-6 py-6')
103
+ end
104
+ classes.join(' ')
105
+ end
106
+
107
+ def header_classes
108
+ base = tabs? ? '' : 'border-b border-gray-200'
109
+ base += ' sts-card__header--compact' if compact
110
+ base
111
+ end
112
+
113
+ def title_classes
114
+ if compact
115
+ 'text-sm leading-5 font-medium text-gray-900 dark:text-white'
116
+ else
117
+ 'text-lg leading-6 font-medium text-gray-900 dark:text-white'
118
+ end
119
+ end
73
120
  end
74
121
  end
75
122
  end
@@ -0,0 +1,78 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["body", "collapseIcon", "actions", "footer"]
5
+ static values = {
6
+ collapsible: { type: Boolean, default: false },
7
+ collapsed: { type: Boolean, default: false },
8
+ identifier: String
9
+ }
10
+
11
+ connect() {
12
+ if (this.collapsibleValue) {
13
+ this._restoreState()
14
+ this._applyState(false)
15
+ }
16
+ }
17
+
18
+ toggle(event) {
19
+ if (!this.collapsibleValue) return
20
+
21
+ // Don't toggle when clicking on actions, buttons, links, or menus inside the header
22
+ if (event.target.closest('a, button:not([data-satis-card-target="collapseIcon"]), .sts-menu, [data-action]')) {
23
+ if (!event.target.closest('[data-satis-card-target="collapseIcon"]')) return
24
+ }
25
+
26
+ this.collapsedValue = !this.collapsedValue
27
+ this._applyState(true)
28
+ this._saveState()
29
+ }
30
+
31
+ _applyState(animate) {
32
+ const collapsed = this.collapsedValue
33
+
34
+ if (this.hasBodyTarget) {
35
+ if (animate) {
36
+ this.bodyTarget.style.transition = "max-height 0.2s ease-in-out, opacity 0.2s ease-in-out"
37
+ }
38
+
39
+ if (collapsed) {
40
+ this.bodyTarget.style.maxHeight = "0px"
41
+ this.bodyTarget.style.opacity = "0"
42
+ this.bodyTarget.style.overflow = "hidden"
43
+ } else {
44
+ this.bodyTarget.style.maxHeight = ""
45
+ this.bodyTarget.style.opacity = ""
46
+ this.bodyTarget.style.overflow = ""
47
+ }
48
+ }
49
+
50
+ if (this.hasFooterTarget) {
51
+ this.footerTarget.style.display = collapsed ? "none" : ""
52
+ }
53
+
54
+ // Actions stay visible when collapsed — they're in the header
55
+ if (this.hasCollapseIconTarget) {
56
+ this.collapseIconTarget.style.transform = collapsed ? "rotate(-90deg)" : "rotate(0deg)"
57
+ }
58
+ }
59
+
60
+ _storageKey() {
61
+ return `satis-card-${this.identifierValue}-collapsed`
62
+ }
63
+
64
+ _saveState() {
65
+ if (this.identifierValue) {
66
+ sessionStorage.setItem(this._storageKey(), this.collapsedValue)
67
+ }
68
+ }
69
+
70
+ _restoreState() {
71
+ if (this.identifierValue) {
72
+ const stored = sessionStorage.getItem(this._storageKey())
73
+ if (stored !== null) {
74
+ this.collapsedValue = stored === "true"
75
+ }
76
+ }
77
+ }
78
+ }
@@ -9,10 +9,10 @@ div.satis-dropdown data-action="keydown->satis-dropdown#dispatch" data-controlle
9
9
  div.hidden.py-1 data-satis-dropdown-target="pills"
10
10
  .flex.flex-col.items-center
11
11
  .w-full.sts-dropdown class=input_class
12
- .h-12.p-1.flex.rounded
12
+ div class=input_wrapper_classes
13
13
  .flex.flex-auto.flex-wrap.sts-dropdown-input
14
14
  / Input where you can search
15
- input.focus:ring-0.border-none.p-1.px-2.appearance-none.w-full.sts-dropdown-input.text-gray-800.dark:text-gray-300 data-action="input->satis-dropdown#search" data-satis-dropdown-target="searchInput" placeholder=placeholder autofocus=options[:autofocus]
15
+ input class=search_input_classes data-action="input->satis-dropdown#search" data-satis-dropdown-target="searchInput" placeholder=placeholder autofocus=options[:autofocus]
16
16
  div
17
17
  / Reset button
18
18
  - unless @reset_button == false
@@ -28,7 +28,7 @@ div.satis-dropdown data-action="keydown->satis-dropdown#dispatch" data-controlle
28
28
  template data-satis-dropdown-target="selectedItemsTemplate"
29
29
 
30
30
  / Container for results
31
- .hidden.container.sts-dropdown-results.shadow.dark:text-gray-300.z-10.rounded.max-h-select.overflow-y-auto.w-full data-satis-dropdown-target="results" data-action="scroll->satis-dropdown#scroll" tabindex="-1"
31
+ div class=results_classes style=results_style data-satis-dropdown-target="results" data-action="scroll->satis-dropdown#scroll" tabindex="-1"
32
32
  .flex.flex-col.w-full data-satis-dropdown-target="items"
33
33
  - options[:collection]&.each do |item|
34
34
  - data_attrs = item.try(:third) ? item.third : {}
@@ -3,7 +3,7 @@
3
3
  module Satis
4
4
  module Dropdown
5
5
  class Component < ViewComponent::Base
6
- attr_reader :url, :form, :attribute, :title, :options
6
+ attr_reader :url, :form, :attribute, :title, :options, :dropdown_max_height, :dropdown_min_height, :dropdown_width, :dropdown_height, :compact
7
7
 
8
8
  def initialize(form:, attribute:, **options, &block)
9
9
  super
@@ -18,6 +18,11 @@ module Satis
18
18
  @needs_exact_match = options[:needs_exact_match]
19
19
  @reset_button = options[:reset_button] || options[:include_blank]
20
20
  @toggle_button = options[:toggle_button] != false
21
+ @dropdown_max_height = options[:max_height]
22
+ @dropdown_min_height = options[:min_height]
23
+ @dropdown_width = options[:width]
24
+ @dropdown_height = options[:height]
25
+ @compact = options[:compact] || false
21
26
 
22
27
  options[:input_html] ||= {}
23
28
 
@@ -113,6 +118,31 @@ module Satis
113
118
  def input_class
114
119
  [@options.fetch(:input_html, {}).fetch(:class, ""), form.has_error?(attribute) ? "is-invalid" : ""].join(" ")
115
120
  end
121
+
122
+ def results_style
123
+ styles = []
124
+ styles << "max-height: #{dropdown_max_height}" if dropdown_max_height
125
+ styles << "min-height: #{dropdown_min_height}" if dropdown_min_height
126
+ styles << "width: #{dropdown_width}" if dropdown_width
127
+ styles << "height: #{dropdown_height}" if dropdown_height
128
+ styles.join('; ')
129
+ end
130
+
131
+ def results_classes
132
+ classes = "hidden container sts-dropdown-results shadow dark:text-gray-300 z-10 rounded overflow-y-auto w-full"
133
+ classes += " max-h-select" unless dropdown_max_height
134
+ classes
135
+ end
136
+
137
+ def input_wrapper_classes
138
+ compact ? 'h-9 p-1 flex rounded' : 'h-12 p-1 flex rounded'
139
+ end
140
+
141
+ def search_input_classes
142
+ base = 'focus:ring-0 border-none p-1 px-2 appearance-none w-full sts-dropdown-input text-gray-800 dark:text-gray-300'
143
+ base += ' text-sm' if compact
144
+ base
145
+ end
116
146
  end
117
147
  end
118
148
  end
@@ -355,6 +355,9 @@ export default class DropdownComponentController extends ApplicationController {
355
355
  }
356
356
 
357
357
  setHiddenSelect() {
358
+ if (this.element.querySelector('select[name*="[TEMPLATE]"]')) {
359
+ return
360
+ }
358
361
  if (this.hiddenSelectTarget.options.length === 0) {
359
362
  this.searchInputTarget.value = ""
360
363
  this.pillsTarget.innerHTML = ""
@@ -542,7 +545,7 @@ export default class DropdownComponentController extends ApplicationController {
542
545
  // auto select if there is only one match and we are not in freetext mode
543
546
  if (!this.freeTextValue) {
544
547
  if (matches.length === 1) {
545
- if (this.filteredSearchQuery.length >= this.minSearchQueryLengthValue &&
548
+ if (this.filteredSearchQuery?.length >= this.minSearchQueryLengthValue &&
546
549
  matches[0].getAttribute("data-satis-dropdown-item-text").toLowerCase().indexOf(this.lastSearch.toLowerCase()) >= 0) {
547
550
  const dataDiv = matches[0].closest('[data-satis-dropdown-target="item"]')
548
551
  this.selectItem(dataDiv)
@@ -601,15 +604,14 @@ export default class DropdownComponentController extends ApplicationController {
601
604
  this.fetchResultsWith(ourUrl).then((itemCount) => {
602
605
  if (this.hasResults) {
603
606
  this.filterResultsChainTo()
604
-
605
- if (!this.resultsShown && !this.chainToValue) {
607
+ if (!this.resultsShown) {
606
608
  this.showResultsList()
607
609
  }
608
610
 
609
611
  // auto select when there is only 1 value
610
- if (this.filteredSearchQuery.length >= this.minSearchQueryLengthValue && this.nrOfItems === 1 && !this.freeTextValue) {
612
+ if (this.filteredSearchQuery?.length >= this.minSearchQueryLengthValue && this.nrOfItems === 1 && !this.freeTextValue) {
611
613
  const dataDiv = this.itemTargets[0].closest('[data-satis-dropdown-target="item"]')
612
- this.selectItem(dataDiv)
614
+ this.selectItem(dataDiv, true)
613
615
  this.setSelectedItem(dataDiv.getAttribute("data-satis-dropdown-item-value"))
614
616
  this.searchQueryValue = ""
615
617
  } else if (this.searchQueryValue?.length > 0) {
@@ -1,3 +1,4 @@
1
+ /* ── Sidebar container ── */
1
2
  .h-screen.flex .sidebar {
2
3
  @apply dark:bg-gray-700 bg-white;
3
4
  width: 260px;
@@ -6,77 +7,44 @@
6
7
  left: 0;
7
8
  margin-top: 4rem;
8
9
  position: absolute;
9
- overflow: visible;
10
- overflow-y: scroll;
10
+ overflow-x: hidden;
11
+ overflow-y: auto;
11
12
  scrollbar-width: none;
12
13
  z-index: 999;
13
- transition: backdrop-filter 0.3s ease-in-out, transform 0.3s ease-in-out;
14
-
15
- }
16
-
17
- .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item{
18
- z-index: 1100;
19
- position: relative;
20
- opacity: 0.99;
21
- margin-left: 10px;
22
- transition: backdrop-filter 0.3s ease-in-out, transform 0.3s ease-in-out;
14
+ transition: width 0.3s ease-in-out;
23
15
  }
24
16
 
25
-
26
17
  .h-screen.flex .sidebar.close {
27
- position: absolute;
28
- overflow: visible;
29
- top: 0;
30
- left: 0;
31
- height: calc(100% - 4rem);
32
- margin-top: 4rem;
33
18
  width: 60px;
34
- z-index: 999;
35
- scrollbar-width: none;
19
+ overflow: visible;
36
20
  }
37
21
 
38
22
  .sidebar::-webkit-scrollbar {
39
23
  display: none;
40
24
  }
41
25
 
26
+ .dark .h-screen.flex .sidebar {
27
+ @apply bg-gray-800;
28
+ }
42
29
 
30
+ /* ── Topbar ── */
43
31
  .h-screen.flex .topbar {
44
32
  @apply bg-white dark:bg-gray-800;
45
33
  height: 4rem;
46
34
  display: flex;
47
35
  max-width: 100%;
48
- left:0;
49
- margin-left: 0;
50
36
  position: relative;
51
- z-index: 10000;
52
- transition: backdrop-filter 0.3s ease-in-out, transform 0.3s ease-in-out;
37
+ z-index: 1000;
53
38
  }
54
39
 
40
+ /* ── Page body ── */
55
41
  .page_body.close {
56
- margin-left: 50px;
57
- max-width: 97%;
58
- }
59
-
60
- .h-screen.flex .sidebar .boxture-logo{
61
- margin-top: 30px;
62
- }
63
-
64
- .sidebar.close .boxture-logo {
65
- opacity: 0;
66
- }
67
-
68
- .h-screen.flex .sidebar.close .icon-link {
69
- margin-top: 0;
70
- transition: 0.3s ease;
71
- transition-delay: 0.1s;
42
+ margin-left: 60px;
43
+ max-width: calc(100% - 60px);
72
44
  }
73
45
 
74
-
75
- .sidebar.close .sts-sidebar-menu-item{
76
- margin-left: 2px;
77
- }
78
-
79
- .sidebar .boxture-logo {
46
+ /* ── Logo ── */
47
+ .h-screen.flex .sidebar .boxture-logo {
80
48
  height: 52px;
81
49
  width: 52px;
82
50
  opacity: 0.5;
@@ -84,66 +52,76 @@
84
52
  border-radius: 16px;
85
53
  font-size: 30px;
86
54
  color: #fff;
87
- margin-top: 80px;
55
+ margin-top: 30px;
88
56
  mix-blend-mode: multiply;
57
+ transition: opacity 0.3s ease-in-out;
89
58
  }
90
59
 
91
60
  .dark .sidebar .boxture-logo {
92
61
  mix-blend-mode: normal;
93
62
  }
94
63
 
95
- .sidebar.close .profile-logo {
96
- display: none;
64
+ .sidebar.close .boxture-logo {
65
+ opacity: 0;
66
+ pointer-events: none;
97
67
  }
98
68
 
99
- .sidebar .arrow:hover {
100
- color: #555555;
101
- cursor: pointer;
69
+ .sidebar .logo_name {
70
+ font-size: 10px;
71
+ opacity: 0.5;
72
+ text-align: center;
73
+ display: block;
102
74
  }
103
75
 
104
- .dark .sidebar .arrow:hover {
105
- color: #c0c0c0;
76
+ .sidebar.close .logo_name {
77
+ display: none;
106
78
  }
107
79
 
108
- .dark .sidebar .arrow {
109
- color: #ffffff;
110
- opacity: 1;
111
- margin-top: 20px;
80
+ .dark .sidebar .logo_name {
81
+ color: #edecec;
82
+ }
83
+
84
+ .sidebar.close .profile-logo {
85
+ display: none;
112
86
  }
113
87
 
88
+ /* ── Collapse arrow ── */
114
89
  .h-screen.flex .sidebar .arrow {
115
90
  margin-top: 20px;
116
91
  font-size: 20px;
117
92
  opacity: 0.5;
118
- left: 0;
119
93
  margin-left: 10px;
120
94
  margin-right: 10px;
121
95
  transform: scaleX(-1);
122
- transition: backdrop-filter 0.3s ease-in-out, transform 0.3s ease-in-out;
96
+ transition: transform 0.3s ease-in-out;
123
97
  }
124
98
 
125
- .h-screen.flex .sidebar.close .arrow{
99
+ .h-screen.flex .sidebar.close .arrow {
126
100
  margin-left: 15px;
127
- left: 0;
128
101
  transform: scaleX(1);
129
102
  }
130
103
 
131
- .sidebar .logo_name {
132
- font-size: 10px;
133
- opacity: 0.5;
134
- text-align: center;
135
- display: block;
104
+ .sidebar .arrow:hover {
105
+ color: #555555;
106
+ cursor: pointer;
136
107
  }
137
108
 
138
- .sidebar.close .logo_name {
139
- display: none;
109
+ .dark .sidebar .arrow {
110
+ color: #ffffff;
111
+ opacity: 1;
112
+ margin-top: 20px;
140
113
  }
141
114
 
142
- .dark .h-screen.flex .sidebar {
143
- @apply bg-gray-800;
115
+ .dark .sidebar .arrow:hover {
116
+ color: #c0c0c0;
144
117
  }
145
118
 
146
- .dark .sidebar .logo_name {
147
- color: #edecec;
119
+ .h-screen.flex .sidebar.close .icon-link {
120
+ margin-top: 0;
121
+ transition: 0.3s ease;
122
+ transition-delay: 0.1s;
148
123
  }
149
124
 
125
+ .sidebar.close .sts-sidebar-menu-item {
126
+ margin-left: 2px;
127
+ }
@@ -1,5 +1,8 @@
1
+ /* ── Base menu item ── */
1
2
  .sts-sidebar-menu-item {
2
3
  @apply pt-1;
4
+ position: relative;
5
+
3
6
  & a.focus {
4
7
  background: rgba(1, 1, 1, 0.1);
5
8
 
@@ -15,9 +18,8 @@
15
18
  @apply rotate-90;
16
19
  }
17
20
 
18
-
19
21
  &__link {
20
- @apply text-gray-800 dark:text-gray-300 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 w-full flex items-center pl-2 pr-1 py-2 text-left text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500
22
+ @apply text-gray-800 dark:text-gray-300 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 w-full flex items-center pl-2 pr-1 py-2 text-left text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500;
21
23
  }
22
24
 
23
25
  &__icon {
@@ -34,166 +36,143 @@
34
36
  }
35
37
  }
36
38
 
37
-
38
- .sidebar.close .sts-sidebar-menu-item__label {
39
+ /* submenu-label always hidden in expanded sidebar */
40
+ .sidebar .sts-sidebar-menu-item .submenu-label {
39
41
  display: none;
40
42
  }
41
43
 
42
- .sidebar.close .sts-sidebar-menu-item:hover > .sts-sidebar-menu-item__link {
43
- width: 40px;
44
- }
45
-
44
+ /* ===================================
45
+ COLLAPSED SIDEBAR (.sidebar.close)
46
+ =================================== */
46
47
 
47
- .sidebar.close .sts-sidebar-menu-item [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item{
48
- display: none;
49
- visibility: hidden;
50
- opacity: 0;
48
+ /* Center icons in collapsed sidebar */
49
+ .sidebar.close .icon-link > .sts-sidebar-menu-item {
50
+ margin-left: 6px;
51
51
  }
52
52
 
53
- .sidebar.close .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item > [data-satis-sidebar-menu-item-target="submenu"]{
54
- opacity: 0;
53
+ /* Hide labels and chevrons */
54
+ .sidebar.close .sts-sidebar-menu-item__label {
55
55
  display: none;
56
- visibility: hidden;
57
56
  }
58
57
 
59
-
60
- .sidebar.close .sts-sidebar-menu-item .sts-sidebar-menu-item__link .sts-sidebar-menu-item__menu-icon{
58
+ .sidebar.close .sts-sidebar-menu-item__menu-icon {
61
59
  display: none;
62
- visibility: hidden;
63
- opacity: 0;
64
- }
65
-
66
- .sidebar.close .icon-link > .sts-sidebar-menu-item:hover:not(:has([data-satis-sidebar-menu-item-target="submenu"])) .sts-sidebar-menu-item__label {
67
- @apply rounded-md bg-gray-50 dark:bg-gray-900 shadow-md py-2;
68
- text-decoration-thickness: 2px;
69
- display: block;
70
- position: absolute;
71
- padding-right: 20px;
72
- padding-left: 20px;
73
- margin-left: 40px;
74
60
  }
75
61
 
76
- .sidebar.close .icon-link > .sts-sidebar-menu-item:has([data-satis-sidebar-menu-item-target="submenu"]):hover > .sts-sidebar-menu-item__link > .sts-sidebar-menu-item__label {
77
- @apply text-white dark:bg-gray-600;
78
- display: block;
62
+ /* ── Flyout panel (shown via JS .flyout-visible) ── */
63
+ .sidebar.close .sts-sidebar-menu-item > [data-satis-sidebar-menu-item-target="submenu"].flyout-visible {
64
+ @apply bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10;
65
+ display: block !important;
79
66
  position: absolute;
80
- background-color: rgba(0, 0, 0, 1);
81
- padding: 5px 10px;
82
- border-radius: 4px;
67
+ left: 54px;
83
68
  width: 220px;
84
- margin-left: 22px;
85
- margin-bottom: 80px;
69
+ padding: 4px 0;
70
+ z-index: 1100;
71
+ border-radius: 0 0.5rem 0.5rem 0.5rem;
86
72
  }
87
73
 
88
-
89
- .sidebar.close .icon-link > .sts-sidebar-menu-item:has([data-satis-sidebar-menu-item-target="submenu"]):last-child:hover > .sts-sidebar-menu-item__link > .sts-sidebar-menu-item__label {
90
- margin-top: -760px;
91
- margin-left: 20px;
74
+ /* Invisible bridge: prevents hover gap between sidebar icon and flyout */
75
+ .sidebar.close .sts-sidebar-menu-item > [data-satis-sidebar-menu-item-target="submenu"].flyout-visible::before {
76
+ content: '';
77
+ position: absolute;
78
+ top: -10px;
79
+ left: -20px;
80
+ width: 20px;
81
+ bottom: 0;
92
82
  }
93
83
 
94
- .fontawesome-i2svg-active .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] {
95
- @apply rounded-md bg-white dark:bg-gray-900 py-0 px-0 shadow-lg;
96
- display: block;
97
- visibility: visible;
98
- opacity: 1;
84
+ /* Nested flyout panels (level 3+) */
85
+ .sidebar.close [data-satis-sidebar-menu-item-target="submenu"].flyout-visible [data-satis-sidebar-menu-item-target="submenu"].flyout-visible {
86
+ @apply bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10 rounded-lg;
87
+ display: block !important;
99
88
  position: absolute;
100
- margin-left: 30px;
101
- margin-top: -45px;
102
-
89
+ left: 100%;
103
90
  width: 220px;
104
-
91
+ padding: 4px 0;
92
+ z-index: 1100;
105
93
  }
106
94
 
107
- .fontawesome-i2svg-active .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item:only-child {
108
- @apply rounded-md bg-white dark:bg-gray-900 py-0 px-0 shadow-lg;
109
- display: block;
110
- visibility: visible;
111
- opacity: 1;
112
- position: fixed;
113
- width: 220px;
114
- margin-left: 0;
115
-
95
+ /* Nested bridge */
96
+ .sidebar.close [data-satis-sidebar-menu-item-target="submenu"].flyout-visible [data-satis-sidebar-menu-item-target="submenu"].flyout-visible::before {
97
+ content: '';
98
+ position: absolute;
99
+ top: 0;
100
+ left: -8px;
101
+ width: 8px;
102
+ bottom: 0;
116
103
  }
117
104
 
118
- .fontawesome-i2svg-active .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item {
105
+ /* Items inside a visible flyout */
106
+ .sidebar.close [data-satis-sidebar-menu-item-target="submenu"].flyout-visible > .sts-sidebar-menu-item {
119
107
  @apply py-0 px-0;
108
+ display: block;
120
109
  margin-left: 0;
121
110
  }
122
111
 
123
- .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item a.focus{
124
- @apply bg-white dark:bg-gray-900;
112
+ /* Flyout item links: Tailwind UI dropdown style */
113
+ .sidebar.close [data-satis-sidebar-menu-item-target="submenu"].flyout-visible .sts-sidebar-menu-item__link {
114
+ @apply rounded-none px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white;
125
115
  }
126
116
 
127
- .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item a.focus{
128
- @apply bg-white dark:bg-gray-900;
129
- }
130
-
131
- .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item.pl-4 {
132
- @apply py-0 px-0 ;
117
+ /* Show regular labels inside flyout (not submenu-label) */
118
+ .sidebar.close [data-satis-sidebar-menu-item-target="submenu"].flyout-visible .sts-sidebar-menu-item__label:not(.submenu-label) {
133
119
  display: block;
134
- visibility: visible;
135
- opacity: 100;
136
-
137
- }
138
-
139
- .sidebar.close .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item .sts-sidebar-menu-item__label {
140
120
  background: none;
141
- display:block;
142
- overflow: visible;
143
121
  padding: 0;
144
122
  }
145
123
 
146
-
147
- .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item:last-child:hover > [data-satis-sidebar-menu-item-target="submenu"] {
148
- margin-top: -425px;
149
- }
150
-
151
- .h-screen.flex .sidebar.close .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] {
152
- position: fixed;
153
- margin-left: 185px;
154
- margin-top: -80px;
124
+ /* Reset focus style inside flyout */
125
+ .sidebar.close .icon-link .sts-sidebar-menu-item a.focus {
126
+ @apply bg-white dark:bg-gray-800;
155
127
  }
156
128
 
157
-
158
- .sidebar.close .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"]::-webkit-scrollbar {
159
- display: none;
129
+ .sidebar.close [data-satis-sidebar-menu-item-target="submenu"].flyout-visible .sts-sidebar-menu-item a.focus {
130
+ @apply bg-gray-100 dark:bg-gray-700;
160
131
  }
161
132
 
162
- .h-screen.flex .sidebar .icon-link .sts-sidebar-menu-item .submenu-label {
163
- display: none;
133
+ /* ── Tooltip label for items without submenus (shown via JS .tooltip-visible) ── */
134
+ .sidebar.close .sts-sidebar-menu-item__label.tooltip-visible {
135
+ @apply bg-gray-900 dark:bg-gray-700 text-white text-xs font-medium shadow-lg ring-1 ring-black/5 rounded-md;
136
+ display: block !important;
137
+ position: absolute;
138
+ left: 54px;
139
+ padding: 6px 12px;
140
+ white-space: nowrap;
141
+ z-index: 1100;
164
142
  }
165
143
 
166
- .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item .submenu-label {
167
- display: none;
144
+ /* Invisible bridge for tooltip */
145
+ .sidebar.close .sts-sidebar-menu-item__label.tooltip-visible::before {
146
+ content: '';
147
+ position: absolute;
148
+ top: 0;
149
+ left: -20px;
150
+ width: 20px;
151
+ bottom: 0;
168
152
  }
169
153
 
170
- .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item:hover .submenu-label {
171
- @apply text-white dark:bg-gray-600;
172
- display: block;
154
+ /* ── Submenu-label header (shown via JS .tooltip-visible) — flush with flyout ── */
155
+ .sidebar.close .submenu-label.tooltip-visible {
156
+ @apply bg-gray-900 dark:bg-gray-700 text-white text-xs font-semibold;
157
+ display: block !important;
173
158
  position: absolute;
174
- background-color: rgba(0, 0, 0, 1);
175
- padding: 5px 10px;
176
- border-radius: 4px;
177
- z-index: 10;
159
+ left: 54px;
160
+ padding: 6px 12px;
161
+ white-space: nowrap;
162
+ z-index: 1100;
178
163
  width: 220px;
179
- margin-left: 177px;
180
- margin-top: 0;
181
- margin-bottom: 150px;
164
+ border-radius: 0.5rem 0.5rem 0 0;
182
165
  }
183
166
 
184
- .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item .submenu-label {
185
- display: none;
167
+ /* Nested submenu-label tooltips position off the flyout edge */
168
+ .sidebar.close [data-satis-sidebar-menu-item-target="submenu"].flyout-visible .submenu-label.tooltip-visible {
169
+ @apply rounded-lg;
170
+ left: 100%;
171
+ width: max-content;
172
+ max-width: 220px;
186
173
  }
187
174
 
188
- .fontawesome-i2svg-active .h-screen.flex .sidebar.close .icon-link .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item:hover > [data-satis-sidebar-menu-item-target="submenu"] .sts-sidebar-menu-item:hover .submenu-label {
189
- @apply text-white dark:bg-gray-600;
190
- display: block;
191
- position: absolute;
192
- background-color: rgba(0, 0, 0, 1);
193
- padding: 5px 10px;
194
- border-radius: 4px;
195
- z-index: 10;
196
- width: 220px;
197
- margin-left: 177px;
198
- margin-bottom: 150px;
175
+ /* Nested tooltip labels shouldn't show (already visible in flyout) */
176
+ .sidebar.close [data-satis-sidebar-menu-item-target="submenu"].flyout-visible .sts-sidebar-menu-item__label.tooltip-visible {
177
+ display: none !important;
199
178
  }
@@ -15,18 +15,131 @@ export default class SidebarMenuItemComponentController extends ApplicationContr
15
15
 
16
16
  this.boundUpdateFocus = this.updateFocus.bind(this)
17
17
  this.boundOpenListener = this.openListener.bind(this)
18
+ this.boundShowFlyout = this.showFlyout.bind(this)
19
+ this.boundHideFlyout = this.hideFlyout.bind(this)
20
+ this.hideTimer = null
18
21
 
19
22
  this.updateFocus(true)
20
23
  this.element.addEventListener('sts-sidebar-menu-item:open', this.boundOpenListener)
24
+ this.element.addEventListener('mouseenter', this.boundShowFlyout)
25
+ this.element.addEventListener('mouseleave', this.boundHideFlyout)
21
26
  window.addEventListener('popstate', debounce(this.boundUpdateFocus, 200))
22
27
  }
23
28
 
24
29
  disconnect() {
25
30
  super.disconnect()
31
+ clearTimeout(this.hideTimer)
26
32
  this.element.removeEventListener('sts-sidebar-menu-item:open', this.boundOpenListener)
33
+ this.element.removeEventListener('mouseenter', this.boundShowFlyout)
34
+ this.element.removeEventListener('mouseleave', this.boundHideFlyout)
27
35
  window.removeEventListener('popstate', debounce(this.boundUpdateFocus, 200))
28
36
  }
29
37
 
38
+ // ── Collapsed sidebar flyout logic ──
39
+
40
+ get isSidebarClosed() {
41
+ const sidebar = this.element.closest('.sidebar')
42
+ return sidebar?.classList.contains('close')
43
+ }
44
+
45
+ get isInsideFlyout() {
46
+ return !!this.element.closest('[data-satis-sidebar-menu-item-target="submenu"].flyout-visible')
47
+ }
48
+
49
+ showFlyout() {
50
+ if (!this.isSidebarClosed) return
51
+
52
+ // Cancel any pending hide
53
+ clearTimeout(this.hideTimer)
54
+
55
+ if (this.hasSubmenuTarget) {
56
+ const submenuLabel = this.element.querySelector(':scope > .sts-sidebar-menu-item__link > .submenu-label')
57
+
58
+ this.submenuTarget.classList.add('flyout-visible')
59
+ if (submenuLabel) submenuLabel.classList.add('tooltip-visible')
60
+
61
+ this.positionFlyout(this.submenuTarget, submenuLabel)
62
+ } else if (!this.isInsideFlyout) {
63
+ const label = this.element.querySelector(':scope > .sts-sidebar-menu-item__link > .sts-sidebar-menu-item__label:not(.submenu-label)')
64
+ if (label) {
65
+ label.classList.add('tooltip-visible')
66
+ this.positionWithinViewport(label)
67
+ }
68
+ }
69
+ }
70
+
71
+ hideFlyout() {
72
+ if (!this.isSidebarClosed) return
73
+
74
+ // Delay hiding so the user can move to the flyout without it disappearing
75
+ this.hideTimer = setTimeout(() => {
76
+ if (this.hasSubmenuTarget) {
77
+ this.submenuTarget.classList.remove('flyout-visible')
78
+ this.submenuTarget.style.top = ''
79
+ }
80
+
81
+ this.element.querySelectorAll('.tooltip-visible').forEach(el => {
82
+ el.classList.remove('tooltip-visible')
83
+ el.style.top = ''
84
+ })
85
+ }, 150)
86
+ }
87
+
88
+ positionFlyout(flyout, label) {
89
+ requestAnimationFrame(() => {
90
+ const flyoutRect = flyout.getBoundingClientRect()
91
+ if (flyoutRect.height === 0) return
92
+
93
+ const viewportHeight = window.innerHeight
94
+ const margin = 8
95
+ const labelHeight = label ? label.getBoundingClientRect().height : 0
96
+
97
+ let flyoutTop = parseFloat(getComputedStyle(flyout).top) || 0
98
+
99
+ // Check if flyout overflows bottom
100
+ if (flyoutRect.bottom > viewportHeight - margin) {
101
+ const overflow = flyoutRect.bottom - viewportHeight + margin
102
+ flyoutTop -= overflow
103
+ }
104
+
105
+ // Check if label above flyout would overflow top
106
+ const parentRect = flyout.offsetParent?.getBoundingClientRect()
107
+ const parentTop = parentRect?.top || 0
108
+ if (parentTop + flyoutTop - labelHeight < margin) {
109
+ flyoutTop = margin - parentTop + labelHeight
110
+ }
111
+
112
+ flyout.style.top = `${flyoutTop}px`
113
+
114
+ // Position label flush against the top of the flyout panel (no gap)
115
+ if (label) {
116
+ label.style.top = `${flyoutTop - labelHeight}px`
117
+ }
118
+ })
119
+ }
120
+
121
+ positionWithinViewport(el) {
122
+ requestAnimationFrame(() => {
123
+ const rect = el.getBoundingClientRect()
124
+ if (rect.height === 0) return
125
+
126
+ const viewportHeight = window.innerHeight
127
+ const margin = 8
128
+
129
+ if (rect.bottom > viewportHeight - margin) {
130
+ const overflow = rect.bottom - viewportHeight + margin
131
+ const currentTop = parseFloat(getComputedStyle(el).top) || 0
132
+ el.style.top = `${currentTop - overflow}px`
133
+ }
134
+
135
+ if (rect.top < margin) {
136
+ el.style.top = `${margin - el.parentElement.getBoundingClientRect().top}px`
137
+ }
138
+ })
139
+ }
140
+
141
+ // ── Submenu expand/collapse (expanded sidebar) ──
142
+
30
143
  open(event) {
31
144
  if (this.hasSubmenuTarget) {
32
145
  const sidebar = this.element.closest('.sidebar')
@@ -43,8 +156,6 @@ export default class SidebarMenuItemComponentController extends ApplicationContr
43
156
  event.preventDefault()
44
157
  }
45
158
  }
46
- // This breaks turbo, so we need to keep the propagation.
47
- // event.stopPropagation();
48
159
  }
49
160
 
50
161
  openListener(event) {
@@ -54,7 +165,6 @@ export default class SidebarMenuItemComponentController extends ApplicationContr
54
165
  }
55
166
  }
56
167
 
57
- // This method is used to show the submenu
58
168
  showSubmenu() {
59
169
  if (!this.hasSubmenuTarget || this.isSubmenuVisible) return
60
170
 
@@ -62,7 +172,6 @@ export default class SidebarMenuItemComponentController extends ApplicationContr
62
172
  this.element.classList.toggle("active", true)
63
173
  }
64
174
 
65
- // This method is used to hide the submenu
66
175
  hideSubmenu() {
67
176
  if (!this.hasSubmenuTarget || !this.isSubmenuVisible) return
68
177
 
@@ -110,12 +219,7 @@ export default class SidebarMenuItemComponentController extends ApplicationContr
110
219
  return this.openSubmenus.length > 0
111
220
  }
112
221
 
113
- /**
114
- * Get a list of all open submenus
115
- * @returns {NodeListOf<Element>}
116
- */
117
222
  get openSubmenus() {
118
- // scope to first match. check if there are any submenus that are not hidden
119
223
  return this.element.querySelectorAll('[data-satis-sidebar-menu-item-target="submenu"]:not([class*="hidden"])')
120
224
  }
121
225
 
@@ -132,7 +132,7 @@ export default class FieldsForController extends ApplicationController {
132
132
  }
133
133
 
134
134
  monitorChanges(event) {
135
- if (event?.detail?.src == "satis-dropdown") {
135
+ if (event?.detail?.src == "satis-dropdown" || event?.detail?.src == "prepopulate" ) {
136
136
  // Skip events caused by the initial load of a satis-dropdown
137
137
  return
138
138
  }
@@ -6,6 +6,9 @@ application.register("satis-appearance-switcher", AppearanceSwitcherComponentCon
6
6
  import DateTimePickerComponentController from "satis/components/date_time_picker/component_controller";
7
7
  application.register("satis-date-time-picker", DateTimePickerComponentController);
8
8
 
9
+ import CardComponentController from "satis/components/card/component_controller";
10
+ application.register("satis-card", CardComponentController);
11
+
9
12
  import DropdownComponentController from "satis/components/dropdown/component_controller";
10
13
  application.register("satis-dropdown", DropdownComponentController);
11
14
 
data/lib/satis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Satis
2
- VERSION = "2.3.2"
2
+ VERSION = "2.3.4"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: satis
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.2
4
+ version: 2.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom de Grunt
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-10 00:00:00.000000000 Z
11
+ date: 2026-05-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: browser
@@ -766,6 +766,7 @@ files:
766
766
  - app/components/satis/card/component.css
767
767
  - app/components/satis/card/component.html.slim
768
768
  - app/components/satis/card/component.rb
769
+ - app/components/satis/card/component_controller.js
769
770
  - app/components/satis/color_picker/component.css
770
771
  - app/components/satis/color_picker/component.rb
771
772
  - app/components/satis/color_picker/component.slim