phlex_kit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +16 -0
  3. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  4. data/app/components/phlex_kit/alert/alert.css +60 -22
  5. data/app/components/phlex_kit/alert/alert.rb +11 -7
  6. data/app/components/phlex_kit/alert/alert_action.rb +14 -0
  7. data/app/components/phlex_kit/alert/alert_title.rb +3 -2
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +5 -2
  9. data/app/components/phlex_kit/avatar/avatar.css +80 -9
  10. data/app/components/phlex_kit/avatar/avatar.rb +6 -4
  11. data/app/components/phlex_kit/avatar/avatar_badge.rb +15 -0
  12. data/app/components/phlex_kit/avatar/avatar_group_count.rb +14 -0
  13. data/app/components/phlex_kit/badge/badge.css +61 -39
  14. data/app/components/phlex_kit/badge/badge.rb +24 -7
  15. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +2 -2
  16. data/app/components/phlex_kit/bubble/bubble.css +49 -8
  17. data/app/components/phlex_kit/button/button.css +71 -26
  18. data/app/components/phlex_kit/button/button.rb +20 -13
  19. data/app/components/phlex_kit/button_group/button_group.css +59 -8
  20. data/app/components/phlex_kit/button_group/button_group.rb +11 -5
  21. data/app/components/phlex_kit/button_group/button_group_separator.rb +14 -0
  22. data/app/components/phlex_kit/button_group/button_group_text.rb +14 -0
  23. data/app/components/phlex_kit/calendar/calendar.css +66 -15
  24. data/app/components/phlex_kit/calendar/calendar.rb +87 -19
  25. data/app/components/phlex_kit/calendar/calendar_controller.js +226 -75
  26. data/app/components/phlex_kit/calendar/calendar_days.rb +7 -6
  27. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +3 -1
  28. data/app/components/phlex_kit/checkbox/checkbox.css +52 -7
  29. data/app/components/phlex_kit/collapsible/collapsible.css +10 -1
  30. data/app/components/phlex_kit/collapsible/collapsible_controller.js +9 -2
  31. data/app/components/phlex_kit/combobox/combobox.css +37 -3
  32. data/app/components/phlex_kit/combobox/combobox.rb +3 -1
  33. data/app/components/phlex_kit/combobox/combobox_controller.js +9 -1
  34. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +7 -2
  35. data/app/components/phlex_kit/command/command.css +58 -22
  36. data/app/components/phlex_kit/command/command_controller.js +3 -1
  37. data/app/components/phlex_kit/command/command_input.rb +29 -25
  38. data/app/components/phlex_kit/command/command_separator.rb +18 -0
  39. data/app/components/phlex_kit/command/command_shortcut.rb +13 -0
  40. data/app/components/phlex_kit/context_menu/context_menu.css +78 -6
  41. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +27 -0
  42. data/app/components/phlex_kit/context_menu/context_menu_group.rb +10 -0
  43. data/app/components/phlex_kit/context_menu/context_menu_item.rb +13 -3
  44. data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +10 -0
  45. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +30 -0
  46. data/app/components/phlex_kit/context_menu/context_menu_shortcut.rb +11 -0
  47. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +11 -0
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +12 -0
  49. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +17 -0
  50. data/app/components/phlex_kit/dialog/dialog.css +34 -16
  51. data/app/components/phlex_kit/dialog/dialog_close.rb +13 -0
  52. data/app/components/phlex_kit/dialog/dialog_content.rb +13 -5
  53. data/app/components/phlex_kit/drawer/drawer.css +56 -17
  54. data/app/components/phlex_kit/drawer/drawer_content.rb +8 -4
  55. data/app/components/phlex_kit/empty/empty.css +13 -8
  56. data/app/components/phlex_kit/field/field.css +152 -0
  57. data/app/components/phlex_kit/field/field.rb +35 -0
  58. data/app/components/phlex_kit/field/field_content.rb +11 -0
  59. data/app/components/phlex_kit/field/field_description.rb +10 -0
  60. data/app/components/phlex_kit/field/field_error.rb +29 -0
  61. data/app/components/phlex_kit/field/field_group.rb +12 -0
  62. data/app/components/phlex_kit/field/field_label.rb +12 -0
  63. data/app/components/phlex_kit/field/field_legend.rb +17 -0
  64. data/app/components/phlex_kit/field/field_separator.rb +15 -0
  65. data/app/components/phlex_kit/field/field_set.rb +10 -0
  66. data/app/components/phlex_kit/field/field_title.rb +11 -0
  67. data/app/components/phlex_kit/hover_card/hover_card.css +12 -4
  68. data/app/components/phlex_kit/hover_card/hover_card_content.rb +11 -2
  69. data/app/components/phlex_kit/input/input.css +45 -11
  70. data/app/components/phlex_kit/input_group/input_group.css +88 -12
  71. data/app/components/phlex_kit/input_group/input_group_addon.rb +11 -4
  72. data/app/components/phlex_kit/input_group/input_group_button.rb +18 -0
  73. data/app/components/phlex_kit/input_otp/input_otp.css +29 -5
  74. data/app/components/phlex_kit/item/item.css +56 -8
  75. data/app/components/phlex_kit/item/item.rb +13 -3
  76. data/app/components/phlex_kit/item/item_footer.rb +8 -0
  77. data/app/components/phlex_kit/item/item_header.rb +8 -0
  78. data/app/components/phlex_kit/item/item_media.rb +13 -3
  79. data/app/components/phlex_kit/item/item_separator.rb +10 -0
  80. data/app/components/phlex_kit/kbd/kbd.css +10 -0
  81. data/app/components/phlex_kit/marker/marker.css +29 -9
  82. data/app/components/phlex_kit/marker/marker.rb +13 -3
  83. data/app/components/phlex_kit/menubar/menubar.css +88 -17
  84. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +26 -0
  85. data/app/components/phlex_kit/menubar/menubar_group.rb +10 -0
  86. data/app/components/phlex_kit/menubar/menubar_item.rb +9 -4
  87. data/app/components/phlex_kit/menubar/menubar_label.rb +10 -0
  88. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +10 -0
  89. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +29 -0
  90. data/app/components/phlex_kit/menubar/menubar_shortcut.rb +10 -0
  91. data/app/components/phlex_kit/menubar/menubar_sub.rb +10 -0
  92. data/app/components/phlex_kit/menubar/menubar_sub_content.rb +11 -0
  93. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +17 -0
  94. data/app/components/phlex_kit/message/message.css +8 -1
  95. data/app/components/phlex_kit/message_scroller/message_scroller.css +18 -0
  96. data/app/components/phlex_kit/native_select/native_select.css +25 -5
  97. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +9 -6
  98. data/app/components/phlex_kit/pagination/pagination.css +10 -3
  99. data/app/components/phlex_kit/pagination/pagination_link.rb +21 -0
  100. data/app/components/phlex_kit/pagination/pagination_next.rb +20 -0
  101. data/app/components/phlex_kit/pagination/pagination_previous.rb +21 -0
  102. data/app/components/phlex_kit/popover/popover.css +15 -4
  103. data/app/components/phlex_kit/popover/popover_content.rb +11 -2
  104. data/app/components/phlex_kit/popover/popover_description.rb +10 -0
  105. data/app/components/phlex_kit/popover/popover_header.rb +10 -0
  106. data/app/components/phlex_kit/popover/popover_title.rb +10 -0
  107. data/app/components/phlex_kit/progress/progress.css +5 -5
  108. data/app/components/phlex_kit/radio_button/radio_button.css +46 -2
  109. data/app/components/phlex_kit/radio_group/radio_group.css +2 -2
  110. data/app/components/phlex_kit/resizable/resizable.css +17 -0
  111. data/app/components/phlex_kit/resizable/resizable_handle.rb +7 -3
  112. data/app/components/phlex_kit/select/select.css +46 -10
  113. data/app/components/phlex_kit/select/select_separator.rb +10 -0
  114. data/app/components/phlex_kit/select/select_trigger.rb +5 -2
  115. data/app/components/phlex_kit/sheet/sheet.css +18 -9
  116. data/app/components/phlex_kit/sheet/sheet_close.rb +10 -0
  117. data/app/components/phlex_kit/sheet/sheet_content.rb +8 -4
  118. data/app/components/phlex_kit/sidebar/sidebar.css +76 -9
  119. data/app/components/phlex_kit/sidebar/sidebar_controller.js +22 -7
  120. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +8 -3
  121. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +21 -0
  122. data/app/components/phlex_kit/sidebar/sidebar_wrapper.rb +25 -8
  123. data/app/components/phlex_kit/skeleton/skeleton.css +1 -1
  124. data/app/components/phlex_kit/slider/slider.css +33 -12
  125. data/app/components/phlex_kit/switch/switch.css +50 -13
  126. data/app/components/phlex_kit/switch/switch.rb +5 -2
  127. data/app/components/phlex_kit/table/table.css +8 -4
  128. data/app/components/phlex_kit/tabs/tabs.css +72 -8
  129. data/app/components/phlex_kit/tabs/tabs.rb +7 -2
  130. data/app/components/phlex_kit/tabs/tabs_list.rb +11 -2
  131. data/app/components/phlex_kit/textarea/textarea.css +33 -13
  132. data/app/components/phlex_kit/toast/toast.css +1 -1
  133. data/app/components/phlex_kit/toggle/toggle.css +25 -10
  134. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -6
  135. data/app/components/phlex_kit/tooltip/tooltip.css +37 -10
  136. data/app/components/phlex_kit/tooltip/tooltip_content.rb +9 -4
  137. data/lib/phlex_kit/version.rb +1 -1
  138. metadata +50 -1
