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
@@ -0,0 +1,29 @@
1
+ module PhlexKit
2
+ # Validation message(s) for a Field, ported from shadcn/ui's FieldError.
3
+ # Pass `errors:` an array of message strings (deduped; one renders plain,
4
+ # several render a list) or give a block. Renders nothing when empty —
5
+ # matching theirs. Distinct from FormFieldError, which is the Stimulus
6
+ # live-validation slot; server-rendered errors belong here. See field.rb.
7
+ class FieldError < BaseComponent
8
+ def initialize(errors: nil, **attrs)
9
+ @errors = Array(errors).compact.uniq
10
+ @attrs = attrs
11
+ end
12
+
13
+ def view_template(&block)
14
+ return if @errors.empty? && !block
15
+
16
+ div(**mix({ class: "pk-field-error", role: "alert", data: { slot: "field-error" } }, @attrs)) do
17
+ if block
18
+ yield
19
+ elsif @errors.length == 1
20
+ plain @errors.first
21
+ else
22
+ ul(class: "pk-field-error-list") do
23
+ @errors.each { |message| li { message } }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,12 @@
1
+ module PhlexKit
2
+ # Column of Fields with the canonical rhythm, ported from shadcn/ui's
3
+ # FieldGroup. Also the CONTAINER for responsive fields — a Field with
4
+ # orientation: :responsive goes horizontal when this group is ≥28rem wide.
5
+ # See field.rb.
6
+ class FieldGroup < BaseComponent
7
+ def initialize(**attrs) = (@attrs = attrs)
8
+ def view_template(&)
9
+ div(**mix({ class: "pk-field-group", data: { slot: "field-group" } }, @attrs), &)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module PhlexKit
2
+ # <label> for a Field's control, ported from shadcn/ui's FieldLabel (their
3
+ # Label + field styling — ours composes .pk-label the same way). Wrap a
4
+ # whole Field in it for the choice-card recipe: the label grows a border
5
+ # and highlights while its control is checked. See field.rb.
6
+ class FieldLabel < BaseComponent
7
+ def initialize(**attrs) = (@attrs = attrs)
8
+ def view_template(&)
9
+ label(**mix({ class: "pk-label pk-field-label", data: { slot: "field-label" } }, @attrs), &)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ module PhlexKit
2
+ # <legend> of a FieldSet, ported from shadcn/ui's FieldLegend.
3
+ # variant: :legend (text-base, default) or :label (text-sm — matches
4
+ # FieldLabel when the set sits among plain fields). See field.rb.
5
+ class FieldLegend < BaseComponent
6
+ VARIANTS = { legend: "legend", label: "label" }.freeze
7
+
8
+ def initialize(variant: :legend, **attrs)
9
+ @variant = variant.to_sym
10
+ @attrs = attrs
11
+ end
12
+
13
+ def view_template(&)
14
+ legend(**mix({ class: "pk-field-legend #{VARIANTS.fetch(@variant)}" }, @attrs), &)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module PhlexKit
2
+ # Hairline between FieldGroups, optionally with centered text riding the
3
+ # line ("Or continue with"), ported from shadcn/ui's FieldSeparator.
4
+ # See field.rb.
5
+ class FieldSeparator < BaseComponent
6
+ def initialize(**attrs) = (@attrs = attrs)
7
+
8
+ def view_template(&block)
9
+ div(**mix({ class: "pk-field-separator", data: { content: block ? "true" : "false" } }, @attrs)) do
10
+ render Separator.new(class: "pk-field-separator-line")
11
+ span(class: "pk-field-separator-content", &block) if block
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ module PhlexKit
2
+ # Semantic <fieldset> grouping several Fields under a FieldLegend, ported
3
+ # from shadcn/ui's FieldSet. See field.rb.
4
+ class FieldSet < BaseComponent
5
+ def initialize(**attrs) = (@attrs = attrs)
6
+ def view_template(&)
7
+ fieldset(**mix({ class: "pk-field-set" }, @attrs), &)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ module PhlexKit
2
+ # Label-looking title that is NOT a <label> (for content inside a
3
+ # FieldLabel-wrapped choice card, where nesting labels is invalid HTML),
4
+ # ported from shadcn/ui's FieldTitle. See field.rb.
5
+ class FieldTitle < BaseComponent
6
+ def initialize(**attrs) = (@attrs = attrs)
7
+ def view_template(&)
8
+ div(**mix({ class: "pk-field-title" }, @attrs), &)
9
+ end
10
+ end
11
+ end
@@ -1,8 +1,16 @@
1
- /* Co-located with hover_card.rb. Tokens from the global stylesheet. */
1
+ /* Co-located with hover_card.rb. Matched to shadcn/ui's current hover-card
2
+ (nova): 16rem popover panel, rounded-lg, p-2.5, text-sm, foreground-tint
3
+ ring. side: top/left/right flip the CSS position (bottom default).
4
+ Tokens from the global stylesheet. */
2
5
  .pk-hover-card { position: relative; display: inline-block; }
