diff --git a/lib/ruby_ui/sidebar/collapsiable_sidebar.rb b/lib/ruby_ui/sidebar/collapsiable_sidebar.rb new file mode 100644 index 00000000..2dbb90e0 --- /dev/null +++ b/lib/ruby_ui/sidebar/collapsiable_sidebar.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module RubyUI + class CollapsiableSidebar < Base + def initialize(side: :left, variant: :sidebar, collapsible: :offcanvas, open: true, **attrs) + @side = side + @variant = variant + @collapsible = collapsible + @open = open + super(**attrs) + end + + def view_template(&) + MobileSidebar(side: @side, **attrs, &) + div(**mix(sidebar_attrs, attrs)) do + div(**gap_element_attrs) + div(**content_wrapper_attrs) do + div(**content_attrs, &) + end + end + end + + private + + def sidebar_attrs + { + class: "group peer hidden text-sidebar-foreground md:block", + data: { + state: @open ? "expanded" : "collapsed", + collapsible: @open ? "" : @collapsible, + variant: @variant, + side: @side, + collapsible_kind: @collapsible, + ruby_ui__sidebar_target: "sidebar" + } + } + end + + def gap_element_attrs + { + class: [ + "relative w-[var(--sidebar-width)] bg-transparent transition-[width]", + "duration-200 ease-linear", + "group-data-[collapsible=offcanvas]:w-0", + "group-data-[side=right]:rotate-180", + variant_classes + ] + } + end + + def content_wrapper_attrs + { + class: [ + "fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)]", + "transition-[left,right,width] duration-200 ease-linear md:flex", + content_wrapper_side_classes, + content_wrapper_variant_classes + ] + } + end + + def content_attrs + { + class: [ + "flex h-full w-full flex-col bg-sidebar", + "group-data-[variant=floating]:rounded-lg", + "group-data-[variant=floating]:border", + "group-data-[variant=floating]:border-sidebar-border", + "group-data-[variant=floating]:shadow" + ], + data: { + sidebar: "sidebar" + } + } + end + + def variant_classes + if %i[floating inset].include?(@variant) + "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" + else + "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]" + end + end + + def content_wrapper_side_classes + return "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" if @side == :left + + "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]" + end + + def content_wrapper_variant_classes + if %i[floating inset].include?(@variant) + "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" + else + "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l" + end + end + end +end diff --git a/lib/ruby_ui/sidebar/mobile_sidebar.rb b/lib/ruby_ui/sidebar/mobile_sidebar.rb new file mode 100644 index 00000000..3ecc6976 --- /dev/null +++ b/lib/ruby_ui/sidebar/mobile_sidebar.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module RubyUI + class MobileSidebar < Base + SIDEBAR_WIDTH_MOBILE = "18rem" + + def initialize(side: :left, **attrs) + @side = side + super(**attrs) + end + + def view_template(&) + Sheet(**attrs) do + SheetContent( + side: @side, + class: "w-[var(--sidebar-width)] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden", + style: { + "--sidebar-width": SIDEBAR_WIDTH_MOBILE + }, + data: { + sidebar: "sidebar", + mobile: "true" + } + ) do + SheetHeader(class: "sr-only") do + SheetTitle { "Sidebar" } + SheetDescription { "Displays the mobile sidebar." } + end + div(class: "flex h-full w-full flex-col", &) + end + end + end + + private + + def default_attrs + { + data: { + ruby_ui__sidebar_target: "mobileSidebar", + action: "ruby--ui-sidebar:open->ruby-ui--sheet#open:self" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/non_collapsible_sidebar.rb b/lib/ruby_ui/sidebar/non_collapsible_sidebar.rb new file mode 100644 index 00000000..37f85f5b --- /dev/null +++ b/lib/ruby_ui/sidebar/non_collapsible_sidebar.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyUI + class NonCollpapsibleSidebar < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "flex h-full w-[var(--sidebar-width)] flex-col bg-sidebar text-sidebar-foreground" + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar.rb b/lib/ruby_ui/sidebar/sidebar.rb new file mode 100644 index 00000000..eef92c83 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module RubyUI + class Sidebar < Base + SIDES = %i[left right].freeze + VARIANTS = %i[sidebar floating inset].freeze + COLLAPSIBLES = %i[offcanvas icon none].freeze + + def initialize(side: :left, variant: :sidebar, collapsible: :offcanvas, open: true, **attrs) + raise ArgumentError, "Invalid side: #{side}." unless SIDES.include?(side.to_sym) + raise ArgumentError "Invalid variant: #{variant}." unless VARIANTS.include?(variant.to_sym) + raise ArgumentError, "Invalid collapsible: #{collapsible}." unless COLLAPSIBLES.include?(collapsible.to_sym) + + @side = side.to_sym + @variant = variant.to_sym + @collapsible = collapsible.to_sym + @open = open + super(**attrs) + end + + def view_template(&) + if @collapsible == :none + NonCollapsiableSidebar(**attrs, &) + else + CollapsiableSidebar(side: @side, variant: @variant, collapsible: @collapsible, open: @open, **attrs, &) + end + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_content.rb b/lib/ruby_ui/sidebar/sidebar_content.rb new file mode 100644 index 00000000..0874ce71 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_content.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarContent < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + data: { + sidebar: "content" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_controller.js b/lib/ruby_ui/sidebar/sidebar_controller.js new file mode 100644 index 00000000..c789438d --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_controller.js @@ -0,0 +1,67 @@ +import { Controller } from "@hotwired/stimulus"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const State = { + EXPANDED: "expanded", + COLLAPSED: "collapsed", +}; +const MOBILE_BREAKPOINT = 768; + +export default class extends Controller { + static targets = ["sidebar", "mobileSidebar"]; + + sidebarTargetConnected() { + const { state, collapsibleKind } = this.sidebarTarget.dataset; + + this.open = state === State.EXPANDED; + this.collapsibleKind = collapsibleKind; + } + + toggle(e) { + e.preventDefault(); + + if (this.#isMobile()) { + this.#openMobileSidebar(); + + return; + } + + this.open = !this.open; + this.onToggle(); + } + + onToggle() { + this.#updateSidebarState(); + this.#persistSidebarState(); + } + + #updateSidebarState() { + if (!this.hasSidebarTarget) { + return; + } + + const { dataset } = this.sidebarTarget; + + dataset.state = this.open ? State.EXPANDED : State.COLLAPSED; + dataset.collapsible = this.open ? "" : this.collapsibleKind; + } + + #persistSidebarState() { + document.cookie = `${SIDEBAR_COOKIE_NAME}=${this.open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + } + + #isMobile() { + return window.innerWidth < MOBILE_BREAKPOINT; + } + + #openMobileSidebar() { + if (!this.hasMobileSidebarTarget) { + return; + } + + this.mobileSidebarTarget.dispatchEvent( + new CustomEvent("ruby--ui-sidebar:open"), + ); + } +} diff --git a/lib/ruby_ui/sidebar/sidebar_footer.rb b/lib/ruby_ui/sidebar/sidebar_footer.rb new file mode 100644 index 00000000..3df63e2b --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_footer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarFooter < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "flex flex-col gap-2 p-2", + data: { + sidebar: "footer" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_group.rb b/lib/ruby_ui/sidebar/sidebar_group.rb new file mode 100644 index 00000000..54e71ece --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_group.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarGroup < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "relative flex w-full min-w-0 flex-col p-2", + data: { + sidebar: "group" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_group_action.rb b/lib/ruby_ui/sidebar/sidebar_group_action.rb new file mode 100644 index 00000000..b6833d56 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_group_action.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarGroupAction < Base + def initialize(as: :button, **attrs) + @as = as + super(**attrs) + end + + def view_template(&) + tag(@as, **attrs, &) + end + + private + + def default_attrs + { + class: [ + "absolute right-3 top-3.5 flex aspect-square w-5 items-center", + "justify-center rounded-md p-0 text-sidebar-foreground", + "outline-none ring-sidebar-ring transition-transform", + "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + "focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "after:absolute after:-inset-2 after:md:hidden", + "group-data-[collapsible=icon]:hidden" + ], + data: { + sidebar: "group-action" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_group_content.rb b/lib/ruby_ui/sidebar/sidebar_group_content.rb new file mode 100644 index 00000000..d4c8de7c --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_group_content.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarGroupContent < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "w-full text-sm", + data: { + sidebar: "group-content" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_group_label.rb b/lib/ruby_ui/sidebar/sidebar_group_label.rb new file mode 100644 index 00000000..77ab53a8 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_group_label.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarGroupLabel < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: [ + "flex h-8 shrink-0 items-center rounded-md px-2 text-xs", + "font-medium text-sidebar-foreground/70 outline-none", + "ring-sidebar-ring transition-[margin,opacity] duration-200", + "ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0" + ], + data: { + sidebar: "group-label" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_header.rb b/lib/ruby_ui/sidebar/sidebar_header.rb new file mode 100644 index 00000000..ca2d1601 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_header.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarHeader < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "flex flex-col gap-2 p-2", + data: { + sidebar: "header" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_input.rb b/lib/ruby_ui/sidebar/sidebar_input.rb new file mode 100644 index 00000000..89bfaed5 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_input.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarInput < Base + def view_template(&) + Input(**attrs, &) + end + + private + + def default_attrs + { + class: "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", + data: { + sidebar: "input" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_inset.rb b/lib/ruby_ui/sidebar/sidebar_inset.rb new file mode 100644 index 00000000..e03d3fcf --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_inset.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarInset < Base + def view_template(&) + main(**attrs, &) + end + + private + + def default_attrs + { + class: [ + "relative flex w-full flex-1 flex-col bg-background", + "md:peer-data-[variant=inset]:m-2", + "md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2", + "md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl", + "md:peer-data-[variant=inset]:shadow" + ] + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_menu.rb b/lib/ruby_ui/sidebar/sidebar_menu.rb new file mode 100644 index 00000000..959db3ef --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_menu.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarMenu < Base + def view_template(&) + ul(**attrs, &) + end + + private + + def default_attrs + { + class: "flex w-full min-w-0 flex-col gap-1", + data: { + sidebar: "menu" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_menu_action.rb b/lib/ruby_ui/sidebar/sidebar_menu_action.rb new file mode 100644 index 00000000..21c48d09 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_menu_action.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarMenuAction < Base + def initialize(as: :button, show_on_hover: false, **attrs) + @as = as + super(**attrs) + end + + def view_template(&) + tag(@as, **attrs, &) + end + + private + + def default_attrs + { + class: [ + "absolute right-1 top-1.5 flex aspect-square w-5 items-center", + "justify-center rounded-md p-0 text-sidebar-foreground outline-none", + "ring-sidebar-ring transition-transform hover:bg-sidebar-accent", + "hover:text-sidebar-accent-foreground focus-visible:ring-2", + "peer-hover/menu-button:text-sidebar-accent-foreground", + "[&>svg]:size-4 [&>svg]:shrink-0", + "after:absolute after:-inset-2 after:md:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + show_on_hover_classes + ], + data: { + sidebar: "menu-action" + } + } + end + + def show_on_hover_classes + return unless @show_on_hover + + [ + "group-focus-within/menu-item:opacity-100", + "group-hover/menu-item:opacity-100 data-[state=open]:opacity-100", + "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0" + ].join(" ") + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_menu_badge.rb b/lib/ruby_ui/sidebar/sidebar_menu_badge.rb new file mode 100644 index 00000000..101b0bbb --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_menu_badge.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarMenuBadge < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: [ + "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none", + "items-center justify-center rounded-md px-1 text-xs font-medium", + "tabular-nums text-sidebar-foreground", + "peer-hover/menu-button:text-sidebar-accent-foreground", + "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden" + ], + data: { + sidebar: "menu-badge" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_menu_button.rb b/lib/ruby_ui/sidebar/sidebar_menu_button.rb new file mode 100644 index 00000000..5480c276 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_menu_button.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarMenuButton < Base + VARIANT_CLASSES = { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]" + }.freeze + + SIZE_CLASSES = { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0" + }.freeze + + def initialize(as: :button, variant: :default, size: :default, active: false, **attrs) + raise ArgumentError, "Invalid variant: #{variant}" unless VARIANT_CLASSES.key?(variant) + raise ArgumentError, "Invalid size: #{size}" unless SIZE_CLASSES.key?(size) + + @as = as + @variant = variant + @size = size + @active = active + super(**attrs) + end + + def view_template(&) + tag(@as, **attrs, &) + end + + private + + def default_attrs + { + class: [ + "peer/menu-button flex w-full items-center gap-2 overflow-hidden", + "rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring", + "transition-[width,height,padding] hover:bg-sidebar-accent", + "hover:text-sidebar-accent-foreground focus-visible:ring-2", + "active:bg-sidebar-accent active:text-sidebar-accent-foreground", + "disabled:pointer-events-none disabled:opacity-50", + "group-has-[[data-sidebar=menu-action]]/menu-item:pr-8", + "aria-disabled:pointer-events-none aria-disabled:opacity-50", + "data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium", + "data-[active=true]:text-sidebar-accent-foreground", + "data-[state=open]:hover:bg-sidebar-accent", + "data-[state=open]:hover:text-sidebar-accent-foreground", + "group-data-[collapsible=icon]:!size-8", + "group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate", + "[&>svg]:size-4 [&>svg]:shrink-0", + VARIANT_CLASSES[@variant], + SIZE_CLASSES[@size] + ], + data: { + sidebar: "menu-button", + size: @size, + active: @active.to_s + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_menu_item.rb b/lib/ruby_ui/sidebar/sidebar_menu_item.rb new file mode 100644 index 00000000..4412c738 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_menu_item.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarMenuItem < Base + def view_template(&) + ul(**attrs, &) + end + + private + + def default_attrs + { + class: "group/menu-item relative", + data: { + sidebar: "menu-item" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_menu_skeleton.rb b/lib/ruby_ui/sidebar/sidebar_menu_skeleton.rb new file mode 100644 index 00000000..acc42853 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_menu_skeleton.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarMenuSkeleton < Base + def initialize(show_icon: false, **attrs) + @show_icon = show_icon + super(**attrs) + end + + def view_template(&) + div(**attrs) do + Skeleton(class: "size-4 rounded-md", data: {sidebar: "menu-skeleton-icon"}) if @show_icon + Skeleton( + class: "h-4 max-w-[var(--skeleton-width)] flex-1", + data: {sidebar: "menu-skeleton-text"}, + style: {"--skeleton-width" => "#{skeleton_width}%"} + ) + end + end + + private + + def default_attrs + { + class: "flex h-8 items-center gap-2 rounded-md px-2", + data: { + sidebar: "menu-skeleton" + } + } + end + + def skeleton_width + @_skeleton_width ||= rand(50..89) + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_menu_sub.rb b/lib/ruby_ui/sidebar/sidebar_menu_sub.rb new file mode 100644 index 00000000..573ae854 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_menu_sub.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarMenuSub < Base + def view_template(&) + ul(**attrs, &) + end + + private + + def default_attrs + { + class: [ + "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l", + "border-sidebar-border px-2.5 py-0.5", + "group-data-[collapsible=icon]:hidden" + ], + data: { + sidebar: "menu-sub" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_menu_sub_button.rb b/lib/ruby_ui/sidebar/sidebar_menu_sub_button.rb new file mode 100644 index 00000000..1c4abef6 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_menu_sub_button.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarMenuSubButton < Base + SIZE_CLASSES = { + sm: "text-xs", + md: "text-sm" + }.freeze + + def initialize(as: :button, size: :md, active: false, **attrs) + raise ArgumentError, "Invalid size: #{size}" unless SIZE_CLASSES.key?(size) + + @as = as + @size = size + @active = active + super(**attrs) + end + + def view_template(&) + tag(@as, **attrs, &) + end + + private + + def default_attrs + { + class: [ + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden", + "rounded-md px-2 text-sidebar-foreground outline-none", + "ring-sidebar-ring hover:bg-sidebar-accent", + "hover:text-sidebar-accent-foreground focus-visible:ring-2", + "active:bg-sidebar-accent active:text-sidebar-accent-foreground", + "disabled:pointer-events-none disabled:opacity-50", + "aria-disabled:pointer-events-none aria-disabled:opacity-50", + "[&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + "[&>svg]:text-sidebar-accent-foreground", + "data-[active=true]:bg-sidebar-accent", + "data-[active=true]:text-sidebar-accent-foreground", + "group-data-[collapsible=icon]:hidden", + SIZE_CLASSES[@size] + ], + data: { + sidebar: "menu-sub-button", + size: @size, + active: @active.to_s + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_menu_sub_item.rb b/lib/ruby_ui/sidebar/sidebar_menu_sub_item.rb new file mode 100644 index 00000000..424ed78e --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_menu_sub_item.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarMenuSubItem < Base + def view_template(&) + li(**attrs, &) + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_rail.rb b/lib/ruby_ui/sidebar/sidebar_rail.rb new file mode 100644 index 00000000..c4e64f26 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_rail.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarRail < Base + def view_template(&) + button(**attrs, &) + end + + private + + def default_attrs + { + class: [ + "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all", + "ease-linear after:absolute after:inset-y-0 after:left-1/2", + "after:w-[2px] hover:after:bg-sidebar-border", + "group-data-[side=left]:-right-4 group-data-[side=right]:left-0", + "sm:flex [[data-side=left]_&]:cursor-w-resize", + "[[data-side=right]_&]:cursor-e-resize", + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize", + "[[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "group-data-[collapsible=offcanvas]:translate-x-0", + "group-data-[collapsible=offcanvas]:after:left-full", + "group-data-[collapsible=offcanvas]:hover:bg-sidebar", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2" + ], + data: { + sidebar: "rail", + tabindex: "-1", + action: "click->ruby-ui--sidebar#toggle" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_separator.rb b/lib/ruby_ui/sidebar/sidebar_separator.rb new file mode 100644 index 00000000..fb00e609 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_separator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarSeparator < Base + def view_template(&) + Separator(**attrs, &) + end + + private + + def default_attrs + { + class: "mx-2 w-auto bg-sidebar-border", + data: { + sidebar: "separator" + } + } + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_trigger.rb b/lib/ruby_ui/sidebar/sidebar_trigger.rb new file mode 100644 index 00000000..64dfaf78 --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_trigger.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarTrigger < Base + def view_template(&) + Button(variant: :ghost, size: :icon, **attrs) do + panel_left_icon + span(class: "sr-only") { "Toggle Sidebar" } + end + end + + private + + def default_attrs + { + class: "h-7 w-7 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + data: { + sidebar: "trigger", + action: "click->ruby-ui--sidebar#toggle" + } + } + end + + def panel_left_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-panel-left" + ) do |s| + s.rect(width: "18", height: "18", x: "3", y: "3", rx: "2") + s.path(d: "M9 3v18") + end + end + end +end diff --git a/lib/ruby_ui/sidebar/sidebar_wrapper.rb b/lib/ruby_ui/sidebar/sidebar_wrapper.rb new file mode 100644 index 00000000..f964860c --- /dev/null +++ b/lib/ruby_ui/sidebar/sidebar_wrapper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module RubyUI + class SidebarWrapper < Base + SIDEBAR_WIDTH = "16rem" + SIDEBAR_WIDTH_ICON = "3rem" + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "group/sidebar-wrapper [&:has([data-variant=inset])]:bg-sidebar flex min-h-svh w-full", + style: "--sidebar-width: #{SIDEBAR_WIDTH}; --sidebar-width-icon: #{SIDEBAR_WIDTH_ICON};", + data: { + controller: "ruby-ui--sidebar" + } + } + end + end +end diff --git a/test/ruby_ui/sidebar_test.rb b/test/ruby_ui/sidebar_test.rb new file mode 100644 index 00000000..08a1c87b --- /dev/null +++ b/test/ruby_ui/sidebar_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::SidebarTest < ComponentTest + def test_render_with_all_items + output = phlex do + RubyUI.SidebarWrapper do + RubyUI.Sidebar do + RubyUI.SidebarHeader do + RubyUI.SidebarGroup do + RubyUI.SidebarGroupContent do + RubyUI.SidebarInput(id: "search", placeholder: "Search the docs") + end + end + end + RubyUI.SidebarContent do + RubyUI.SidebarGroup do + RubyUI.SidebarGroupLabel { "Application" } + RubyUI.SidebarGroupAction { "Group Action" } + RubyUI.SidebarGroupContent do + RubyUI.SidebarMenu do + RubyUI.SidebarMenuItem do + RubyUI.SidebarMenuSub do + RubyUI.SidebarMenuSubItem do + RubyUI.SidebarMenuSubButton(as: :a, href: "#") { "Sub Item 1" } + end + end + end + RubyUI.SidebarMenuItem do + RubyUI.SidebarMenuButton(as: :a, href: "#") { "Settings" } + RubyUI.SidebarMenuAction { "Settings" } + end + RubyUI.SidebarMenuItem do + RubyUI.SidebarMenuButton { "Dashboard" } + RubyUI.SidebarMenuAction { "Dashboard" } + RubyUI.SidebarMenuBadge { "Dashboard Badge" } + end + RubyUI.SidebarMenuItem do + RubyUI.SidebarMenuSkeleton() + end + end + end + end + RubyUI.SidebarSeparator() + end + RubyUI.SidebarFooter { "Footer" } + RubyUI.SidebarRail() + end + RubyUI.SidebarInset do + RubyUI.SidebarTrigger() + end + end + end + + assert_match(/Search the docs/, output) + assert_match(/Application/, output) + assert_match(/Group Action/, output) + assert_match(/Sub Item 1/, output) + assert_match(/Settings/, output) + assert_match(/Dashboard/, output) + assert_match(/Dashboard Badge/, output) + assert_match(/Footer/, output) + end + + def test_with_side_right + output = phlex do + RubyUI.Sidebar(side: :right) + end + + assert_match(/data-side="right"/, output) + end + + def test_with_variant_floating + output = phlex do + RubyUI.Sidebar(variant: :floating) + end + + assert_match(/data-variant="floating"/, output) + end + + def test_with_collapsible_icon + output = phlex do + RubyUI.Sidebar(collapsible: :icon) + end + + assert_match(/data-collapsible-kind="icon"/, output) + end + + def test_with_open_false + output = phlex do + RubyUI.Sidebar(open: false) + end + + assert_match(/data-state="collapsed"/, output) + end + + def test_with_collapsible_offcanvas + output = phlex do + RubyUI.Sidebar(collapsible: :offcanvas) + end + + assert_match(/data-collapsible-kind="offcanvas"/, output) + end +end