@@ -1,9 +1,20 @@
1
- /* Co-located with popover.rb. CSS-positioned panel. Tokens from the global stylesheet. */
1
+ /* Co-located with popover.rb. CSS-positioned panel matched to nova: w-72,
2
+ rounded-lg, p-2.5 with a gap-2.5 column, foreground-tint ring, text-sm;
3
+ Header (title + muted description) stacks at gap-0.5. `align: :end`
4
+ flips the panel to the trigger's end edge. Tokens global. */
2
5
  .pk-popover { position: relative; display: inline-block; }
3
6
  .pk-popover-trigger { display: inline-block; }
4
7
  .pk-popover-content {
5
- position: absolute; z-index: 50; top: calc(100% + .5rem); left: 0;
6
- min-width: 12rem; border: 1px solid var(--pk-border); border-radius: calc(var(--pk-radius) - 2px);
7
- background: var(--pk-surface); color: var(--pk-text); padding: .75rem;
8
+ position: absolute; z-index: 50; top: calc(100% + .25rem); left: 0;
9
+ display: flex; flex-direction: column; gap: .625rem;
10
+ width: 18rem;
11
+ border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
12
+ border-radius: var(--pk-radius);
13
+ background: var(--pk-surface); color: var(--pk-text); padding: .625rem;
14
+ font-size: .875rem;
8
15
  box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
9
16
  }