3
6
  .pk-hover-card-trigger { display: inline-block; }
4
7
  .pk-hover-card-content {
5
- position: absolute; z-index: 50; top: calc(100% + .5rem); left: 0; width: 16rem;
6
- border: 1px solid var(--pk-border); border-radius: calc(var(--pk-radius) - 2px); background: var(--pk-surface);
7
- color: var(--pk-text); padding: 1rem; box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
8
+ position: absolute; z-index: 50; top: calc(100% + .25rem); left: 0; width: 16rem;
9
+ border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
10
+ border-radius: var(--pk-radius); background: var(--pk-surface);
11
+ color: var(--pk-text); padding: .625rem; font-size: .875rem;
12
+ box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
8
13
  }
14
+ .pk-hover-card-content.top { top: auto; bottom: calc(100% + .25rem); }
15
+ .pk-hover-card-content.left { top: 50%; left: auto; right: calc(100% + .25rem); transform: translateY(-50%); }
16
+ .pk-hover-card-content.right { top: 50%; left: calc(100% + .25rem); transform: translateY(-50%); }
@@ -1,8 +1,17 @@
1
1
  module PhlexKit
2
+ # The floating panel of a PhlexKit::HoverCard. `side:` picks the edge it
3
+ # opens from (:bottom default, :top/:left/:right — shadcn's side prop).
2
4
  class HoverCardContent < BaseComponent
3
- def initialize(**attrs) = (@attrs = attrs)
5
+ SIDES = { bottom: nil, top: "top", left: "left", right: "right" }.freeze
6
+
7
+ def initialize(side: :bottom, **attrs)
8
+ @side = side.to_sym
9
+ @attrs = attrs
10
+ end
11
+
4
12
  def view_template(&)
5
- div(**mix({ class: "pk-hover-card-content pk-hidden", data: { phlex_kit__hover_card_target: "content", state: "closed" } }, @attrs), &)
13
+ classes = [ "pk-hover-card-content", "pk-hidden", SIDES.fetch(@side) ].compact.join(" ")
14
+ div(**mix({ class: classes, data: { phlex_kit__hover_card_target: "content", state: "closed" } }, @attrs), &)
6
15
  end
7
16
  end
8
17
  end