17
+ .pk-popover-content.end { left: auto; right: 0; }
18
+ .pk-popover-header { display: flex; flex-direction: column; gap: .125rem; font-size: .875rem; }
19
+ .pk-popover-title { margin: 0; font-size: .875rem; font-weight: 500; }
20
+ .pk-popover-description { margin: 0; color: var(--pk-muted); }
@@ -1,8 +1,17 @@
1
1
  module PhlexKit
2
+ # The floating panel of a PhlexKit::Popover. `align: :end` flips it to the
3
+ # trigger's end edge (their align prop). See popover.rb.
2
4
  class PopoverContent < BaseComponent
3
- def initialize(**attrs) = (@attrs = attrs)
5
+ ALIGNS = { start: nil, end: "end" }.freeze
6
+
7
+ def initialize(align: :start, **attrs)
8
+ @align = align.to_sym
9
+ @attrs = attrs
10
+ end
11
+
4
12
  def view_template(&)
5
- div(**mix({ class: "pk-popover-content pk-hidden", data: { phlex_kit__popover_target: "content", state: "closed" } }, @attrs), &)
13
+ classes = [ "pk-popover-content", "pk-hidden", ALIGNS.fetch(@align) ].compact.join(" ")
14
+ div(**mix({ class: classes, data: { phlex_kit__popover_target: "content", state: "closed" } }, @attrs), &)
6
15
  end
7
16
  end
8
17
  end
@@ -0,0 +1,10 @@
1
+ module PhlexKit
2
+ # Muted body copy of a PopoverHeader, ported from shadcn/ui's
3
+ # PopoverDescription. See popover.rb.
4
+ class PopoverDescription < BaseComponent
5
+ def initialize(**attrs) = (@attrs = attrs)
6
+ def view_template(&)
7
+ p(**mix({ class: "pk-popover-description" }, @attrs), &)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ module PhlexKit
2
+ # Title + description block at the top of a PopoverContent, ported from
3
+ # shadcn/ui's PopoverHeader. See popover.rb.
4
+ class PopoverHeader < BaseComponent
5
+ def initialize(**attrs) = (@attrs = attrs)
6
+ def view_template(&)
7
+ div(**mix({ class: "pk-popover-header" }, @attrs), &)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ module PhlexKit
2
+ # Heading of a PopoverHeader, ported from shadcn/ui's PopoverTitle.
3
+ # See popover.rb.
4
+ class PopoverTitle < BaseComponent
5
+ def initialize(**attrs) = (@attrs = attrs)
6
+ def view_template(&)
7
+ h2(**mix({ class: "pk-popover-title" }, @attrs), &)
8
+ end
9
+ end
10
+ end
@@ -1,13 +1,13 @@
1
- /* Co-located with progress.rb — UI::Progress. ruby_ui's Tailwind (h-2 rounded-full
2
- bg-primary/20, indicator bg-primary translated by value) as vanilla CSS on the
3
- palette tokens. Theme tokens come from the global stylesheet. */
1
+ /* Co-located with progress.rb — PhlexKit::Progress. Matched to nova: an h-1
2
+ muted track with the brand indicator translated by value. Theme tokens
3
+ from the global stylesheet. */
4
4
  .pk-progress {
5
5
  position: relative;
6
- height: .5rem;
6
+ height: .25rem;
7
7
  width: 100%;
8
8
  overflow: hidden;
9
9
  border-radius: 999px;
10
- background: color-mix(in oklab, var(--pk-brand) 20%, transparent);
10
+ background: var(--pk-surface-2);
11
11
  }