@@ -1,29 +1,63 @@
1
- /* Co-located with input.rb — UI::Input. ruby_ui's Tailwind input replaced with
2
- our own vanilla CSS: the geometry is ruby_ui's (h-9 / rounded-md / px-3 py-1 /
3
- text-sm / shadow-xs / ring-on-focus), realised against this app's palette
4
- tokens. Not styled to match the legacy `.field input`; this is the kit's own
5
- look and will be revisited in the visual redesign. Theme tokens come from the
6
- global stylesheet. */
1
+ /* Co-located with input.rb — PhlexKit::Input. Matched to shadcn/ui's current
2
+ input (nova): h-8, rounded-lg, px-2.5, translucent input fill in dark /
3
+ transparent in light, ring focus (border-ring, not brand), invalid ring
4
+ with the 20/40 light/dark split, tinted disabled fill, styled file
5
+ selector button. text-base on small screens (prevents iOS zoom), text-sm
6
+ from md up — their text-base md:text-sm. Theme tokens global. */
7
7
  .pk-input {
8
8
  height: 2rem;
9
9
  width: 100%;
10
+ min-width: 0;
10
11
  border-radius: var(--pk-radius);
11
12
  border: 1px solid var(--pk-input);
12
13
  background: color-mix(in oklab, var(--pk-input) 30%, transparent);
13
14
  color: var(--pk-text);
14
15
  padding: .25rem .625rem;
15
16
  font: inherit;
16
- font-size: .875rem;
17
- transition: color .15s ease, border-color .15s ease, box-shadow .15s ease;
17
+ font-size: 1rem;
18
+ transition: color .15s ease, border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
19
+ }
20
+ @media (min-width: 768px) {
21
+ .pk-input { font-size: .875rem; }
18
22
  }
19
23
  .pk-input::placeholder { color: var(--pk-muted); }
24
+ .pk-input::file-selector-button {
25
+ display: inline-flex;
26
+ height: 1.5rem;
27
+ border: 0;
28
+ background: transparent;
29
+ color: var(--pk-text);
30
+ font: inherit;
31
+ font-size: .875rem;
32
+ font-weight: 500;
33
+ margin-right: .5rem;
34
+ }
20
35
  .pk-input:focus-visible {
21
36
  outline: none;
22
- border-color: var(--pk-brand);
37
+ border-color: var(--pk-ring);
23
38
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
24
39
  }