12
12
  .pk-progress-indicator {
13
13
  height: 100%;
@@ -1,9 +1,53 @@
1
- /* Co-located with radio_button.rb. Theme tokens from the global stylesheet. */
1
+ /* Co-located with radio_button.rb PhlexKit::RadioButton. Matched to
2
+ shadcn/ui's current radio-group item (same recipe as the checkbox):
3
+ appearance:none circle with the input border + translucent dark fill,
4
+ brand fill with a primary-foreground dot when checked, extended touch
5
+ target, focus + aria-invalid rings. Native input semantics kept — no JS. */
2
6
  .pk-radio {
7
+ appearance: none;
8
+ -webkit-appearance: none;
9
+ position: relative;
10
+ display: inline-grid;
11
+ place-content: center;
3
12
  height: 1rem;
4
13
  width: 1rem;
5
14
  flex: none;
15
+ margin: 0;
6
16
  border-radius: 9999px;
7
- accent-color: var(--pk-brand);
17
+ border: 1px solid var(--pk-input);
18
+ background: color-mix(in oklab, var(--pk-input) 30%, transparent);
19
+ cursor: pointer;
20
+ transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
8
21
  }
22
+ .pk-radio::before { content: ""; position: absolute; inset: -.5rem; }
23
+ .pk-radio:checked { background: var(--pk-brand); border-color: var(--pk-brand); }
24
+ .pk-radio:checked::after {
25
+ content: "";
26
+ width: .5rem;
27
+ height: .5rem;
28
+ border-radius: 9999px;
29
+ background: var(--pk-brand-ink);
30
+ }
31
+ .pk-radio:focus-visible {
32
+ outline: none;
33
+ border-color: var(--pk-ring);
34
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
35
+ }
36
+ .pk-radio[aria-invalid="true"] {
37
+ border-color: color-mix(in oklab, var(--pk-red) 50%, transparent);
38
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
39
+ }
40
+ .pk-radio[aria-invalid="true"]:checked { border-color: var(--pk-brand); }
9
41
  .pk-radio:disabled { cursor: not-allowed; opacity: .5; }
42
+ :root[data-theme="light"] .pk-radio:not(:checked) { background: transparent; }
43
+ :root[data-theme="light"] .pk-radio[aria-invalid="true"] {
44
+ border-color: var(--pk-red);
45
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
46
+ }
47
+ @media (prefers-color-scheme: light) {
48
+ :root[data-theme="system"] .pk-radio:not(:checked) { background: transparent; }
49
+ :root[data-theme="system"] .pk-radio[aria-invalid="true"] {
50
+ border-color: var(--pk-red);
51
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
52
+ }
53
+ }
@@ -1,3 +1,3 @@
1
- /* Co-located with radio_group.rb — UI::RadioGroup, shadcn parity (grid gap-3). */
2
- .pk-radio-group { display: grid; gap: .75rem; }
1
+ /* Co-located with radio_group.rb — UI::RadioGroup, shadcn parity (grid gap-2). */
2
+ .pk-radio-group { display: grid; gap: .5rem; }
3
3
  .pk-radio-group .pk-label { gap: .75rem; }
@@ -21,3 +21,20 @@
21
21
  outline: none;
22
22
  box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
23
23
  }
24
+
25
+ /* Visible grip pill (their withHandle / .cn-resizable-handle-icon). */
26
+ .pk-resizable-handle-grip {
27
+ position: absolute;
28
+ top: 50%;
29
+ left: 50%;
30
+ z-index: 1;
31
+ width: .25rem;
32
+ height: 1.5rem;
33
+ border-radius: var(--pk-radius);
34
+ background: var(--pk-border);
35
+ transform: translate(-50%, -50%);
36
+ }
37
+ .pk-resizable-group.vertical > .pk-resizable-handle .pk-resizable-handle-grip {
38
+ width: 1.5rem;
39
+ height: .25rem;
40
+ }
@@ -1,7 +1,9 @@
1
1
  module PhlexKit
2
- # Drag handle between two ResizablePanels. See resizable_panel_group.rb.
2
+ # Drag handle between two ResizablePanels. `with_handle: true` shows the
3
+ # visible grip pill (their withHandle). See resizable_panel_group.rb.
3
4
  class ResizableHandle < BaseComponent
4
- def initialize(**attrs)
5
+ def initialize(with_handle: false, **attrs)
6
+ @with_handle = with_handle
5
7
  @attrs = attrs
6
8
  end
7
9
 
@@ -15,7 +17,9 @@ module PhlexKit
15
17
  phlex_kit__resizable_target: "handle",
16
18
  action: "pointerdown->phlex-kit--resizable#start"
17
19
  }
18
- }, @attrs))
20
+ }, @attrs)) do
21
+ span(class: "pk-resizable-handle-grip", aria: { hidden: "true" }) if @with_handle
22
+ end
19
23
  end
20
24
  end
21
25
  end
@@ -21,15 +21,23 @@
21
21
  border: 1px solid var(--pk-input);
22
22
  background: transparent;
23
23
  color: var(--pk-text);
24
- padding: .25rem .75rem;
24
+ padding: .25rem .5rem .25rem .625rem;
25
+ gap: .375rem;
25
26
  font: inherit;
26
27
  font-size: .875rem;
27
- cursor: pointer;
28
- transition: border-color .15s ease, box-shadow .15s ease;
28
+ cursor: pointer;
29
+ background: color-mix(in oklab, var(--pk-input) 30%, transparent);
30
+ transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
31
+ }
32
+ .pk-select-trigger:hover { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
33
+ .pk-select-trigger.sm { height: 1.75rem; border-radius: min(calc(var(--pk-radius) - 2px), 10px); }
34
+ .pk-select-trigger[aria-invalid="true"] {
35
+ border-color: color-mix(in oklab, var(--pk-red) 50%, transparent);
36
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
29
37
  }
30
38
  .pk-select-trigger:focus-visible {
31
39
  outline: none;
32
- border-color: var(--pk-brand);
40
+ border-color: var(--pk-ring);
33
41
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
34
42
  }
35
43
  .pk-select-trigger:disabled { cursor: not-allowed; opacity: .5; }
@@ -49,15 +57,15 @@
49
57
  .pk-select-viewport {
50
58
  max-height: 24rem;
51
59
  overflow: auto;
52
- border-radius: calc(var(--pk-radius) - 2px);
53
- border: 1px solid var(--pk-border);
60
+ border-radius: var(--pk-radius);
61
+ border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
54
62
  background: var(--pk-surface);
55
63
  color: var(--pk-text);
56
64
  padding: .25rem;
57
65
  box-shadow: 0 4px 16px rgba(0, 0, 0, .4);
58
66
  }
59
67
 
60
- .pk-select-label { margin: 0; padding: .375rem .5rem; font-size: .875rem; font-weight: 600; }
68
+ .pk-select-label { margin: 0; padding: .25rem .375rem; font-size: .75rem; font-weight: 400; color: var(--pk-muted); }
61
69
 
62
70
  .pk-select-item {
63
71
  position: relative;
@@ -65,16 +73,44 @@
65
73
  align-items: center;
66
74
  cursor: pointer;
67
75
  user-select: none;
68
- border-radius: calc(var(--pk-radius) - 4px);
69
- padding: .375rem .5rem;
76
+ gap: .375rem;
77
+ border-radius: calc(var(--pk-radius) - 2px);
78
+ padding: .25rem 2rem .25rem .375rem;
70
79
  font-size: .875rem;
71
80
  color: var(--pk-text);
72
81
  outline: none;
73
82
  transition: background-color .12s ease, color .12s ease;
74
83
  }
84
+ .pk-select-item svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
75
85
  .pk-select-item:hover,
76
86
  .pk-select-item:focus,
77
87
  .pk-select-item[aria-current="true"],
78
88
  .pk-select-item[aria-selected="true"] { background: var(--pk-accent); }
79
- .pk-select-item-check { visibility: hidden; flex: none; width: 1rem; height: 1rem; margin-right: .5rem; }
89
+ .pk-select-item-check {
90
+ visibility: hidden;
91
+ position: absolute;
92
+ right: .5rem;
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ width: 1rem;
97
+ height: 1rem;
98
+ pointer-events: none;
99
+ }
80
100
  .pk-select-item[aria-selected="true"] .pk-select-item-check { visibility: visible; }
101
+
102
+ .pk-select-separator { height: 1px; margin: .25rem -.25rem; background: var(--pk-border); }
103
+ :root[data-theme="light"] .pk-select-trigger { background: transparent; }
104
+ :root[data-theme="light"] .pk-select-trigger:hover { background: transparent; }
105
+ :root[data-theme="light"] .pk-select-trigger[aria-invalid="true"] {
106
+ border-color: var(--pk-red);
107
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
108
+ }
109
+ @media (prefers-color-scheme: light) {
110
+ :root[data-theme="system"] .pk-select-trigger { background: transparent; }
111
+ :root[data-theme="system"] .pk-select-trigger:hover { background: transparent; }
112
+ :root[data-theme="system"] .pk-select-trigger[aria-invalid="true"] {
113
+ border-color: var(--pk-red);
114
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
115
+ }
116
+ }
@@ -0,0 +1,10 @@
1
+ module PhlexKit
2
+ # Hairline between SelectGroups, ported from shadcn/ui's SelectSeparator.
3
+ # See select.rb.
4
+ class SelectSeparator < BaseComponent
5
+ def initialize(**attrs) = (@attrs = attrs)
6
+ def view_template
7
+ div(**mix({ class: "pk-select-separator", role: "separator", aria: { hidden: true } }, @attrs))
8
+ end
9
+ end
10
+ end
@@ -3,7 +3,10 @@ module PhlexKit
3
3
  # up/down chevron, and opens the panel on click (`phlex-kit--select#onClick`).
4
4
  # See select.rb.
5
5
  class SelectTrigger < BaseComponent
6
- def initialize(**attrs)
6
+ SIZES = { md: nil, sm: "sm" }.freeze
7
+
8
+ def initialize(size: :md, **attrs)
9
+ @size = size.to_sym
7
10
  @attrs = attrs
8
11
  end
9
12
 
@@ -11,7 +14,7 @@ module PhlexKit
11
14
  button(**mix({
12
15
  type: :button,
13
16
  role: "combobox",
14
- class: "pk-select-trigger",
17
+ class: [ "pk-select-trigger", SIZES.fetch(@size) ].compact.join(" "),
15
18
  aria: { expanded: "false", haspopup: "listbox", autocomplete: "none" },
16
19
  data: { action: "phlex-kit--select#onClick", phlex_kit__select_target: "trigger" }
17
20
  }, @attrs)) do
@@ -1,17 +1,26 @@
1
- /* Co-located with sheet.rb. Theme tokens from the global stylesheet. */
1
+ /* Co-located with sheet.rb. Matched to nova: the panel carries NO padding
2
+ (header/footer bring their own p-4; body content adds px-4), gap-4 column,
3
+ text-sm, title text-base/medium, close pinned top-3/right-3, side panels
4
+ 75%-wide capped at 24rem. Overlay at the kit-unified black/10 + blur-xs
5
+ (see dialog.css). Theme tokens from the global stylesheet. */
2
6
  .pk-sheet-trigger { display: inline-block; }
3
- .pk-sheet-backdrop { position: fixed; inset: 0; z-index: 50; background: rgb(0 0 0 / .5); backdrop-filter: blur(8px); }
7
+ .pk-sheet-backdrop { position: fixed; inset: 0; z-index: 50; background: rgb(0 0 0 / .1); backdrop-filter: blur(4px); }
8
+ @supports not (backdrop-filter: blur(1px)) {
9
+ .pk-sheet-backdrop { background: rgb(0 0 0 / .5); }
10
+ }
4
11
  .pk-sheet-content {
5
12
  position: fixed; z-index: 50; display: flex; flex-direction: column; gap: 1rem;
6
- background: var(--pk-surface); color: var(--pk-text); padding: 1.5rem;
13
+ background: var(--pk-surface); color: var(--pk-text); padding: 0;
14
+ font-size: .875rem;
7
15
  box-shadow: 0 10px 30px rgb(0 0 0 / .35); overflow: auto;
8
16
  }
9
- .pk-sheet-content.right { inset-block: 0; right: 0; height: 100%; width: min(24rem, 100%); border-left: 1px solid var(--pk-border); }
10
- .pk-sheet-content.left { inset-block: 0; left: 0; height: 100%; width: min(24rem, 100%); border-right: 1px solid var(--pk-border); }
17
+ .pk-sheet-content.right { inset-block: 0; right: 0; height: 100%; width: 75%; max-width: 24rem; border-left: 1px solid var(--pk-border); }
18
+ .pk-sheet-content.left { inset-block: 0; left: 0; height: 100%; width: 75%; max-width: 24rem; border-right: 1px solid var(--pk-border); }
11
19
  .pk-sheet-content.top { inset-inline: 0; top: 0; border-bottom: 1px solid var(--pk-border); }
12
20
  .pk-sheet-content.bottom { inset-inline: 0; bottom: 0; border-top: 1px solid var(--pk-border); }
13
- .pk-sheet-header { display: flex; flex-direction: column; gap: .375rem; }
14
- .pk-sheet-footer { display: flex; flex-direction: column-reverse; gap: .5rem; }
15
- .pk-sheet-title { font-size: 1.125rem; font-weight: 600; line-height: 1; letter-spacing: -.01em; }
21
+ .pk-sheet-header { display: flex; flex-direction: column; gap: .125rem; padding: 1rem; }
22
+ .pk-sheet-footer { display: flex; flex-direction: column-reverse; gap: .5rem; padding: 1rem; margin-top: auto; }
23
+ .pk-sheet-title { font-size: 1rem; font-weight: 500; line-height: 1; color: var(--pk-text); }
16
24
  .pk-sheet-description { font-size: .875rem; color: var(--pk-muted); }
17
- .pk-sheet-middle { padding-block: 1rem; }
25
+ .pk-sheet-middle { flex: 1; padding-inline: 1rem; }
26
+ .pk-sheet-content > .pk-overlay-close { inset-inline-end: .75rem; top: .75rem; }
@@ -0,0 +1,10 @@
1
+ module PhlexKit
2
+ # Wraps any element (typically a Button) so clicking it closes the enclosing
3
+ # sheet, mirroring shadcn/ui's SheetClose. See sheet.rb.
4
+ class SheetClose < BaseComponent
5
+ def initialize(**attrs) = (@attrs = attrs)
6
+ def view_template(&)
7
+ span(**mix({ style: "display: contents", data: { action: "click->phlex-kit--sheet-content#close" } }, @attrs), &)
8
+ end
9
+ end
10
+ end
@@ -1,8 +1,10 @@
1
1
  module PhlexKit
2
2
  class SheetContent < BaseComponent
3
3
  SIDES = { top: "top", right: "right", bottom: "bottom", left: "left" }.freeze
4
- def initialize(side: :right, **attrs)
4
+
5
+ def initialize(side: :right, show_close_button: true, **attrs)
5
6
  @side = side.to_sym
7
+ @show_close_button = show_close_button
6
8
  @attrs = attrs
7
9
  end
8
10
  def view_template(&block)
@@ -11,9 +13,11 @@ module PhlexKit
11
13
  div(class: "pk-sheet-backdrop", data: { action: "click->phlex-kit--sheet-content#close" })
12
14
  div(**mix({ class: ["pk-sheet-content", SIDES.fetch(@side)].join(" ") }, @attrs)) do
13
15
  block&.call
14
- button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--sheet-content#close" }) do
15
- render Icon.new(:x, size: 15)
16
- span(class: "pk-sr-only") { "Close" }
16
+ if @show_close_button
17
+ button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--sheet-content#close" }) do
18
+ render Icon.new(:x, size: 15)
19
+ span(class: "pk-sr-only") { "Close" }
20
+ end
17
21
  end
18
22
  end
19
23
  end
@@ -3,8 +3,10 @@
3
3
  / MenuAction / MenuBadge / MenuSkeleton / MenuSub / MenuSubItem / MenuSubButton
4
4
  / Separator / Input / Inset). ruby_ui's structure at collapsible:none, extended