25
- .pk-input:disabled { cursor: not-allowed; opacity: .5; }
40
+ .pk-input:disabled {
41
+ pointer-events: none;
42
+ cursor: not-allowed;
43
+ opacity: .5;
44
+ background: color-mix(in oklab, var(--pk-input) 80%, transparent);
45
+ }
26
46
  .pk-input[aria-invalid="true"] {
47
+ border-color: color-mix(in oklab, var(--pk-red) 50%, transparent);
48
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
49
+ }
50
+ :root[data-theme="light"] .pk-input { background: transparent; }
51
+ :root[data-theme="light"] .pk-input:disabled { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
52
+ :root[data-theme="light"] .pk-input[aria-invalid="true"] {
27
53
  border-color: var(--pk-red);
28
- box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 30%, transparent);
54
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
55
+ }
56
+ @media (prefers-color-scheme: light) {
57
+ :root[data-theme="system"] .pk-input { background: transparent; }
58
+ :root[data-theme="system"] .pk-input:disabled { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
59
+ :root[data-theme="system"] .pk-input[aria-invalid="true"] {
60
+ border-color: var(--pk-red);
61
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
62
+ }
29
63
  }
@@ -1,35 +1,111 @@
1
- /* Co-located with input_group.rb — UI::InputGroup, shadcn parity: the shell
2
- carries the border/fill and focus ring; the inner .pk-input goes naked. */
1
+ /* Co-located with input_group.rb — PhlexKit::InputGroup, shadcn parity: the
2
+ shell carries the border/fill and focus ring; the inner .pk-input /
3
+ .pk-textarea go naked. Matched to nova: fixed h-8 (auto with a textarea or
4
+ block addon), ring-colored focus, invalid + disabled states lifted from
5
+ the control via :has(), block addons stack the group into a column,
6
+ theme-forked fill (translucent input in dark / transparent in light). */
3
7
  .pk-input-group {
8
+ position: relative;
4
9
  display: flex;
5
10
  align-items: center;
6
11
  width: 100%;
12
+ min-width: 0;
13
+ height: 2rem;
7
14
  border: 1px solid var(--pk-input);
8
15
  border-radius: var(--pk-radius);
9
16
  background: color-mix(in oklab, var(--pk-input) 30%, transparent);
10
- transition: border-color .15s ease, box-shadow .15s ease;
17
+ transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
18
+ }
19
+ .pk-input-group:has(> textarea),
20
+ .pk-input-group:has(> .pk-textarea),
21
+ .pk-input-group:has(> .pk-input-group-addon.block-start),
22
+ .pk-input-group:has(> .pk-input-group-addon.block-end) {
23
+ height: auto;
24
+ flex-direction: column;
25
+ align-items: stretch;
11
26
  }
12
27
  .pk-input-group:focus-within {
13
- border-color: var(--pk-brand);
28
+ border-color: var(--pk-ring);
14
29
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
15
30
  }
16
- .pk-input-group .pk-input {
31
+ .pk-input-group:has([aria-invalid="true"]) {
32
+ border-color: color-mix(in oklab, var(--pk-red) 50%, transparent);
33
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
34
+ }
35
+ .pk-input-group:has(:disabled) {
36
+ opacity: .5;
37
+ background: color-mix(in oklab, var(--pk-input) 80%, transparent);
38
+ }
39
+ .pk-input-group .pk-input,
40
+ .pk-input-group .pk-textarea {
17
41
  border: 0;
18
42
  background: transparent;
19
43
  box-shadow: none;
20
44
  flex: 1;
21
45
  }
22
- .pk-input-group .pk-input:focus-visible { outline: none; box-shadow: none; border: 0; }
46
+ .pk-input-group .pk-input:focus-visible,
47
+ .pk-input-group .pk-textarea:focus-visible { outline: none; box-shadow: none; border: 0; }
48
+ .pk-input-group .pk-input:disabled,
49
+ .pk-input-group .pk-textarea:disabled { background: transparent; }
50
+ /* Tighten the control's near-side padding beside an inline addon. */
51
+ .pk-input-group:has(> .pk-input-group-addon.start) > .pk-input { padding-left: .375rem; }
52
+ .pk-input-group:has(> .pk-input-group-addon.end) > .pk-input { padding-right: .375rem; }
53
+
23
54
  .pk-input-group-addon {
24
55
  display: flex;
25
56
  align-items: center;
26
- gap: .25rem;
57
+ justify-content: center;
58
+ gap: .5rem;
27
59
  flex: none;
28
- padding: 0 .625rem;
60
+ cursor: text;
61
+ user-select: none;
62
+ padding-block: .375rem;
63
+ color: var(--pk-muted);
64
+ font-size: .875rem;
65
+ font-weight: 500;
66
+ }
67
+ .pk-input-group-addon.start { order: -1; padding-left: .5rem; }
68
+ .pk-input-group-addon.end { order: 99; padding-right: .5rem; }
69
+ .pk-input-group-addon.block-start,
70
+ .pk-input-group-addon.block-end {
71
+ width: 100%;
72
+ justify-content: flex-start;
73
+ padding-inline: .625rem;
74
+ }
75
+ .pk-input-group-addon.block-start { order: -1; padding-top: .5rem; }
76
+ .pk-input-group-addon.block-end { order: 99; padding-bottom: .5rem; }
77
+ .pk-input-group-addon.block-start.bordered { border-bottom: 1px solid var(--pk-border); padding-bottom: .5rem; }
78
+ .pk-input-group-addon.block-end.bordered { border-top: 1px solid var(--pk-border); padding-top: .5rem; }
79
+ .pk-input-group-addon svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
80
+ .pk-input-group-addon > kbd { border-radius: calc(var(--pk-radius) - 5px); }
81
+ .pk-input-group-addon.start:has(> .pk-button) { margin-left: -0.3rem; }
82
+ .pk-input-group-addon.end:has(> .pk-button) { margin-right: -0.3rem; }
83
+
84
+ .pk-button.pk-input-group-button {
85
+ gap: .25rem;
86
+ border-radius: calc(var(--pk-radius) - 3px);
87
+ box-shadow: none;
88
+ }
89
+ .pk-input-group-text {
90
+ display: flex;
91
+ align-items: center;
92
+ gap: .5rem;
29
93
  color: var(--pk-muted);
30
94
  font-size: .875rem;
31
95
  }
32
- .pk-input-group-addon.start { padding-right: 0; }
33
- .pk-input-group-addon.end { padding-left: 0; }
34
- .pk-input-group-addon svg { width: 1rem; height: 1rem; }
35
- .pk-input-group-text { color: var(--pk-muted); }
96
+ .pk-input-group-text svg { pointer-events: none; }
97
+
98
+ :root[data-theme="light"] .pk-input-group { background: transparent; }
99
+ :root[data-theme="light"] .pk-input-group:has(:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
100
+ :root[data-theme="light"] .pk-input-group:has([aria-invalid="true"]) {
101
+ border-color: var(--pk-red);
102
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
103
+ }
104
+ @media (prefers-color-scheme: light) {
105
+ :root[data-theme="system"] .pk-input-group { background: transparent; }
106
+ :root[data-theme="system"] .pk-input-group:has(:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
107
+ :root[data-theme="system"] .pk-input-group:has([aria-invalid="true"]) {
108
+ border-color: var(--pk-red);
109
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
110
+ }
111
+ }
@@ -1,8 +1,15 @@
1
1
  module PhlexKit
2
- # Leading/trailing addon inside an InputGroup — icons, text or small buttons.
3
- # See input_group.rb.
2
+ # Addon inside an InputGroup — icons, text or small buttons. `align:` places
3
+ # it: :start / :end inline beside the control (shadcn's inline-start/-end),
4
+ # :block_start / :block_end as full-width header/footer rows (the group
5
+ # stacks into a column). See input_group.rb.
4
6
  class InputGroupAddon < BaseComponent
5
- ALIGNS = { start: "start", end: "end" }.freeze
7
+ ALIGNS = {
8
+ start: "start",
9
+ end: "end",
10
+ block_start: "block-start",
11
+ block_end: "block-end"
12
+ }.freeze
6
13
 
7
14
  def initialize(align: :start, **attrs)
8
15
  @align = align.to_sym
@@ -10,7 +17,7 @@ module PhlexKit
10
17
  end
11
18
 
12
19
  def view_template(&)
13
- div(**mix({ class: "pk-input-group-addon #{ALIGNS.fetch(@align)}" }, @attrs), &)
20
+ div(**mix({ class: "pk-input-group-addon #{ALIGNS.fetch(@align)}", role: "group" }, @attrs), &)
14
21
  end
15
22
  end
16
23
  end
@@ -0,0 +1,18 @@
1
+ module PhlexKit
2
+ # Small button inside an InputGroupAddon, ported from shadcn/ui's
3
+ # InputGroupButton — a ghost xs Button tuned to sit flush in the shell
4
+ # (tighter radius, no shadow). Same knobs as Button (variant:/size:/icon:),
5
+ # defaulting to variant: :ghost, size: :xs. See input_group.rb.
6
+ class InputGroupButton < BaseComponent
7
+ def initialize(variant: :ghost, size: :xs, icon: false, **attrs)
8
+ @variant = variant
9
+ @size = size
10
+ @icon = icon
11
+ @attrs = attrs
12
+ end
13
+
14
+ def view_template(&block)
15
+ render Button.new(variant: @variant, size: @size, icon: @icon, **mix({ class: "pk-input-group-button" }, @attrs), &block)
16
+ end
17
+ end
18
+ end
@@ -1,10 +1,12 @@
1
- /* Co-located with input_otp.rb — UI::InputOTP, shadcn parity: joined cells
2
- (shared borders, outer corners rounded), ring on the active cell. */
1
+ /* Co-located with input_otp.rb — PhlexKit::InputOTP, shadcn parity: joined
2
+ cells (shared borders, outer corners rounded), ring on the active cell,
3
+ nova metrics (size-8 slots, ring-colored focus, theme-forked fill,
4
+ invalid states on slot + group). */
3
5
  .pk-input-otp { display: flex; align-items: center; gap: .5rem; }
4
6
  .pk-input-otp-group { display: flex; align-items: center; }
5
7
  .pk-input-otp-slot {
6
- width: 2.25rem;
7
- height: 2.25rem;
8
+ width: 2rem;
9
+ height: 2rem;
8
10
  border: 1px solid var(--pk-input);
9
11
  background: color-mix(in oklab, var(--pk-input) 30%, transparent);
10
12
  color: var(--pk-text);
@@ -26,7 +28,29 @@
26
28
  .pk-input-otp-slot:focus {
27
29
  position: relative;
28
30
  z-index: 1;
29
- border-color: var(--pk-brand);
31
+ border-color: var(--pk-ring);
30
32
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
31
33
  }
34
+ .pk-input-otp-slot[aria-invalid="true"] { border-color: var(--pk-red); }
35
+ .pk-input-otp-slot[aria-invalid="true"]:focus {
36
+ border-color: var(--pk-red);
37
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
38
+ }
39
+ .pk-input-otp-group:has([aria-invalid="true"]) {
40
+ border-radius: var(--pk-radius);
41
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
42
+ }
43
+ .pk-input-otp-slot:disabled { cursor: not-allowed; opacity: .5; }
44
+ :root[data-theme="light"] .pk-input-otp-slot { background: transparent; }
45
+ :root[data-theme="light"] .pk-input-otp-slot[aria-invalid="true"]:focus,
46
+ :root[data-theme="light"] .pk-input-otp-group:has([aria-invalid="true"]) {
47
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
48
+ }
49
+ @media (prefers-color-scheme: light) {
50
+ :root[data-theme="system"] .pk-input-otp-slot { background: transparent; }
51
+ :root[data-theme="system"] .pk-input-otp-slot[aria-invalid="true"]:focus,
52
+ :root[data-theme="system"] .pk-input-otp-group:has([aria-invalid="true"]) {
53
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
54
+ }
55
+ }
32
56
  .pk-input-otp-separator { color: var(--pk-muted); }
@@ -1,4 +1,8 @@
1
- /* Co-located with item.rb — UI::Item family, shadcn parity. */
1
+ /* Co-located with item.rb — PhlexKit::Item family, matched to shadcn/ui's
2
+ current item (nova): rounded-lg rows, sizes (sm keeps default paddings per
3
+ nova; xs tightens), media variants (icon glyph / square image thumb sized
4
+ by the item size), media nudges down beside a description, link items
5
+ (href:) hover-fill, Header stacks the item into a column. */
2
6
  .pk-item {
3
7
  display: flex;
4
8
  align-items: center;
@@ -6,27 +10,71 @@
6
10
  width: 100%;
7
11
  border: 1px solid transparent;
8
12
  border-radius: var(--pk-radius);
9
- padding: .625rem .875rem;
13
+ padding: .625rem .75rem;
10
14
  font-size: .875rem;
15
+ color: inherit;
16
+ text-decoration: none;
11
17
  }
18
+ a.pk-item:hover { background: var(--pk-surface-2); }
12
19
  .pk-item.outline { border-color: var(--pk-border); }
13
- .pk-item.muted { background: var(--pk-surface-2); }
20
+ .pk-item.muted { background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent); }
21
+ .pk-item.xs { gap: .5rem; padding: .5rem .625rem; }
22
+ .pk-item:has(> .pk-item-header),
23
+ .pk-item:has(> .pk-item-footer) { flex-direction: column; align-items: stretch; }
24
+
14
25
  .pk-item-media {
15
26
  display: flex;
16
27
  align-items: center;
17
28
  justify-content: center;
29
+ gap: .5rem;
18
30
  flex: none;
19
31
  color: var(--pk-muted);
20
32
  }
21
- .pk-item-media svg { width: 1rem; height: 1rem; }
22
- .pk-item-content { display: flex; flex: 1; flex-direction: column; gap: .125rem; min-width: 0; }
23
- .pk-item-title { font-weight: 500; line-height: 1.25; }
33
+ .pk-item:has(.pk-item-description) > .pk-item-media {
34
+ transform: translateY(.125rem);
35
+ align-self: flex-start;
36
+ }
37
+ .pk-item-media.icon svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
38
+ .pk-item-media.image {
39
+ width: 2.5rem;
40
+ height: 2.5rem;
41
+ overflow: hidden;
42
+ border-radius: calc(var(--pk-radius) - 4px);
43
+ }
44
+ .pk-item.sm .pk-item-media.image { width: 2rem; height: 2rem; }
45
+ .pk-item.xs .pk-item-media.image { width: 1.5rem; height: 1.5rem; }
46
+ .pk-item-media.image img { width: 100%; height: 100%; object-fit: cover; }
47
+
48
+ .pk-item-content { display: flex; flex: 1; flex-direction: column; gap: .25rem; min-width: 0; }
49
+ .pk-item.xs .pk-item-content { gap: 0; }
50
+ .pk-item-title {
51
+ display: flex;
52
+ align-items: center;
53
+ gap: .5rem;
54
+ font-weight: 500;
55
+ font-size: .875rem;
56
+ line-height: 1.375;
57
+ text-underline-offset: 4px;
58
+ }
24
59
  .pk-item-description {
25
60
  color: var(--pk-muted);
26
61
  font-size: .875rem;
27
- line-height: 1.375;
62
+ line-height: 1.5;
63
+ text-align: left;
28
64
  overflow: hidden;
29
65
  text-overflow: ellipsis;
30
66
  }
67
+ .pk-item.xs .pk-item-description { font-size: .75rem; }
31
68
  .pk-item-actions { display: flex; align-items: center; gap: .5rem; flex: none; }
32
- .pk-item-group { display: flex; flex-direction: column; width: 100%; gap: .5rem; }
69
+ .pk-item-header,
70
+ .pk-item-footer {
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: space-between;
74
+ gap: .5rem;
75
+ width: 100%;
76
+ }
77
+ .pk-item-separator { height: 1px; margin-block: .5rem; background: var(--pk-border); }
78
+ .pk-item-group { display: flex; flex-direction: column; width: 100%; gap: 1rem; }
79
+ .pk-item-group:has(.pk-item.sm) { gap: .625rem; }
80
+ .pk-item-group:has(.pk-item.xs) { gap: .5rem; }
@@ -1,18 +1,28 @@
1
1
  module PhlexKit
2
2
  # List row, ported from shadcn/ui's Item: a flex row of ItemMedia +
3
3
  # ItemContent(ItemTitle + ItemDescription) + ItemActions, optionally grouped
4
- # in an ItemGroup. variant :outline draws a bordered card row.
4
+ # in an ItemGroup (with ItemSeparator) or stacked under an ItemHeader.
5
+ # variant :outline draws a bordered card row; `size:` tightens (:sm/:xs);
6
+ # `href:` renders an <a> (their asChild link item — hover fills apply).
5
7
  # `.pk-item*` (item.css).
6
8
  class Item < BaseComponent
7
9
  VARIANTS = { default: nil, outline: "outline", muted: "muted" }.freeze
10
+ SIZES = { md: nil, sm: "sm", xs: "xs" }.freeze
8
11
 
9
- def initialize(variant: :default, **attrs)
12
+ def initialize(variant: :default, size: :md, href: nil, **attrs)
10
13
  @variant = variant.to_sym
14
+ @size = size.to_sym
15
+ @href = href
11
16
  @attrs = attrs
12
17
  end
13
18
 
14
19
  def view_template(&)
15
- div(**mix({ class: [ "pk-item", VARIANTS.fetch(@variant) ].compact.join(" ") }, @attrs), &)
20
+ classes = [ "pk-item", VARIANTS.fetch(@variant), SIZES.fetch(@size) ].compact.join(" ")
21
+ if @href
22
+ a(**mix({ class: classes, href: @href }, @attrs), &)
23
+ else
24
+ div(**mix({ class: classes }, @attrs), &)
25
+ end
16
26
  end
17
27
  end
18
28
  end
@@ -0,0 +1,8 @@
1
+ module PhlexKit
2
+ # Full-width footer band of an Item. Ported from shadcn/ui's ItemFooter.
3
+ # See item.rb.
4
+ class ItemFooter < BaseComponent
5
+ def initialize(**attrs) = (@attrs = attrs)
6
+ def view_template(&) = div(**mix({ class: "pk-item-footer" }, @attrs), &)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PhlexKit
2
+ # Full-width header band of an Item (e.g. a cover image) — the item stacks
3
+ # into a column when present. Ported from shadcn/ui's ItemHeader. See item.rb.
4
+ class ItemHeader < BaseComponent
5
+ def initialize(**attrs) = (@attrs = attrs)
6
+ def view_template(&) = div(**mix({ class: "pk-item-header" }, @attrs), &)
7
+ end
8
+ end
@@ -1,7 +1,17 @@
1
1
  module PhlexKit
2
- # Leading icon/avatar slot of an Item. See item.rb.
2
+ # Leading media slot of an Item. variant :icon sizes a glyph; :image crops a
3
+ # square thumbnail (sized by the item's size modifier); :default is bare.
4
+ # See item.rb.
3
5
  class ItemMedia < BaseComponent
4
- def initialize(**attrs) = (@attrs = attrs)
5
- def view_template(&) = div(**mix({ class: "pk-item-media" }, @attrs), &)
6
+ VARIANTS = { default: nil, icon: "icon", image: "image" }.freeze
7
+
8
+ def initialize(variant: :default, **attrs)
9
+ @variant = variant.to_sym
10
+ @attrs = attrs
11
+ end
12
+
13
+ def view_template(&)
14
+ div(**mix({ class: [ "pk-item-media", VARIANTS.fetch(@variant) ].compact.join(" ") }, @attrs), &)
15
+ end
6
16
  end
7
17
  end
@@ -0,0 +1,10 @@
1
+ module PhlexKit
2
+ # Hairline between Items in an ItemGroup, ported from shadcn/ui's
3
+ # ItemSeparator. See item.rb.
4
+ class ItemSeparator < BaseComponent
5
+ def initialize(**attrs) = (@attrs = attrs)
6
+ def view_template
7
+ div(**mix({ class: "pk-item-separator", role: "separator", aria: { hidden: true } }, @attrs))
8
+ end
9
+ end
10
+ end
@@ -15,3 +15,13 @@
15
15
  font-weight: 500;
16
16
  }
17
17
  .pk-kbd-group { display: inline-flex; align-items: center; gap: .25rem; font-family: inherit; }
18
+ .pk-kbd svg:not([class*="size-"]) { width: .75rem; height: .75rem; }
19
+ /* Inside an (inverted) tooltip the chip tints from the page background. */
20
+ .pk-tooltip-content .pk-kbd {
21
+ background: color-mix(in oklab, var(--pk-bg) 10%, transparent);
22
+ color: var(--pk-bg);
23
+ }
24
+ :root[data-theme="light"] .pk-tooltip-content .pk-kbd { background: color-mix(in oklab, var(--pk-bg) 20%, transparent); }
25
+ @media (prefers-color-scheme: light) {
26
+ :root[data-theme="system"] .pk-tooltip-content .pk-kbd { background: color-mix(in oklab, var(--pk-bg) 20%, transparent); }
27
+ }