5
5
  to shadcn/ui's static part set; Tailwind → vanilla CSS on the palette tokens.
6
- The collapsible/offcanvas/icon modes (+ their trigger/rail/Stimulus/cookie/
7
- Sheet) are deliberately not ported.
6
+ Collapsible modes: offcanvas (slide away) and icon (3rem rail — labels
7
+ hide, buttons become icon squares with pure-CSS hover tooltips), both with
8
+ the mobile drawer + scrim, the SidebarRail grab strip, ⌘B, and cookie
9
+ persistence via the phlex-kit--sidebar controller.
8
10
  Theme tokens come from the global stylesheet. The sidebar reads the shared
9
11
  palette by default; --pk-sidebar[-text|-accent|-border|-primary|-primary-ink]
10
12
  are optional per-role overrides (shadcn sidebar-* parity), undefined by
@@ -164,11 +166,70 @@
164
166
  Mobile (<768px): the rail is a fixed overlay drawer behind a scrim
165
167
  (data-open). The trigger only shows inside an offcanvas wrapper. The 767px
166
168
  cutoff matches sidebar_controller.js. */
167
- .pk-sidebar-wrapper.collapsible-offcanvas .pk-sidebar {
168
- transition: margin-left .2s ease, transform .2s ease;
169
+ .pk-sidebar-wrapper.collapsible-offcanvas .pk-sidebar,
170
+ .pk-sidebar-wrapper.collapsible-icon .pk-sidebar {
171
+ position: relative;
172
+ transition: margin-left .2s ease, transform .2s ease, width .2s ease;
169
173
  }
170
174
  .pk-sidebar-wrapper.collapsible-offcanvas[data-collapsed] .pk-sidebar { margin-left: -16rem; }
171
175
 
176
+ /* Icon mode: the rail shrinks to a 3rem strip — labels and text hide, menu
177
+ buttons become centered icon squares, hover shows a data-tooltip bubble. */
178
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar { width: 3rem; }
179
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] :is(.pk-sidebar-group-label, .pk-sidebar-group-action, .pk-sidebar-menu-badge, .pk-sidebar-menu-action, .pk-sidebar-menu-sub, .pk-sidebar-input, .pk-sidebar-separator) { display: none; }
180
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] :is(.pk-sidebar-header, .pk-sidebar-content, .pk-sidebar-footer) {
181
+ padding-inline: .5rem;
182
+ align-items: center;
183
+ }
184
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button {
185
+ width: 2rem;
186
+ height: 2rem;
187
+ padding: .5rem;
188
+ justify-content: center;
189
+ }
190
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button > :not(svg) { display: none; }
191
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button[data-tooltip] { position: relative; }
192
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button[data-tooltip]:hover::after {
193
+ content: attr(data-tooltip);
194
+ position: absolute;
195
+ left: calc(100% + .625rem);
196
+ top: 50%;
197
+ transform: translateY(-50%);
198
+ z-index: 60;
199
+ width: max-content;
200
+ padding: .375rem .75rem;
201
+ border-radius: calc(var(--pk-radius) - 2px);
202
+ background: var(--pk-text);
203
+ color: var(--pk-bg);
204
+ font-size: .75rem;
205
+ pointer-events: none;
206
+ }
207
+
208
+ /* Edge grab strip (SidebarRail) — invisible, click toggles. */
209
+ .pk-sidebar-rail {
210
+ position: absolute;
211
+ inset-block: 0;
212
+ right: -.5rem;
213
+ z-index: 20;
214
+ width: 1rem;
215
+ border: 0;
216
+ padding: 0;
217
+ background: transparent;
218
+ cursor: w-resize;
219
+ display: none;
220
+ }
221
+ .pk-sidebar-wrapper.collapsible-offcanvas .pk-sidebar-rail,
222
+ .pk-sidebar-wrapper.collapsible-icon .pk-sidebar-rail { display: block; }
223
+ .pk-sidebar-wrapper[data-collapsed] .pk-sidebar-rail { cursor: e-resize; }
224
+ .pk-sidebar-rail:hover::after {
225
+ content: "";
226
+ position: absolute;
227
+ inset-block: 0;
228
+ left: 50%;
229
+ width: 2px;
230
+ background: var(--pk-sidebar-border, var(--pk-border));
231
+ }
232
+
172
233
  .pk-sidebar-trigger {
173
234
  display: none;
174
235
  align-items: center;
@@ -184,12 +245,18 @@
184
245
  }
185
246
  .pk-sidebar-trigger:hover { background: var(--pk-accent); }
186
247
  .pk-sidebar-trigger svg { width: 1rem; height: 1rem; }
187
- .collapsible-offcanvas .pk-sidebar-trigger { display: inline-flex; }
248
+ :is(.collapsible-offcanvas, .collapsible-icon) .pk-sidebar-trigger { display: inline-flex; }
188
249
 
189
250
  .pk-sidebar-scrim { display: none; }
190
251
 
191
252
  @media (max-width: 767px) {
192
- .pk-sidebar-wrapper.collapsible-offcanvas .pk-sidebar {
253
+ /* Below the cutoff both collapsible modes become the overlay drawer. */
254
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar { width: 16rem; }
255
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] :is(.pk-sidebar-group-label, .pk-sidebar-group-action, .pk-sidebar-menu-badge, .pk-sidebar-menu-action, .pk-sidebar-menu-sub, .pk-sidebar-input, .pk-sidebar-separator) { display: revert; }
256
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button { width: 100%; height: auto; padding: .5rem; justify-content: flex-start; }
257
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button > :not(svg) { display: revert; }
258
+ .pk-sidebar-wrapper.collapsible-icon .pk-sidebar-rail { display: none; }
259
+ .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon) .pk-sidebar {
193
260
  position: fixed;
194
261
  inset-block: 0;
195
262
  left: 0;
@@ -199,8 +266,8 @@
199
266
  transform: translateX(-100%);
200
267
  box-shadow: 0 8px 30px rgb(0 0 0 / .35);
201
268
  }
202
- .pk-sidebar-wrapper.collapsible-offcanvas[data-open] .pk-sidebar { transform: none; }
203
- .pk-sidebar-wrapper.collapsible-offcanvas[data-open] .pk-sidebar-scrim {
269
+ .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon)[data-open] .pk-sidebar { transform: none; }
270
+ .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon)[data-open] .pk-sidebar-scrim {
204
271
  display: block;
205
272
  position: fixed;
206
273
  inset: 0;
@@ -213,5 +280,5 @@
213
280
  }
214
281
 
215
282
  @media (prefers-reduced-motion: reduce) {
216
- .pk-sidebar-wrapper.collapsible-offcanvas .pk-sidebar { transition: none; }
283
+ .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon) .pk-sidebar { transition: none; }
217
284
  }
@@ -1,23 +1,38 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--sidebar" — set by SidebarWrapper when
4
- // collapsible: :offcanvas. One attribute per surface, both DOM-only so Turbo's
5
- // page cache never restores an open drawer (the wrapper also closes on
4
+ // collapsible: :offcanvas or :icon. One attribute per surface, both DOM-only so
5
+ // Turbo's page cache never restores an open drawer (the wrapper also closes on
6
6
  // turbo:before-cache): `data-open` shows the mobile overlay drawer,
7
- // `data-collapsed` slides the desktop rail out of the layout. The 767px cutoff
8
- // matches sidebar.css's offcanvas media query.
7
+ // `data-collapsed` collapses the desktop rail (offcanvas slides it away; icon
8
+ // mode shrinks it to a 3rem strip). The 767px cutoff matches sidebar.css's
9
+ // media query. The desktop state persists in a `pk_sidebar_state` cookie so a
10
+ // host layout can render the collapsed rail server-side (no flash) via
11
+ // SidebarWrapper's `default_collapsed:`.
9
12
  export default class extends Controller {
10
13
  static MOBILE = "(max-width: 767px)"
14
+ static COOKIE = "pk_sidebar_state"
11
15
 
12
- toggle() {
13
- if (this.mobile) this.element.toggleAttribute("data-open")
14
- else this.element.toggleAttribute("data-collapsed")
16
+ toggle(event) {
17
+ if (event?.type === "keydown" && !(event.metaKey || event.ctrlKey)) return
18
+
19
+ if (this.mobile) {
20
+ this.element.toggleAttribute("data-open")
21
+ } else {
22
+ this.element.toggleAttribute("data-collapsed")
23
+ this.persist()
24
+ }
15
25
  }
16
26
 
17
27
  closeMobile() {
18
28
  this.element.removeAttribute("data-open")
19
29
  }
20
30
 
31
+ persist() {
32
+ const state = this.element.hasAttribute("data-collapsed") ? "collapsed" : "expanded"
33
+ document.cookie = `${this.constructor.COOKIE}=${state}; path=/; max-age=${60 * 60 * 24 * 7}; samesite=lax`
34
+ }
35
+
21
36
  get mobile() {
22
37
  return window.matchMedia(this.constructor.MOBILE).matches
23
38
  }
@@ -2,16 +2,21 @@ module PhlexKit
2
2
  # The interactive element of a SidebarMenuItem. `as:` is :button (default) or :a
3
3
  # (pass `href:`); `active: true` marks the current page (drives the highlight via
4
4
  # data-active — a named kwarg, not an attr, since `mix` would merge a repeated
5
- # attribute rather than override). Attrs pass through via mix. See sidebar.rb.
5
+ # attribute rather than override). `tooltip:` labels the button while an
6
+ # icon-collapsed rail hides its text (pure-CSS hover bubble). Attrs pass
7
+ # through via mix. See sidebar.rb.
6
8
  class SidebarMenuButton < BaseComponent
7
- def initialize(as: :button, active: false, **attrs)
9
+ def initialize(as: :button, active: false, tooltip: nil, **attrs)
8
10
  @as = as
9
11
  @active = active
12
+ @tooltip = tooltip
10
13
  @attrs = attrs
11
14
  end
12
15
 
13
16
  def view_template(&block)
14
- send(@as, **mix({ class: "pk-sidebar-menu-button", "data-active": (@active ? "true" : "false") }, @attrs), &block)
17
+ base = { class: "pk-sidebar-menu-button", "data-active": (@active ? "true" : "false") }
18
+ base["data-tooltip"] = @tooltip if @tooltip
19
+ send(@as, **mix(base, @attrs), &block)
15
20
  end
16
21
  end
17
22
  end
@@ -0,0 +1,21 @@
1
+ module PhlexKit
2
+ # Invisible grab strip along the sidebar's trailing edge — clicking it
3
+ # toggles the collapse, like shadcn/ui's SidebarRail. Place it as the last
4
+ # child of a Sidebar inside a collapsible wrapper. See sidebar.rb.
5
+ class SidebarRail < BaseComponent
6
+ def initialize(**attrs)
7
+ @attrs = attrs
8
+ end
9
+
10
+ def view_template
11
+ button(**mix({
12
+ type: :button,
13
+ class: "pk-sidebar-rail",
14
+ aria_label: "Toggle sidebar",
15
+ tabindex: "-1",
16
+ title: "Toggle sidebar",
17
+ data: { action: "click->phlex-kit--sidebar#toggle" }
18
+ }, @attrs))
19
+ end
20
+ end
21
+ end