diff --git a/app/components/primer/beta/page_layout.html.erb b/app/components/primer/beta/page_layout.html.erb new file mode 100644 index 0000000000..4e261ecfdd --- /dev/null +++ b/app/components/primer/beta/page_layout.html.erb @@ -0,0 +1,15 @@ +<%= render Primer::BaseComponent.new(**@system_arguments) do %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "PageLayout-wrapper #{@wrapper_sizing_class}")) do %> + <%= header_region %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "PageLayout-columns")) do %> + <% if pane_region.render_first? %> + <%= pane_region %> + <%= content_region %> + <% else %> + <%= content_region %> + <%= pane_region %> + <% end %> + <% end %> + <%= footer_region %> + <% end %> +<% end %> diff --git a/app/components/primer/beta/page_layout.rb b/app/components/primer/beta/page_layout.rb new file mode 100644 index 0000000000..f4a5e3d5cb --- /dev/null +++ b/app/components/primer/beta/page_layout.rb @@ -0,0 +1,504 @@ +# frozen_string_literal: true + +module Primer + module Beta + # `PageLayout` provides foundational patterns for responsive pages. `PageLayout` can be used for simple two-column pages, or it can be nested to provide flexible 3-column experiences. + # + # On smaller screens, `PageLayout` uses vertically stacked rows to display content. `PageLayout` is responsible for determining the arrangement of the main regions that compose a page. + # This means anything after the global and local headers (i.e. repo or org headers), and anything before the global footer. + # + # `PageLayout` controls the page spacings, supports header and footer regions, provides different styles of panes, and handles responsive strategies. + # + # `PageLayout` flows as both column, when there's enough horizontal space to render both `Content` and `Pane` side-by-side (on a desktop of tablet device, per instance); + # or it flows as a row, when `Content` and `Pane` are stacked vertically (e.g. on a mobile device). + # `PageLayout` should always work in any screen size. + # + # @accessibility + # Keyboard navigation follows the markup order. Decide carefully how the focus order should be be by deciding whether + # `content_region` or `pane_region` comes first in code. This is determined by the `position` argrument to the `pane_region` slot. + class PageLayout < Primer::Component + status :beta + + WRAPPER_SIZING_DEFAULT = :fluid + WRAPPER_SIZING_MAPPINGS = { + WRAPPER_SIZING_DEFAULT => "", + :md => "container-md", + :lg => "container-lg", + :xl => "container-xl" + }.freeze + WRAPPER_SIZING_OPTIONS = WRAPPER_SIZING_MAPPINGS.keys.freeze + + OUTER_SPACING_DEFAULT = :normal + OUTER_SPACING_MAPPINGS = { + OUTER_SPACING_DEFAULT => "PageLayout--outerSpacing-normal", + :condensed => "PageLayout--outerSpacing-condensed" + }.freeze + OUTER_SPACING_OPTIONS = OUTER_SPACING_MAPPINGS.keys.freeze + + COLUMN_GAP_DEFAULT = :normal + COLUMN_GAP_MAPPINGS = { + COLUMN_GAP_DEFAULT => "PageLayout--columnGap-normal", + :condensed => "PageLayout--columnGap-condensed" + }.freeze + COLUMN_GAP_OPTIONS = COLUMN_GAP_MAPPINGS.keys.freeze + + ROW_GAP_DEFAULT = :normal + ROW_GAP_MAPPINGS = { + ROW_GAP_DEFAULT => "PageLayout--rowGap-normal", + :condensed => "PageLayout--rowGap-condensed" + }.freeze + ROW_GAP_OPTIONS = ROW_GAP_MAPPINGS.keys.freeze + + RESPONSIVE_VARIANT_DEFAULT = :stack_regions + RESPONSIVE_VARIANT_MAPPINGS = { + RESPONSIVE_VARIANT_DEFAULT => "PageLayout--responsive-stackRegions", + :separate_regions => "PageLayout--responsive-separateRegions" + }.freeze + RESPONSIVE_VARIANT_OPTIONS = RESPONSIVE_VARIANT_MAPPINGS.keys.freeze + + PRIMARY_REGION_DEFAULT = :content + PRIMARY_REGION_MAPPINGS = { + PRIMARY_REGION_DEFAULT => "PageLayout--responsive-primary-content", + :pane => "PageLayout--responsive-primary-pane" + }.freeze + PRIMARY_REGION_OPTIONS = PRIMARY_REGION_MAPPINGS.keys.freeze + + HEADER_DIVIDER_NARROW_DEFAULT = :inherit + HEADER_DIVIDER_NARROW_MAPPINGS = { + HEADER_DIVIDER_NARROW_DEFAULT => "PageLayout-region--dividerNarrow-line-after", + :none => "", + :line => "PageLayout-region--dividerNarrow-line-after", + :filled => "PageLayout-region--dividerNarrow-filled-after" + }.freeze + HEADER_DIVIDER_NARROW_OPTIONS = HEADER_DIVIDER_NARROW_MAPPINGS.keys.freeze + + FOOTER_DIVIDER_NARROW_DEFAULT = :inherit + FOOTER_DIVIDER_NARROW_MAPPINGS = { + FOOTER_DIVIDER_NARROW_DEFAULT => "PageLayout-region--dividerNarrow-line-before", + :none => "", + :line => "PageLayout-region--dividerNarrow-line-before", + :filled => "PageLayout-region--dividerNarrow-filled-before" + }.freeze + FOOTER_DIVIDER_NARROW_OPTIONS = FOOTER_DIVIDER_NARROW_MAPPINGS.keys.freeze + + # The layout's content. + # + # @param width [Symbol] <%= one_of(Primer::Beta::PageLayout::Content::WIDTH_OPTIONS) %> + # @param tag [Symbol] <%= one_of(Primer::Beta::PageLayout::Content::TAG_OPTIONS) %> + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + renders_one :content_region, "Primer::Beta::PageLayout::Content" + + # The layout's header. + # + # @param divider [Boolean] Whether to show a header divider + # @param divider_narrow [Symbol] Whether to show a divider below the `header` region if in responsive mode. <%= one_of(Primer::Beta::PageLayout::HEADER_DIVIDER_NARROW_OPTIONS) %> + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + renders_one :header_region, lambda { |divider: false, divider_narrow: :line, **header_system_arguments| + header_system_arguments[:classes] = class_names( + header_system_arguments[:classes], + { "PageLayout-header--hasDivider" => divider }, + { HEADER_DIVIDER_NARROW_MAPPINGS[fetch_or_fallback(HEADER_DIVIDER_NARROW_OPTIONS, divider_narrow, HEADER_DIVIDER_NARROW_DEFAULT)] => divider }, + "PageLayout-header", + "PageLayout-region" + ) + + Primer::BaseComponent.new(tag: :div, **header_system_arguments) + } + + # The layout's footer. + # + # @param divider [Boolean] Whether to show a footer divider + # @param divider_narrow [Symbol] Whether to show a divider below the `footer` region if in responsive mode. <%= one_of(Primer::Beta::PageLayout::FOOTER_DIVIDER_NARROW_OPTIONS) %> + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + renders_one :footer_region, lambda { |divider: false, divider_narrow: FOOTER_DIVIDER_NARROW_DEFAULT, **footer_system_arguments| + # These classes have to be set in the parent `Layout` element, so we modify its system arguments. + + footer_system_arguments[:classes] = class_names( + footer_system_arguments[:classes], + { "PageLayout-footer--hasDivider" => divider }, + { FOOTER_DIVIDER_NARROW_MAPPINGS[fetch_or_fallback(FOOTER_DIVIDER_NARROW_OPTIONS, divider_narrow, FOOTER_DIVIDER_NARROW_DEFAULT)] => divider }, + "PageLayout-footer", + "PageLayout-region" + ) + + Primer::BaseComponent.new(tag: :div, **footer_system_arguments) + } + + # The layout's pane. + # + # @param width [Symbol] <%= one_of(Primer::Beta::PageLayout::Pane::WIDTH_OPTIONS) %> + # @param position [Symbol] Pane placement when `Layout` is in column modes. <%= one_of(Primer::Beta::PageLayout::Pane::POSITION_OPTIONS) %> + # @param position_narrow [Symbol] Pane placement when `Layout` is in column modes. <%= one_of(Primer::Beta::PageLayout::Pane::POSITION_NARROW_OPTIONS) %> + # @param divider [Boolean] Whether to show a pane line divider. + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + renders_one :pane_region, lambda { | + width: Pane::WIDTH_DEFAULT, + position: Pane::POSITION_DEFAULT, + position_narrow: Pane::POSITION_NARROW_DEFAULT, + divider: false, + **pane_system_arguments + | + position_narrow = position if position_narrow == Pane::POSITION_NARROW_DEFAULT + # These classes have to be set in the parent `Layout` element, so we modify its system arguments. + @system_arguments[:classes] = class_names( + @system_arguments[:classes], + Pane::POSITION_MAPPINGS[fetch_or_fallback(Pane::POSITION_OPTIONS, position, Pane::POSITION_DEFAULT)], + Pane::WIDTH_MAPPINGS[fetch_or_fallback(Pane::WIDTH_OPTIONS, width, Pane::WIDTH_DEFAULT)], + { Pane::POSITION_NARROW_MAPPINGS[fetch_or_fallback(Pane::POSITION_NARROW_OPTIONS, position_narrow, Pane::POSITION_NARROW_DEFAULT)] => @responsive_variant == :stack_regions }, + { "PageLayout--hasPaneDivider" => divider } + ) + + Pane.new(divider: divider, position: position, **pane_system_arguments) + } + + # @example Default + # + # <%= render(Primer::Beta::PageLayout.new) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # + # @example Header and footer + # + # <%= render(Primer::Beta::PageLayout.new) do |c| %> + # <% c.header_region(border: true) { "Header" } %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% c.footer_region(border: true) { "Footer" } %> + # <% end %> + # + # @example Wrapper sizing + # + # @description + # When `:fluid` the layout will be set to full width. When the other sizing options are used the layout will be centered with corresponding widths. + # + # - `:fluid`: full width + # - `:md`: max-width: 768px + # - `:lg`: max-width: 1012px + # - `:xl`: max-width: 1280px + # + # @code + # <%= render(Primer::Beta::PageLayout.new(wrapper_sizing: :fluid)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(wrapper_sizing: :md)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(wrapper_sizing: :lg)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(wrapper_sizing: :xl)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # + # @example Outer spacing + # + # @description + # Sets wrapper margins surrounding the component to distance itself from the viewport edges. + # + # - `:condensed` keeps the margin at 16px. + # - `:normal`` sets the margin to 16px, and to 24px on lg breakpoints and above. + # + # @code + # <%= render(Primer::Beta::PageLayout.new(outer_spacing: :condensed)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(outer_spacing: :normal)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # + # @example Column gap + # + # @description + # Sets the gap between columns to distance them from each other. + # + # - `:condensed` keeps the gap always at 16px. + # - `:normal` sets the gap to 16px, and to 24px on lg breakpoints and above. + # + # @code + # <%= render(Primer::Beta::PageLayout.new(column_gap: :condensed)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(column_gap: :normal)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # + # @example Row gap + # + # @description + # Sets the gap below the header and above the footer. + # + # - `:condensed` keeps the gap always at 16px. + # - `:normal` sets the gap to 16px, and to 24px on lg breakpoints and above. + # + # @code + # <%= render(Primer::Beta::PageLayout.new(row_gap: :condensed)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(row_gap: :normal)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # + # @example Pane widths + # + # @description + # Sets the pane width. The width is predetermined according to the breakpoint instead of it being percentage-based. + # + # - `default`: + # - `narrow`: + # - `wide`: + # + # When flowing as a row, `Pane` takes the full width. + # + # @code + # <%= render(Primer::Beta::PageLayout.new) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(width: :default, border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(mt: 5)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(width: :narrow, border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(mt: 5)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(width: :wide, border: true) { "Pane" } %> + # <% end %> + # + # @example Pane position + # + # @description + # Use `start` for panes that manipulate local navigation, while right-aligned `end` is useful for metadata and other auxiliary information. + # + # @code + # <%= render(Primer::Beta::PageLayout.new) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(position: :start, border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new( mt: 5)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(position: :end, border: true) { "Pane" } %> + # <% end %> + # + # @example Pane resposive position + # + # @description + # Defines the position of the pane in the responsive layout. + # + # - `:start` puts the pane above content + # - `:end` puts it below content. + # - `:inherit` uses the same value from `position` + # + # @code + # <%= render(Primer::Beta::PageLayout.new(mt: 5)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(position_narrow: :inherit, border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(position_narrow: :start, border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(mt: 5)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(position_narrow: :end, border: true) { "Pane" } %> + # <% end %> + # + # @example Header + # + # @description + # You can add an optional header to the layout and have spacing and positioning taken care of for you. + # You can optionally add a divider to the header. + # + # @code + # <%= render(Primer::Beta::PageLayout.new) do |c| %> + # <% c.header_region(border: true) { "Header" } %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new) do |c| %> + # <% c.header_region(divider: true, border: true) { "Header" } %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # + # @example Footer + # + # @description + # You can add an optional footer to the layout and have spacing and positioning taken care of for you. + # You can optionally add a divider to the footer. + # + # @code + # <%= render(Primer::Beta::PageLayout.new) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% c.footer_region(border: true) { "Header" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% c.footer_region(divider: true, border: true) { "Header" } %> + # <% end %> + # + # @param wrapper_sizing [Symbol] Define the maximum width of the component. `:fluid` sets it to full-width. Other values center Layout horizontally. <%= one_of(Primer::Beta::PageLayout::WRAPPER_SIZING_OPTIONS) %> + # @param outer_spacing [Symbol] Sets wrapper margins surrounding the component to distance itself from the viewport edges. <%= one_of(Primer::Beta::PageLayout::OUTER_SPACING_OPTIONS) %> + # @param column_gap [Symbol] Sets gap between columns. <%= one_of(Primer::Beta::PageLayout::COLUMN_GAP_OPTIONS) %> + # @param row_gap [Symbol] Sets the gap below the header and above the footer. <%= one_of(Primer::Beta::PageLayout::ROW_GAP_OPTIONS) %> + # @param responsive_variant [Symbol] Defines how the layout component adapts to smaller viewports. `:stack_regions` presents the content in a vertical flow, with pane and content vertically arranged. `:separate_regions` presents pane and content as different pages on smaller viewports. + # @param primary_region [Symbol] When `responsive_variant` is set to `:separate_regions`, defines which region appears first on small viewports. `:content` is default. + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + def initialize( + wrapper_sizing: WRAPPER_SIZING_DEFAULT, + outer_spacing: OUTER_SPACING_DEFAULT, + column_gap: COLUMN_GAP_DEFAULT, + row_gap: ROW_GAP_DEFAULT, + responsive_variant: RESPONSIVE_VARIANT_DEFAULT, + primary_region: PRIMARY_REGION_DEFAULT, + **system_arguments + ) + + @wrapper_sizing_class = WRAPPER_SIZING_MAPPINGS[fetch_or_fallback(WRAPPER_SIZING_OPTIONS, wrapper_sizing, WRAPPER_SIZING_DEFAULT)] + @responsive_variant = responsive_variant + @system_arguments = system_arguments + @system_arguments[:tag] = :div + @system_arguments[:classes] = class_names( + "PageLayout", + OUTER_SPACING_MAPPINGS[fetch_or_fallback(OUTER_SPACING_OPTIONS, outer_spacing, OUTER_SPACING_DEFAULT)], + COLUMN_GAP_MAPPINGS[fetch_or_fallback(COLUMN_GAP_OPTIONS, column_gap, COLUMN_GAP_DEFAULT)], + ROW_GAP_MAPPINGS[fetch_or_fallback(ROW_GAP_OPTIONS, row_gap, ROW_GAP_DEFAULT)], + RESPONSIVE_VARIANT_MAPPINGS[fetch_or_fallback(RESPONSIVE_VARIANT_OPTIONS, @responsive_variant, RESPONSIVE_VARIANT_DEFAULT)], + { PRIMARY_REGION_MAPPINGS[fetch_or_fallback(PRIMARY_REGION_OPTIONS, primary_region, PRIMARY_REGION_DEFAULT)] => @responsive_variant == :separate_regions }, + system_arguments[:classes] + ) + end + + def render? + content_region.present? && pane_region.present? + end + + # The layout's content. + class Content < Primer::Component + status :beta + + WIDTH_DEFAULT = :fluid + WIDTH_OPTIONS = [WIDTH_DEFAULT, :md, :lg, :xl].freeze + + TAG_DEFAULT = :div + TAG_OPTIONS = [TAG_DEFAULT, :main].freeze + + # @param width [Symbol] <%= one_of(Primer::Beta::PageLayout::Content::WIDTH_OPTIONS) %> + # @param tag [Symbol] <%= one_of(Primer::Beta::PageLayout::Content::TAG_OPTIONS) %> + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + def initialize(tag: TAG_DEFAULT, width: WIDTH_DEFAULT, **system_arguments) + @width = fetch_or_fallback(WIDTH_OPTIONS, width, WIDTH_DEFAULT) + + @system_arguments = system_arguments + @system_arguments[:tag] = fetch_or_fallback(TAG_OPTIONS, tag, TAG_DEFAULT) + @system_arguments[:classes] = class_names( + "PageLayout-region", + "PageLayout-content", + system_arguments[:classes] + ) + end + + def call + render(Primer::BaseComponent.new(**@system_arguments)) do + if @width == :fluid + content + else + render(Primer::BaseComponent.new(tag: :div, classes: "PageLayout-content-centered-#{@width}")) do + render(Primer::BaseComponent.new(tag: :div, container: @width)) do + content + end + end + end + end + end + end + + # The layout's pane content. This is a secondary, smaller region that is paired with the `Content` region. + class Pane < Primer::Component + status :beta + + POSITION_DEFAULT = :start + POSITION_MAPPINGS = { + POSITION_DEFAULT => "PageLayout--panePos-start", + :end => "PageLayout--panePos-end" + }.freeze + POSITION_OPTIONS = POSITION_MAPPINGS.keys.freeze + + WIDTH_DEFAULT = :default + WIDTH_MAPPINGS = { + WIDTH_DEFAULT => "", + :narrow => "PageLayout--paneWidth-narrow", + :wide => "PageLayout--paneWidth-wide" + }.freeze + WIDTH_OPTIONS = WIDTH_MAPPINGS.keys.freeze + + POSITION_NARROW_DEFAULT = :inherit + POSITION_NARROW_MAPPINGS = { + POSITION_NARROW_DEFAULT => "", + :start => "PageLayout--responsive-panePos-start", + :end => "PageLayout--responsive-panePos-end" + }.freeze + POSITION_NARROW_OPTIONS = POSITION_NARROW_MAPPINGS.keys.freeze + + DIVIDER_DEFAULT = :start + DIVIDER_MAPPINGS = { + DIVIDER_DEFAULT => "PageLayout--panePos-start", + :end => "PageLayout--panePos-start" + }.freeze + DIVIDER_OPTIONS = DIVIDER_MAPPINGS.keys.freeze + + DIVIDER_NARROW_DEFAULT = :inherit + DIVIDER_NARROW_MAPPINGS = { + { end: DIVIDER_NARROW_DEFAULT } => "PageLayout-region--dividerNarrow-line-before", + { end: :none } => "PageLayout-region--dividerNarrow-none-before", + { end: :line } => "PageLayout-region--dividerNarrow-line-before", + { end: :filled } => "PageLayout-region--dividerNarrow-filled-before", + { start: DIVIDER_NARROW_DEFAULT } => "PageLayout-region--dividerNarrow-line-after", + { start: :none } => "PageLayout-region--dividerNarrow-none-after", + { start: :line } => "PageLayout-region--dividerNarrow-line-after", + { start: :filled } => "PageLayout-region--dividerNarrow-filled-after" + }.freeze + DIVIDER_NARROW_USER_OPTIONS = [DIVIDER_NARROW_DEFAULT, :none, :line, :filled].freeze + + TAG_DEFAULT = :div + TAG_OPTIONS = [TAG_DEFAULT, :aside, :nav, :section].freeze + + # @param divider [Boolean] + # @param divider_narrow [Symbol] <%= one_of(Primer::Beta::PageLayout::Pane::DIVIDER_NARROW_OPTIONS) %> + # @param position [Symbol] <%= one_of(Primer::Beta::PageLayout::Pane::POSITION_OPTIONS) %> + # @param tag [Symbol] <%= one_of(Primer::Beta::PageLayout::Pane::TAG_OPTIONS) %> + def initialize(divider: false, divider_narrow: DIVIDER_NARROW_DEFAULT, position: POSITION_DEFAULT, tag: TAG_DEFAULT, **system_arguments) + @system_arguments = system_arguments + @position = position + + @system_arguments[:tag] = fetch_or_fallback(TAG_OPTIONS, tag, TAG_DEFAULT) + @system_arguments[:classes] = class_names( + "PageLayout-region", + "PageLayout-pane", + { DIVIDER_NARROW_MAPPINGS[{ position => fetch_or_fallback(DIVIDER_NARROW_USER_OPTIONS, divider_narrow, DIVIDER_NARROW_DEFAULT) }] => divider }, + @system_arguments[:classes] + ) + end + + def render_first? + @position == :start + end + + def call + render(Primer::BaseComponent.new(**@system_arguments)) { content } + end + end + end + end +end diff --git a/app/components/primer/beta/split_page_layout.html.erb b/app/components/primer/beta/split_page_layout.html.erb new file mode 100644 index 0000000000..e6e39137b9 --- /dev/null +++ b/app/components/primer/beta/split_page_layout.html.erb @@ -0,0 +1,8 @@ +<%= render Primer::BaseComponent.new(**@system_arguments) do %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "PageLayout-wrapper")) do %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "PageLayout-columns")) do %> + <%= pane_region %> + <%= content_region %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/primer/beta/split_page_layout.rb b/app/components/primer/beta/split_page_layout.rb new file mode 100644 index 0000000000..ec919174a6 --- /dev/null +++ b/app/components/primer/beta/split_page_layout.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +module Primer + module Beta + # In the `SplitPageLayout`, changes in the Pane region are reflected in the `Content` region. This is also known as a "List/Detail" or "Master/Detail" pattern. + # + # On larger screens, the user sees both regions side by side, with the `Pane` region appearing flushed to the left. + # + # On smaller screens, the user only sees one of `Pane` or `Content` regions at a time. + # Pages may decide if it's more important to show the `Pane` region or the `Content` region first by the `:primary_region` property. + # + # @accessibility + # Keyboard navigation follows the markup order. In the case of the `SplitPageLayout`, the `Pane` region is the first region, and the `Content` region is the second. + class SplitPageLayout < Primer::Component + status :beta + + PANE_TAG_DEFAULT = :div + PANE_TAG_OPTIONS = [PANE_TAG_DEFAULT, :aside, :nav, :section].freeze + + PANE_WIDTH_DEFAULT = :default + PANE_WIDTH_MAPPINGS = { + PANE_WIDTH_DEFAULT => "", + :narrow => "PageLayout--paneWidth-narrow", + :wide => "PageLayout--paneWidth-wide" + }.freeze + PANE_WIDTH_OPTIONS = PANE_WIDTH_MAPPINGS.keys.freeze + + INNER_SPACING_DEFAULT = :normal + INNER_SPACING_MAPPINGS = { + normal: "PageLayout--innerSpacing-normal", + condensed: "PageLayout--innerSpacing-condensed" + }.freeze + INNER_SPACING_OPTIONS = INNER_SPACING_MAPPINGS.keys.freeze + + PRIMARY_REGION_DEFAULT = :content + PRIMARY_REGION_MAPPINGS = { + PRIMARY_REGION_DEFAULT => "PageLayout--responsive-primary-content", + :pane => "PageLayout--responsive-primary-pane" + }.freeze + PRIMARY_REGION_OPTIONS = PRIMARY_REGION_MAPPINGS.keys.freeze + + # The layout's content. + # + # @param width [Symbol] <%= one_of(Primer::Beta::SplitPageLayout::Content::WIDTH_OPTIONS) %> + # @param tag [Symbol] <%= one_of(Primer::Beta::SplitPageLayout::Content::TAG_OPTIONS) %> + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + renders_one :content_region, "Primer::Beta::SplitPageLayout::Content" + + # The layout's pane. + # + # @param width [Symbol] <%= one_of(Primer::Beta::SplitPageLayout::PANE_WIDTH_OPTIONS) %> + # @param tag [Symbol] <%= one_of(Primer::Beta::SplitPageLayout::PANE_TAG_OPTIONS) %> + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + renders_one :pane_region, lambda { |width: PANE_WIDTH_DEFAULT, tag: PANE_TAG_DEFAULT, **system_arguments| + @pane_system_arguments = system_arguments + @pane_system_arguments[:tag] = fetch_or_fallback(PANE_TAG_OPTIONS, tag, PANE_TAG_DEFAULT) + @pane_system_arguments[:classes] = class_names( + @pane_system_arguments[:classes], + "PageLayout-region", + "PageLayout-pane" + ) + + # These classes have to be set in the parent element, so we modify its system arguments. + @system_arguments[:classes] = class_names( + @system_arguments[:classes], + PANE_WIDTH_MAPPINGS[fetch_or_fallback(PANE_WIDTH_OPTIONS, width, PANE_WIDTH_DEFAULT)] + ) + + Primer::BaseComponent.new(**@pane_system_arguments) + } + + # @example Default + # + # <%= render(Primer::Beta::SplitPageLayout.new) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # + # @example Inner spacing + # + # @description + # Sets padding to regions individually. + # + # - `:condensed` keeps the margin at 16px. + # - `:normal` sets the margin to 16px, and to 24px on lg breakpoints and above. + # + # @code + # <%= render(Primer::Beta::PageLayout.new(inner_spacing: :condensed)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(inner_spacing: :normal)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # + # @example Responsive primary region + # + # @description + # When `responsive_variant` is set to `:separate_regions`, defines which region appears first on small viewports. `:content` is default. + # + # - `:content` + # - `:pane` + # + # @code + # <%= render(Primer::Beta::PageLayout.new(resposive_primary_region: :content)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::PageLayout.new(primary_region: :pane)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(border: true) { "Pane" } %> + # <% end %> + # + # @example Pane widths + # + # @description + # Sets the pane width. The width is predetermined according to the breakpoint instead of it being percentage-based. + # + # - `default`: + # - `narrow`: + # - `wide`: + # + # When flowing as a row, `Pane` takes the full width. + # + # @code + # <%= render(Primer::Beta::SplitPageLayout.new) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(width: :default, border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::SplitPageLayout.new(mt: 5)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(width: :narrow, border: true) { "Pane" } %> + # <% end %> + # <%= render(Primer::Beta::SplitPageLayout.new(mt: 5)) do |c| %> + # <% c.content_region(border: true) { "Content" } %> + # <% c.pane_region(width: :wide, border: true) { "Pane" } %> + # <% end %> + # + # + # @param inner_spacing [Symbol] Sets padding to regions individually. <%= one_of(Primer::Beta::SplitPageLayout::INNER_SPACING_OPTIONS) %> + # @param primary_region [Symbol] When `responsive_variant` is set to `:separate_regions`, defines which region appears first on small viewports. `:content` is default. <%= one_of(Primer::Beta::SplitPageLayout::PRIMARY_REGION_OPTIONS) %> + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + def initialize( + inner_spacing: INNER_SPACING_DEFAULT, + primary_region: PRIMARY_REGION_DEFAULT, + **system_arguments + ) + + @system_arguments = system_arguments + @system_arguments[:tag] = :div + @system_arguments[:classes] = class_names( + "PageLayout", + INNER_SPACING_MAPPINGS[fetch_or_fallback(INNER_SPACING_OPTIONS, inner_spacing, INNER_SPACING_DEFAULT)], + PRIMARY_REGION_MAPPINGS[fetch_or_fallback(PRIMARY_REGION_OPTIONS, primary_region, PRIMARY_REGION_DEFAULT)], + "PageLayout--responsive-separateRegions", + "PageLayout--columnGap-none", + "PageLayout--rowGap-none", + "PageLayout--panePos-start", + "PageLayout--hasPaneDivider", + system_arguments[:classes] + ) + end + + def render? + content_region.present? && pane_region.present? + end + + # The layout's content. + class Content < Primer::Component + status :beta + + WIDTH_DEFAULT = :fluid + WIDTH_OPTIONS = [WIDTH_DEFAULT, :md, :lg, :xl].freeze + + TAG_DEFAULT = :div + TAG_OPTIONS = [TAG_DEFAULT, :main].freeze + + # @param width [Symbol] <%= one_of(Primer::Beta::SplitPageLayout::Content::WIDTH_OPTIONS) %> + # @param tag [Symbol] <%= one_of(Primer::Beta::SplitPageLayout::Content::TAG_OPTIONS) %> + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + def initialize(tag: TAG_DEFAULT, width: WIDTH_DEFAULT, **system_arguments) + @width = fetch_or_fallback(WIDTH_OPTIONS, width, WIDTH_DEFAULT) + + @system_arguments = system_arguments + @system_arguments[:tag] = fetch_or_fallback(TAG_OPTIONS, tag, TAG_DEFAULT) + @system_arguments[:classes] = class_names( + "PageLayout-region", + "PageLayout-content", + system_arguments[:classes] + ) + end + + def call + render(Primer::BaseComponent.new(**@system_arguments)) do + if @width == :fluid + content + else + render(Primer::BaseComponent.new(tag: :div, classes: "PageLayout-content-centered-#{@width}")) do + render(Primer::BaseComponent.new(tag: :div, container: @width)) do + content + end + end + end + end + end + end + end + end +end diff --git a/docs/content/components/beta/pagelayout.md b/docs/content/components/beta/pagelayout.md new file mode 100644 index 0000000000..75229edff3 --- /dev/null +++ b/docs/content/components/beta/pagelayout.md @@ -0,0 +1,284 @@ +--- +title: PageLayout +componentId: page_layout +status: Beta +source: https://github.com/primer/view_components/tree/main/app/components/primer/beta/page_layout.rb +storybook: https://primer.style/view-components/stories/?path=/story/primer-beta-page-layout +--- + +import Example from '../../../src/@primer/gatsby-theme-doctocat/components/example' + + + +`PageLayout` provides foundational patterns for responsive pages. `PageLayout` can be used for simple two-column pages, or it can be nested to provide flexible 3-column experiences. + + On smaller screens, `PageLayout` uses vertically stacked rows to display content. `PageLayout` is responsible for determining the arrangement of the main regions that compose a page. +This means anything after the global and local headers (i.e. repo or org headers), and anything before the global footer. + + `PageLayout` controls the page spacings, supports header and footer regions, provides different styles of panes, and handles responsive strategies. + +`PageLayout` flows as both column, when there's enough horizontal space to render both `Content` and `Pane` side-by-side (on a desktop of tablet device, per instance); +or it flows as a row, when `Content` and `Pane` are stacked vertically (e.g. on a mobile device). +`PageLayout` should always work in any screen size. + +## Accessibility + +Keyboard navigation follows the markup order. Decide carefully how the focus order should be be by deciding whether +`content_region` or `pane_region` comes first in code. This is determined by the `position` argrument to the `pane_region` slot. + +## Arguments + +| Name | Type | Default | Description | +| :- | :- | :- | :- | +| `wrapper_sizing` | `Symbol` | `:fluid` | Define the maximum width of the component. `:fluid` sets it to full-width. Other values center Layout horizontally. One of `:fluid`, `:lg`, `:md`, or `:xl`. | +| `outer_spacing` | `Symbol` | `:normal` | Sets wrapper margins surrounding the component to distance itself from the viewport edges. One of `:condensed` and `:normal`. | +| `column_gap` | `Symbol` | `:normal` | Sets gap between columns. One of `:condensed` and `:normal`. | +| `row_gap` | `Symbol` | `:normal` | Sets the gap below the header and above the footer. One of `:condensed` and `:normal`. | +| `responsive_variant` | `Symbol` | `:stack_regions` | Defines how the layout component adapts to smaller viewports. `:stack_regions` presents the content in a vertical flow, with pane and content vertically arranged. `:separate_regions` presents pane and content as different pages on smaller viewports. | +| `primary_region` | `Symbol` | `:content` | When `responsive_variant` is set to `:separate_regions`, defines which region appears first on small viewports. `:content` is default. | +| `system_arguments` | `Hash` | N/A | [System arguments](/system-arguments) | + +## Slots + +### `Content_region` + +The layout's content. + +| Name | Type | Default | Description | +| :- | :- | :- | :- | +| `width` | `Symbol` | N/A | One of `:fluid`, `:lg`, `:md`, or `:xl`. | +| `tag` | `Symbol` | N/A | One of `:div` and `:main`. | +| `system_arguments` | `Hash` | N/A | [System arguments](/system-arguments) | + +### `Header_region` + +The layout's header. + +| Name | Type | Default | Description | +| :- | :- | :- | :- | +| `divider` | `Boolean` | N/A | Whether to show a header divider | +| `divider_narrow` | `Symbol` | N/A | Whether to show a divider below the `header` region if in responsive mode. One of `:filled`, `:inherit`, `:line`, or `:none`. | +| `system_arguments` | `Hash` | N/A | [System arguments](/system-arguments) | + +### `Footer_region` + +The layout's footer. + +| Name | Type | Default | Description | +| :- | :- | :- | :- | +| `divider` | `Boolean` | N/A | Whether to show a footer divider | +| `divider_narrow` | `Symbol` | N/A | Whether to show a divider below the `footer` region if in responsive mode. One of `:filled`, `:inherit`, `:line`, or `:none`. | +| `system_arguments` | `Hash` | N/A | [System arguments](/system-arguments) | + +### `Pane_region` + +The layout's pane. + +| Name | Type | Default | Description | +| :- | :- | :- | :- | +| `width` | `Symbol` | N/A | One of `:default`, `:narrow`, or `:wide`. | +| `position` | `Symbol` | N/A | Pane placement when `Layout` is in column modes. One of `:end` and `:start`. | +| `position_narrow` | `Symbol` | N/A | Pane placement when `Layout` is in column modes. One of `:end`, `:inherit`, or `:start`. | +| `divider` | `Boolean` | N/A | Whether to show a pane line divider. | +| `system_arguments` | `Hash` | N/A | [System arguments](/system-arguments) | + +## Examples + +### Default + + + +```erb + +<%= render(Primer::Beta::PageLayout.new) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +``` + +### Header and footer + + + +```erb + +<%= render(Primer::Beta::PageLayout.new) do |c| %> + <% c.header_region(border: true) { "Header" } %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> + <% c.footer_region(border: true) { "Footer" } %> +<% end %> +``` + +### Wrapper sizing + +When `:fluid` the layout will be set to full width. When the other sizing options are used the layout will be centered with corresponding widths. - `:fluid`: full width - `:md`: max-width: 768px - `:lg`: max-width: 1012px - `:xl`: max-width: 1280px + + + +```erb +<%= render(Primer::Beta::PageLayout.new(wrapper_sizing: :fluid)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(wrapper_sizing: :md)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(wrapper_sizing: :lg)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(wrapper_sizing: :xl)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +``` + +### Outer spacing + +Sets wrapper margins surrounding the component to distance itself from the viewport edges. - `:condensed` keeps the margin at 16px. - `:normal`` sets the margin to 16px, and to 24px on lg breakpoints and above. + + + +```erb +<%= render(Primer::Beta::PageLayout.new(outer_spacing: :condensed)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(outer_spacing: :normal)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +``` + +### Column gap + +Sets the gap between columns to distance them from each other. - `:condensed` keeps the gap always at 16px. - `:normal` sets the gap to 16px, and to 24px on lg breakpoints and above. + + + +```erb +<%= render(Primer::Beta::PageLayout.new(column_gap: :condensed)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(column_gap: :normal)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +``` + +### Row gap + +Sets the gap below the header and above the footer. - `:condensed` keeps the gap always at 16px. - `:normal` sets the gap to 16px, and to 24px on lg breakpoints and above. + + + +```erb +<%= render(Primer::Beta::PageLayout.new(row_gap: :condensed)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(row_gap: :normal)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +``` + +### Pane widths + +Sets the pane width. The width is predetermined according to the breakpoint instead of it being percentage-based. - `default`: - `narrow`: - `wide`: When flowing as a row, `Pane` takes the full width. + + + +```erb +<%= render(Primer::Beta::PageLayout.new) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(width: :default, border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(mt: 5)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(width: :narrow, border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(mt: 5)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(width: :wide, border: true) { "Pane" } %> +<% end %> +``` + +### Pane position + +Use `start` for panes that manipulate local navigation, while right-aligned `end` is useful for metadata and other auxiliary information. + + + +```erb +<%= render(Primer::Beta::PageLayout.new) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(position: :start, border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new( mt: 5)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(position: :end, border: true) { "Pane" } %> +<% end %> +``` + +### Pane resposive position + +Defines the position of the pane in the responsive layout. - `:start` puts the pane above content - `:end` puts it below content. - `:inherit` uses the same value from `position` + + + +```erb +<%= render(Primer::Beta::PageLayout.new(mt: 5)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(position_narrow: :inherit, border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(position_narrow: :start, border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(mt: 5)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(position_narrow: :end, border: true) { "Pane" } %> +<% end %> +``` + +### Header + +You can add an optional header to the layout and have spacing and positioning taken care of for you. You can optionally add a divider to the header. + + + +```erb +<%= render(Primer::Beta::PageLayout.new) do |c| %> + <% c.header_region(border: true) { "Header" } %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new) do |c| %> + <% c.header_region(divider: true, border: true) { "Header" } %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +``` + +### Footer + +You can add an optional footer to the layout and have spacing and positioning taken care of for you. You can optionally add a divider to the footer. + + + +```erb +<%= render(Primer::Beta::PageLayout.new) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> + <% c.footer_region(border: true) { "Header" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> + <% c.footer_region(divider: true, border: true) { "Header" } %> +<% end %> +``` diff --git a/docs/content/components/beta/splitpagelayout.md b/docs/content/components/beta/splitpagelayout.md new file mode 100644 index 0000000000..5e0fc8d0fc --- /dev/null +++ b/docs/content/components/beta/splitpagelayout.md @@ -0,0 +1,121 @@ +--- +title: SplitPageLayout +componentId: split_page_layout +status: Beta +source: https://github.com/primer/view_components/tree/main/app/components/primer/beta/split_page_layout.rb +storybook: https://primer.style/view-components/stories/?path=/story/primer-beta-split-page-layout +--- + +import Example from '../../../src/@primer/gatsby-theme-doctocat/components/example' + + + +In the `SplitPageLayout`, changes in the Pane region are reflected in the `Content` region. This is also known as a "List/Detail" or "Master/Detail" pattern. + +On larger screens, the user sees both regions side by side, with the `Pane` region appearing flushed to the left. + +On smaller screens, the user only sees one of `Pane` or `Content` regions at a time. +Pages may decide if it's more important to show the `Pane` region or the `Content` region first by the `:primary_region` property. + +## Accessibility + +Keyboard navigation follows the markup order. In the case of the `SplitPageLayout`, the `Pane` region is the first region, and the `Content` region is the second. + +## Arguments + +| Name | Type | Default | Description | +| :- | :- | :- | :- | +| `inner_spacing` | `Symbol` | `:normal` | Sets padding to regions individually. One of `:condensed` and `:normal`. | +| `primary_region` | `Symbol` | `:content` | When `responsive_variant` is set to `:separate_regions`, defines which region appears first on small viewports. `:content` is default. One of `:content` and `:pane`. | +| `system_arguments` | `Hash` | N/A | [System arguments](/system-arguments) | + +## Slots + +### `Content_region` + +The layout's content. + +| Name | Type | Default | Description | +| :- | :- | :- | :- | +| `width` | `Symbol` | N/A | One of `:fluid`, `:lg`, `:md`, or `:xl`. | +| `tag` | `Symbol` | N/A | One of `:div` and `:main`. | +| `system_arguments` | `Hash` | N/A | [System arguments](/system-arguments) | + +### `Pane_region` + +The layout's pane. + +| Name | Type | Default | Description | +| :- | :- | :- | :- | +| `width` | `Symbol` | N/A | One of `:default`, `:narrow`, or `:wide`. | +| `tag` | `Symbol` | N/A | One of `:aside`, `:div`, `:nav`, or `:section`. | +| `system_arguments` | `Hash` | N/A | [System arguments](/system-arguments) | + +## Examples + +### Default + + + +```erb + +<%= render(Primer::Beta::SplitPageLayout.new) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +``` + +### Inner spacing + +Sets padding to regions individually. - `:condensed` keeps the margin at 16px. - `:normal` sets the margin to 16px, and to 24px on lg breakpoints and above. + + + +```erb +<%= render(Primer::Beta::PageLayout.new(inner_spacing: :condensed)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(inner_spacing: :normal)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +``` + +### Responsive primary region + +When `responsive_variant` is set to `:separate_regions`, defines which region appears first on small viewports. `:content` is default. - `:content` - `:pane` + + + +```erb +<%= render(Primer::Beta::PageLayout.new(resposive_primary_region: :content)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::PageLayout.new(primary_region: :pane)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(border: true) { "Pane" } %> +<% end %> +``` + +### Pane widths + +Sets the pane width. The width is predetermined according to the breakpoint instead of it being percentage-based. - `default`: - `narrow`: - `wide`: When flowing as a row, `Pane` takes the full width. + + + +```erb +<%= render(Primer::Beta::SplitPageLayout.new) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(width: :default, border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::SplitPageLayout.new(mt: 5)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(width: :narrow, border: true) { "Pane" } %> +<% end %> +<%= render(Primer::Beta::SplitPageLayout.new(mt: 5)) do |c| %> + <% c.content_region(border: true) { "Content" } %> + <% c.pane_region(width: :wide, border: true) { "Pane" } %> +<% end %> +``` diff --git a/docs/src/@primer/gatsby-theme-doctocat/nav.yml b/docs/src/@primer/gatsby-theme-doctocat/nav.yml index a7e75db06c..bdee1dead2 100644 --- a/docs/src/@primer/gatsby-theme-doctocat/nav.yml +++ b/docs/src/@primer/gatsby-theme-doctocat/nav.yml @@ -81,12 +81,16 @@ url: "/components/octicon" - title: OcticonSymbols url: "/components/octiconsymbols" + - title: PageLayout + url: "/components/pagelayout" - title: Popover url: "/components/popover" - title: ProgressBar url: "/components/progressbar" - title: Spinner url: "/components/spinner" + - title: SplitPageLayout + url: "/components/splitpagelayout" - title: State url: "/components/state" - title: Subhead diff --git a/lib/tasks/docs.rake b/lib/tasks/docs.rake index da068ff1a2..077c7c32ab 100644 --- a/lib/tasks/docs.rake +++ b/lib/tasks/docs.rake @@ -29,6 +29,8 @@ namespace :docs do # Rails controller for rendering arbitrary ERB view_context = ApplicationController.new.tap { |c| c.request = ActionDispatch::TestRequest.create }.view_context components = [ + Primer::Beta::PageLayout, + Primer::Beta::SplitPageLayout, Primer::Alpha::Layout, Primer::HellipButton, Primer::Alpha::BorderBox::Header, diff --git a/package.json b/package.json index 7cc01984fb..60f48f9918 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ }, "devDependencies": { "@github/prettier-config": "0.0.4", - "@primer/css": "^19.0.0", + "@primer/css": "^19.2.0", "@primer/primitives": "^7.1.0", "@rollup/plugin-node-resolve": "^11.2.0", "@rollup/plugin-typescript": "^8.2.0", diff --git a/static/arguments.yml b/static/arguments.yml index 20b9c4284b..5928e21b06 100644 --- a/static/arguments.yml +++ b/static/arguments.yml @@ -279,6 +279,61 @@ type: Hash default: N/A description: "[System arguments](/system-arguments)" +- component: PageLayout + source: https://github.com/primer/view_components/tree/main/app/components/primer/beta/page_layout.rb + parameters: + - name: wrapper_sizing + type: Symbol + default: "`:fluid`" + description: Define the maximum width of the component. `:fluid` sets it to full-width. + Other values center Layout horizontally. One of `:fluid`, `:lg`, `:md`, or `:xl`. + - name: outer_spacing + type: Symbol + default: "`:normal`" + description: Sets wrapper margins surrounding the component to distance itself + from the viewport edges. One of `:condensed` and `:normal`. + - name: column_gap + type: Symbol + default: "`:normal`" + description: Sets gap between columns. One of `:condensed` and `:normal`. + - name: row_gap + type: Symbol + default: "`:normal`" + description: Sets the gap below the header and above the footer. One of `:condensed` + and `:normal`. + - name: responsive_variant + type: Symbol + default: "`:stack_regions`" + description: Defines how the layout component adapts to smaller viewports. `:stack_regions` + presents the content in a vertical flow, with pane and content vertically arranged. + `:separate_regions` presents pane and content as different pages on smaller + viewports. + - name: primary_region + type: Symbol + default: "`:content`" + description: When `responsive_variant` is set to `:separate_regions`, defines + which region appears first on small viewports. `:content` is default. + - name: system_arguments + type: Hash + default: N/A + description: "[System arguments](/system-arguments)" +- component: SplitPageLayout + source: https://github.com/primer/view_components/tree/main/app/components/primer/beta/split_page_layout.rb + parameters: + - name: inner_spacing + type: Symbol + default: "`:normal`" + description: Sets padding to regions individually. One of `:condensed` and `:normal`. + - name: primary_region + type: Symbol + default: "`:content`" + description: When `responsive_variant` is set to `:separate_regions`, defines + which region appears first on small viewports. `:content` is default. One of + `:content` and `:pane`. + - name: system_arguments + type: Hash + default: N/A + description: "[System arguments](/system-arguments)" - component: Text source: https://github.com/primer/view_components/tree/main/app/components/primer/beta/text.rb parameters: diff --git a/static/audited_at.json b/static/audited_at.json index 360fa1bbf2..32facb2b55 100644 --- a/static/audited_at.json +++ b/static/audited_at.json @@ -18,6 +18,11 @@ "Primer::Beta::Blankslate": "", "Primer::Beta::Breadcrumbs": "", "Primer::Beta::Breadcrumbs::Item": "", + "Primer::Beta::PageLayout": "", + "Primer::Beta::PageLayout::Content": "", + "Primer::Beta::PageLayout::Pane": "", + "Primer::Beta::SplitPageLayout": "", + "Primer::Beta::SplitPageLayout::Content": "", "Primer::Beta::Text": "", "Primer::Beta::Truncate": "", "Primer::Beta::Truncate::TruncateText": "", diff --git a/static/classes.yml b/static/classes.yml index 1a46c93a28..8646ac74b5 100644 --- a/static/classes.yml +++ b/static/classes.yml @@ -49,6 +49,37 @@ - ".Link--muted" - ".Link--primary" - ".Link--secondary" +- ".PageLayout" +- ".PageLayout--columnGap-condensed" +- ".PageLayout--columnGap-none" +- ".PageLayout--columnGap-normal" +- ".PageLayout--hasPaneDivider" +- ".PageLayout--innerSpacing-normal" +- ".PageLayout--outerSpacing-condensed" +- ".PageLayout--outerSpacing-normal" +- ".PageLayout--panePos-end" +- ".PageLayout--panePos-start" +- ".PageLayout--paneWidth-narrow" +- ".PageLayout--paneWidth-wide" +- ".PageLayout--responsive-panePos-end" +- ".PageLayout--responsive-panePos-start" +- ".PageLayout--responsive-primary-content" +- ".PageLayout--responsive-separateRegions" +- ".PageLayout--responsive-stackRegions" +- ".PageLayout--rowGap-condensed" +- ".PageLayout--rowGap-none" +- ".PageLayout--rowGap-normal" +- ".PageLayout-columns" +- ".PageLayout-content" +- ".PageLayout-footer" +- ".PageLayout-footer--hasDivider" +- ".PageLayout-header" +- ".PageLayout-header--hasDivider" +- ".PageLayout-pane" +- ".PageLayout-region" +- ".PageLayout-region--dividerNarrow-line-after" +- ".PageLayout-region--dividerNarrow-line-before" +- ".PageLayout-wrapper" - ".Popover" - ".Popover-message" - ".Popover-message--large" diff --git a/static/constants.json b/static/constants.json index 59af9dcfc7..907f73cbda 100644 --- a/static/constants.json +++ b/static/constants.json @@ -249,6 +249,227 @@ }, "Primer::Beta::Breadcrumbs::Item": { }, + "Primer::Beta::PageLayout": { + "COLUMN_GAP_DEFAULT": "normal", + "COLUMN_GAP_MAPPINGS": { + "normal": "PageLayout--columnGap-normal", + "condensed": "PageLayout--columnGap-condensed" + }, + "COLUMN_GAP_OPTIONS": [ + "normal", + "condensed" + ], + "Content": "Primer::Beta::PageLayout::Content", + "FOOTER_DIVIDER_NARROW_DEFAULT": "inherit", + "FOOTER_DIVIDER_NARROW_MAPPINGS": { + "inherit": "PageLayout-region--dividerNarrow-line-before", + "none": "", + "line": "PageLayout-region--dividerNarrow-line-before", + "filled": "PageLayout-region--dividerNarrow-filled-before" + }, + "FOOTER_DIVIDER_NARROW_OPTIONS": [ + "inherit", + "none", + "line", + "filled" + ], + "HEADER_DIVIDER_NARROW_DEFAULT": "inherit", + "HEADER_DIVIDER_NARROW_MAPPINGS": { + "inherit": "PageLayout-region--dividerNarrow-line-after", + "none": "", + "line": "PageLayout-region--dividerNarrow-line-after", + "filled": "PageLayout-region--dividerNarrow-filled-after" + }, + "HEADER_DIVIDER_NARROW_OPTIONS": [ + "inherit", + "none", + "line", + "filled" + ], + "OUTER_SPACING_DEFAULT": "normal", + "OUTER_SPACING_MAPPINGS": { + "normal": "PageLayout--outerSpacing-normal", + "condensed": "PageLayout--outerSpacing-condensed" + }, + "OUTER_SPACING_OPTIONS": [ + "normal", + "condensed" + ], + "PRIMARY_REGION_DEFAULT": "content", + "PRIMARY_REGION_MAPPINGS": { + "content": "PageLayout--responsive-primary-content", + "pane": "PageLayout--responsive-primary-pane" + }, + "PRIMARY_REGION_OPTIONS": [ + "content", + "pane" + ], + "Pane": "Primer::Beta::PageLayout::Pane", + "RESPONSIVE_VARIANT_DEFAULT": "stack_regions", + "RESPONSIVE_VARIANT_MAPPINGS": { + "stack_regions": "PageLayout--responsive-stackRegions", + "separate_regions": "PageLayout--responsive-separateRegions" + }, + "RESPONSIVE_VARIANT_OPTIONS": [ + "stack_regions", + "separate_regions" + ], + "ROW_GAP_DEFAULT": "normal", + "ROW_GAP_MAPPINGS": { + "normal": "PageLayout--rowGap-normal", + "condensed": "PageLayout--rowGap-condensed" + }, + "ROW_GAP_OPTIONS": [ + "normal", + "condensed" + ], + "WRAPPER_SIZING_DEFAULT": "fluid", + "WRAPPER_SIZING_MAPPINGS": { + "fluid": "", + "md": "container-md", + "lg": "container-lg", + "xl": "container-xl" + }, + "WRAPPER_SIZING_OPTIONS": [ + "fluid", + "md", + "lg", + "xl" + ] + }, + "Primer::Beta::PageLayout::Content": { + "TAG_DEFAULT": "div", + "TAG_OPTIONS": [ + "div", + "main" + ], + "WIDTH_DEFAULT": "fluid", + "WIDTH_OPTIONS": [ + "fluid", + "md", + "lg", + "xl" + ] + }, + "Primer::Beta::PageLayout::Pane": { + "DIVIDER_DEFAULT": "start", + "DIVIDER_MAPPINGS": { + "start": "PageLayout--panePos-start", + "end": "PageLayout--panePos-start" + }, + "DIVIDER_NARROW_DEFAULT": "inherit", + "DIVIDER_NARROW_MAPPINGS": { + "{:end=>:inherit}": "PageLayout-region--dividerNarrow-line-before", + "{:end=>:none}": "PageLayout-region--dividerNarrow-none-before", + "{:end=>:line}": "PageLayout-region--dividerNarrow-line-before", + "{:end=>:filled}": "PageLayout-region--dividerNarrow-filled-before", + "{:start=>:inherit}": "PageLayout-region--dividerNarrow-line-after", + "{:start=>:none}": "PageLayout-region--dividerNarrow-none-after", + "{:start=>:line}": "PageLayout-region--dividerNarrow-line-after", + "{:start=>:filled}": "PageLayout-region--dividerNarrow-filled-after" + }, + "DIVIDER_NARROW_USER_OPTIONS": [ + "inherit", + "none", + "line", + "filled" + ], + "DIVIDER_OPTIONS": [ + "start", + "end" + ], + "POSITION_DEFAULT": "start", + "POSITION_MAPPINGS": { + "start": "PageLayout--panePos-start", + "end": "PageLayout--panePos-end" + }, + "POSITION_NARROW_DEFAULT": "inherit", + "POSITION_NARROW_MAPPINGS": { + "inherit": "", + "start": "PageLayout--responsive-panePos-start", + "end": "PageLayout--responsive-panePos-end" + }, + "POSITION_NARROW_OPTIONS": [ + "inherit", + "start", + "end" + ], + "POSITION_OPTIONS": [ + "start", + "end" + ], + "TAG_DEFAULT": "div", + "TAG_OPTIONS": [ + "div", + "aside", + "nav", + "section" + ], + "WIDTH_DEFAULT": "default", + "WIDTH_MAPPINGS": { + "default": "", + "narrow": "PageLayout--paneWidth-narrow", + "wide": "PageLayout--paneWidth-wide" + }, + "WIDTH_OPTIONS": [ + "default", + "narrow", + "wide" + ] + }, + "Primer::Beta::SplitPageLayout": { + "Content": "Primer::Beta::SplitPageLayout::Content", + "INNER_SPACING_DEFAULT": "normal", + "INNER_SPACING_MAPPINGS": { + "normal": "PageLayout--innerSpacing-normal", + "condensed": "PageLayout--innerSpacing-condensed" + }, + "INNER_SPACING_OPTIONS": [ + "normal", + "condensed" + ], + "PANE_TAG_DEFAULT": "div", + "PANE_TAG_OPTIONS": [ + "div", + "aside", + "nav", + "section" + ], + "PANE_WIDTH_DEFAULT": "default", + "PANE_WIDTH_MAPPINGS": { + "default": "", + "narrow": "PageLayout--paneWidth-narrow", + "wide": "PageLayout--paneWidth-wide" + }, + "PANE_WIDTH_OPTIONS": [ + "default", + "narrow", + "wide" + ], + "PRIMARY_REGION_DEFAULT": "content", + "PRIMARY_REGION_MAPPINGS": { + "content": "PageLayout--responsive-primary-content", + "pane": "PageLayout--responsive-primary-pane" + }, + "PRIMARY_REGION_OPTIONS": [ + "content", + "pane" + ] + }, + "Primer::Beta::SplitPageLayout::Content": { + "TAG_DEFAULT": "div", + "TAG_OPTIONS": [ + "div", + "main" + ], + "WIDTH_DEFAULT": "fluid", + "WIDTH_OPTIONS": [ + "fluid", + "md", + "lg", + "xl" + ] + }, "Primer::Beta::Text": { "DEFAULT_TAG": "span" }, diff --git a/static/statuses.json b/static/statuses.json index d170c9414a..0f4ce1359e 100644 --- a/static/statuses.json +++ b/static/statuses.json @@ -18,6 +18,11 @@ "Primer::Beta::Blankslate": "beta", "Primer::Beta::Breadcrumbs": "beta", "Primer::Beta::Breadcrumbs::Item": "alpha", + "Primer::Beta::PageLayout": "beta", + "Primer::Beta::PageLayout::Content": "beta", + "Primer::Beta::PageLayout::Pane": "beta", + "Primer::Beta::SplitPageLayout": "beta", + "Primer::Beta::SplitPageLayout::Content": "beta", "Primer::Beta::Text": "beta", "Primer::Beta::Truncate": "beta", "Primer::Beta::Truncate::TruncateText": "alpha", diff --git a/stories/primer/beta/page_layout_stories.rb b/stories/primer/beta/page_layout_stories.rb new file mode 100644 index 0000000000..11d763b80c --- /dev/null +++ b/stories/primer/beta/page_layout_stories.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "primer/beta/page_layout" + +class Primer::Beta::PageLayoutStories < ViewComponent::Storybook::Stories + layout "storybook_preview" + + story(:page_layout) do + controls do + select(:wrapper_sizing, Primer::Beta::PageLayout::WRAPPER_SIZING_OPTIONS, Primer::Beta::PageLayout::WRAPPER_SIZING_DEFAULT) + select(:outer_spacing, Primer::Beta::PageLayout::OUTER_SPACING_OPTIONS, Primer::Beta::PageLayout::OUTER_SPACING_DEFAULT) + select(:column_gap, Primer::Beta::PageLayout::COLUMN_GAP_OPTIONS, Primer::Beta::PageLayout::COLUMN_GAP_DEFAULT) + select(:row_gap, Primer::Beta::PageLayout::ROW_GAP_OPTIONS, Primer::Beta::PageLayout::ROW_GAP_DEFAULT) + select(:primary_region, Primer::Beta::PageLayout::PRIMARY_REGION_OPTIONS, Primer::Beta::PageLayout::PRIMARY_REGION_DEFAULT) + select(:responsive_variant, Primer::Beta::PageLayout::RESPONSIVE_VARIANT_OPTIONS, Primer::Beta::PageLayout::RESPONSIVE_VARIANT_DEFAULT) + end + + content do |c| + c.content_region(border: true) do + "Main region" + end + c.pane_region(border: true) do + "Pane region" + end + end + end + + story(:page_layout_with_header_and_footer) do + controls do + select(:wrapper_sizing, Primer::Beta::PageLayout::WRAPPER_SIZING_OPTIONS, Primer::Beta::PageLayout::WRAPPER_SIZING_DEFAULT) + select(:outer_spacing, Primer::Beta::PageLayout::OUTER_SPACING_OPTIONS, Primer::Beta::PageLayout::OUTER_SPACING_DEFAULT) + select(:column_gap, Primer::Beta::PageLayout::COLUMN_GAP_OPTIONS, Primer::Beta::PageLayout::COLUMN_GAP_DEFAULT) + select(:row_gap, Primer::Beta::PageLayout::ROW_GAP_OPTIONS, Primer::Beta::PageLayout::ROW_GAP_DEFAULT) + select(:primary_region, Primer::Beta::PageLayout::PRIMARY_REGION_OPTIONS, Primer::Beta::PageLayout::PRIMARY_REGION_DEFAULT) + select(:responsive_variant, Primer::Beta::PageLayout::RESPONSIVE_VARIANT_OPTIONS, Primer::Beta::PageLayout::RESPONSIVE_VARIANT_DEFAULT) + end + + content do |c| + c.header_region(border: true) do + "Header region" + end + c.content_region(border: true) do + "Content region" + end + c.pane_region(border: true) do + "Pane region" + end + c.footer_region(border: true) do + "Footer region" + end + end + end +end diff --git a/stories/primer/beta/split_page_layout_stories.rb b/stories/primer/beta/split_page_layout_stories.rb new file mode 100644 index 0000000000..335941da78 --- /dev/null +++ b/stories/primer/beta/split_page_layout_stories.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "primer/beta/split_page_layout" + +class Primer::Beta::SplitPageLayoutStories < ViewComponent::Storybook::Stories + layout "storybook_preview" + + story(:page_layout) do + controls do + select(:inner_spacing, Primer::Beta::SplitPageLayout::INNER_SPACING_OPTIONS, Primer::Beta::SplitPageLayout::INNER_SPACING_DEFAULT) + select(:primary_region, Primer::Beta::SplitPageLayout::PRIMARY_REGION_OPTIONS, Primer::Beta::SplitPageLayout::PRIMARY_REGION_DEFAULT) + end + + content do |c| + c.content_region(border: true) do + "Content region" + end + c.pane_region(border: true) do + "Pane region" + end + end + end +end diff --git a/test/components/component_test.rb b/test/components/component_test.rb index afa2359404..446ba53c5a 100644 --- a/test/components/component_test.rb +++ b/test/components/component_test.rb @@ -7,6 +7,14 @@ class PrimerComponentTest < Minitest::Test # Components with any arguments necessary to make them render COMPONENTS_WITH_ARGS = [ + [Primer::Beta::PageLayout, {}, proc { |component| + component.content_region(tag: :div) { "Foo" } + component.pane_region(tag: :div) { "Bar" } + }], + [Primer::Beta::SplitPageLayout, {}, proc { |component| + component.content_region(tag: :div) { "Foo" } + component.pane_region(tag: :div) { "Bar" } + }], [Primer::Alpha::Layout, {}, proc { |component| component.main(tag: :div) { "Foo" } component.sidebar(tag: :div) { "Bar" } diff --git a/test/components/primer/beta/page_layout_test.rb b/test/components/primer/beta/page_layout_test.rb new file mode 100644 index 0000000000..b82ba2db30 --- /dev/null +++ b/test/components/primer/beta/page_layout_test.rb @@ -0,0 +1,424 @@ +# frozen_string_literal: true + +require "test_helper" + +class PrimerBetaPageLayoutTest < Minitest::Test + include Primer::ComponentTestHelpers + + def test_doesnt_render_without_both_slots + render_inline(Primer::Beta::PageLayout.new) + refute_component_rendered + + render_inline(Primer::Beta::PageLayout.new) { |c| c.content_region { "Content" } } + refute_component_rendered + + render_inline(Primer::Beta::PageLayout.new) { |c| c.pane_region { "Pane" } } + refute_component_rendered + end + + def test_renders_layout + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout-columns") do + assert_selector("div.PageLayout-region.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-region.PageLayout-pane", text: "Pane") + end + end + end + + def test_optionally_renders_header_and_footer + render_inline(Primer::Beta::PageLayout.new) do |c| + c.header_region { "Header" } + c.content_region { "Content" } + c.pane_region { "Pane" } + c.footer_region { "Footer" } + end + + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout-header.PageLayout-region", text: "Header") + assert_selector("div.PageLayout-columns") do + assert_selector("div.PageLayout-region.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-region.PageLayout-pane", text: "Pane") + end + assert_selector("div.PageLayout-footer.PageLayout-region", text: "Footer") + end + end + + def test_renders_layout_with_correct_default_classes + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + expected_classes = [ + "PageLayout", + "PageLayout--responsive-stackRegions", + "PageLayout--outerSpacing-normal", + "PageLayout--columnGap-normal", + "PageLayout--rowGap-normal", + "PageLayout--panePos-start", + "PageLayout--responsive-panePos-start" + ].join(".") + assert_selector("div.#{expected_classes}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + + def test_wrapper_sizing + Primer::Beta::PageLayout::WRAPPER_SIZING_OPTIONS.each do |size| + render_inline(Primer::Beta::PageLayout.new(wrapper_sizing: size)) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout-wrapper#{size == :fluid ? '' : ".container-#{size}"}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + end + + def test_outer_spacing + Primer::Beta::PageLayout::OUTER_SPACING_OPTIONS.each do |size| + render_inline(Primer::Beta::PageLayout.new(outer_spacing: size)) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + size_class = Primer::Beta::PageLayout::OUTER_SPACING_MAPPINGS[size] + assert_selector("div.PageLayout#{size_class.empty? ? '' : ".#{size_class}"}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_column_gap + Primer::Beta::PageLayout::COLUMN_GAP_OPTIONS.each do |size| + render_inline(Primer::Beta::PageLayout.new(column_gap: size)) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + size_class = Primer::Beta::PageLayout::COLUMN_GAP_MAPPINGS[size] + assert_selector("div.PageLayout#{size_class.empty? ? '' : ".#{size_class}"}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_row_gap + Primer::Beta::PageLayout::ROW_GAP_OPTIONS.each do |size| + render_inline(Primer::Beta::PageLayout.new(row_gap: size)) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + size_class = Primer::Beta::PageLayout::ROW_GAP_MAPPINGS[size] + assert_selector("div.PageLayout#{size_class.empty? ? '' : ".#{size_class}"}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_responsive_variant + Primer::Beta::PageLayout::RESPONSIVE_VARIANT_OPTIONS.each do |variant| + render_inline(Primer::Beta::PageLayout.new(responsive_variant: variant)) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + variant_class = Primer::Beta::PageLayout::RESPONSIVE_VARIANT_MAPPINGS[variant] + assert_selector("div.PageLayout#{variant_class.empty? ? '' : ".#{variant_class}"}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_primary_region + Primer::Beta::PageLayout::PRIMARY_REGION_OPTIONS.each do |region| + render_inline(Primer::Beta::PageLayout.new(responsive_variant: :separate_regions, primary_region: region)) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + region_class = Primer::Beta::PageLayout::PRIMARY_REGION_MAPPINGS[region] + assert_selector("div.PageLayout#{region_class.empty? ? '' : ".#{region_class}"}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_primary_region_not_set_when_stack_regions + render_inline(Primer::Beta::PageLayout.new(responsive_variant: :stack_regions)) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + refute_selector("div.PageLayout--responsive-primary-pane") + refute_selector("div.PageLayout--responsive-primary-content") + end + + def test_pane_position + Primer::Beta::PageLayout::Pane::POSITION_OPTIONS.each do |position| + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(position: position) { "Pane" } + end + + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout--panePos-#{position}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + end + + def test_pane_position_renders_pane_first + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(position: :start) { "Pane" } + end + + assert_match(/PageLayout-pane.*PageLayout-content/m, @rendered_component) + end + + def test_pane_position_renders_pane_last + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(position: :end) { "Pane" } + end + + assert_match(/PageLayout-content.*PageLayout-pane/m, @rendered_component) + end + + def test_stack_regions_variant_with_pane_position_narrow + Primer::Beta::PageLayout::Pane::POSITION_OPTIONS.each do |position| + Primer::Beta::PageLayout::Pane::POSITION_NARROW_OPTIONS.each do |position_narrow| + render_inline(Primer::Beta::PageLayout.new(responsive_variant: :stack_regions)) do |c| + c.content_region { "Content" } + c.pane_region(position: position, position_narrow: position_narrow) { "Pane" } + end + + position_narrow = position if position_narrow == :inherit + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout--panePos-#{position}.PageLayout--responsive-panePos-#{position_narrow}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + end + end + + def test_variant_pane_position_not_set_when_separate_regions + render_inline(Primer::Beta::PageLayout.new(responsive_variant: :separate_regions)) do |c| + c.content_region { "Content" } + c.pane_region(position: :start) { "Pane" } + end + + refute_selector("div.PageLayout--responsive-panePos-end") + refute_selector("div.PageLayout--responsive-panePos-start") + end + + def test_pane_width + Primer::Beta::PageLayout::Pane::WIDTH_OPTIONS.each do |size| + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(width: size) { "Pane" } + end + + width_class = Primer::Beta::PageLayout::Pane::WIDTH_MAPPINGS[size] + assert_selector("div.PageLayout") do + assert_selector("div#{width_class.empty? ? '' : ".#{width_class}"}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + end + + def test_pane_divider_present_when_set + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(divider: true) { "Pane" } + end + + assert_selector("div.PageLayout.PageLayout--hasPaneDivider") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane.PageLayout-region--dividerNarrow-line-after", text: "Pane") + end + end + + def test_pane_divider_absent_when_not_set + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + refute_selector("div.PageLayout.PageLayout--hasPaneDivider") + end + + def test_pane_divider_narrow + Primer::Beta::PageLayout::Pane::POSITION_OPTIONS.each do |position| + Primer::Beta::PageLayout::Pane::DIVIDER_NARROW_USER_OPTIONS.each do |type| + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(position: position, divider: true, divider_narrow: type) { "Pane" } + end + + type_class = Primer::Beta::PageLayout::Pane::DIVIDER_NARROW_MAPPINGS[{ position => type }] + assert_selector("div.PageLayout.PageLayout--hasPaneDivider") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane.#{type_class}", text: "Pane") + end + end + end + end + + def test_pane_divider_narrow_not_applied_when_no_divider + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(position: :start, divider: false, divider_narrow: :line) { "Pane" } + end + + type_class = Primer::Beta::PageLayout::Pane::DIVIDER_NARROW_MAPPINGS[{ start: :line }] + refute_selector("div.PageLayout-pane.#{type_class}", text: "Pane") + end + + def test_pane_tags + Primer::Beta::PageLayout::Pane::TAG_OPTIONS.each do |tag| + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(tag: tag) { "Pane" } + end + + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("#{tag}.PageLayout-pane", text: "Pane") + end + end + end + + def test_main_width + Primer::Beta::PageLayout::Content::WIDTH_OPTIONS.each do |width| + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region(width: width) { "Content" } + c.pane_region { "Pane" } + end + + assert_selector("div.PageLayout-columns") do + assert_selector("div.PageLayout-content") do + if width == :fluid + assert_text("Content") + else + assert_selector("div.PageLayout-content-centered-#{width}") do + assert_selector("div.container-#{width}", text: "Content") + end + end + end + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_main_tags + Primer::Beta::PageLayout::Content::TAG_OPTIONS.each do |tag| + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region(tag: tag) { "Content" } + c.pane_region { "Pane" } + end + + assert_selector("div.PageLayout") do + assert_selector("#{tag}.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_header_divider_present_when_set + render_inline(Primer::Beta::PageLayout.new) do |c| + c.header_region(divider: true) { "Header" } + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + assert_selector("div.PageLayout-header.PageLayout-header--hasDivider.PageLayout-region--dividerNarrow-line-after") + end + + def test_header_divider_not_present_when_not_set + render_inline(Primer::Beta::PageLayout.new) do |c| + c.header_region { "Header" } + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + refute_selector("div.PageLayout-header.PageLayout-header--hasDivider") + end + + def test_header_responsive_divider + Primer::Beta::PageLayout::HEADER_DIVIDER_NARROW_OPTIONS.each do |opt| + render_inline(Primer::Beta::PageLayout.new) do |c| + c.header_region(divider: true, divider_narrow: opt) { "Header" } + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + divider_class = Primer::Beta::PageLayout::HEADER_DIVIDER_NARROW_MAPPINGS[opt] + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout-header#{divider_class.empty? ? '' : ".#{divider_class}"}", text: "Header") + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_footer_divider_present_when_set + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + c.footer_region(divider: true) { "Footer" } + end + + assert_selector("div.PageLayout-footer.PageLayout-footer--hasDivider.PageLayout-region--dividerNarrow-line-before") + end + + def test_footer_divider_not_present_when_not_set + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + c.footer_region { "Footer" } + end + + refute_selector("div.PageLayout-footer.PageLayout-footer--hasDivider.PageLayout-region--dividerNarrow-line-before") + end + + def test_footer_responsive_divider + Primer::Beta::PageLayout::FOOTER_DIVIDER_NARROW_OPTIONS.each do |opt| + render_inline(Primer::Beta::PageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + c.footer_region(divider: true, divider_narrow: opt) { "Footer" } + end + + divider_class = Primer::Beta::PageLayout::FOOTER_DIVIDER_NARROW_MAPPINGS[opt] + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout-footer#{divider_class.empty? ? '' : ".#{divider_class}"}", text: "Footer") + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end +end diff --git a/test/components/primer/beta/split_page_layout_test.rb b/test/components/primer/beta/split_page_layout_test.rb new file mode 100644 index 0000000000..1b6fb39c4d --- /dev/null +++ b/test/components/primer/beta/split_page_layout_test.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "test_helper" + +class PrimerBetaSplitPageLayoutTest < Minitest::Test + include Primer::ComponentTestHelpers + + def test_doesnt_render_without_both_slots + render_inline(Primer::Beta::SplitPageLayout.new) + refute_component_rendered + + render_inline(Primer::Beta::SplitPageLayout.new) { |c| c.content_region { "Content" } } + refute_component_rendered + + render_inline(Primer::Beta::SplitPageLayout.new) { |c| c.pane_region { "Pane" } } + refute_component_rendered + end + + def test_renders_layout + render_inline(Primer::Beta::SplitPageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + + def test_renders_layout_with_correct_default_classes + render_inline(Primer::Beta::SplitPageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + expected_classes = [ + "PageLayout", + "PageLayout--innerSpacing-normal", + "PageLayout--columnGap-none", + "PageLayout--rowGap-none", + "PageLayout--panePos-start", + "PageLayout--hasPaneDivider", + "PageLayout--responsive-separateRegions", + "PageLayout--responsive-primary-content" + ].join(".") + assert_selector("div.#{expected_classes}") do + assert_selector("div.PageLayout-wrapper") do + assert_selector("div.PageLayout-columns") do + assert_selector("div.PageLayout-region.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-region.PageLayout-pane", text: "Pane") + end + end + end + end + + def test_primary_region + Primer::Beta::SplitPageLayout::PRIMARY_REGION_OPTIONS.each do |region| + render_inline(Primer::Beta::SplitPageLayout.new(primary_region: region)) do |c| + c.content_region { "Content" } + c.pane_region { "Pane" } + end + + region_class = Primer::Beta::SplitPageLayout::PRIMARY_REGION_MAPPINGS[region] + assert_selector("div.PageLayout#{region_class.empty? ? '' : ".#{region_class}"}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_pane_width + Primer::Beta::SplitPageLayout::PANE_WIDTH_OPTIONS.each do |size| + render_inline(Primer::Beta::SplitPageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(width: size) { "Pane" } + end + + width_class = Primer::Beta::SplitPageLayout::PANE_WIDTH_MAPPINGS[size] + assert_selector("div.PageLayout") do + assert_selector("div#{width_class.empty? ? '' : ".#{width_class}"}") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + end + + def test_pane_tags + Primer::Beta::SplitPageLayout::PANE_TAG_OPTIONS.each do |tag| + render_inline(Primer::Beta::SplitPageLayout.new) do |c| + c.content_region { "Content" } + c.pane_region(tag: tag) { "Pane" } + end + + assert_selector("div.PageLayout") do + assert_selector("div.PageLayout-content", text: "Content") + assert_selector("#{tag}.PageLayout-pane", text: "Pane") + end + end + end + + def test_content_width + Primer::Beta::SplitPageLayout::Content::WIDTH_OPTIONS.each do |width| + render_inline(Primer::Beta::SplitPageLayout.new) do |c| + c.content_region(width: width) { "Content" } + c.pane_region { "Pane" } + end + + assert_selector("div.PageLayout-columns") do + assert_selector("div.PageLayout-content") do + if width == :fluid + assert_text("Content") + else + assert_selector("div.PageLayout-content-centered-#{width}") do + assert_selector("div.container-#{width}", text: "Content") + end + end + end + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end + + def test_content_tags + Primer::Beta::SplitPageLayout::Content::TAG_OPTIONS.each do |tag| + render_inline(Primer::Beta::SplitPageLayout.new) do |c| + c.content_region(tag: tag) { "Content" } + c.pane_region { "Pane" } + end + + assert_selector("div.PageLayout") do + assert_selector("#{tag}.PageLayout-content", text: "Content") + assert_selector("div.PageLayout-pane", text: "Pane") + end + end + end +end diff --git a/test/components/stories_test.rb b/test/components/stories_test.rb index 2c03d2023f..96dd4879e7 100644 --- a/test/components/stories_test.rb +++ b/test/components/stories_test.rb @@ -9,6 +9,9 @@ class AllComponentsHaveStoriesTest < Minitest::Test Primer::FlexItemComponent, Primer::OcticonSymbolsComponent, Primer::Beta::Breadcrumbs::Item, + Primer::Beta::SplitPageLayout::Content, + Primer::Beta::PageLayout::Pane, + Primer::Beta::PageLayout::Content, Primer::Alpha::Layout::Main, Primer::Alpha::Layout::Sidebar ].freeze diff --git a/yarn.lock b/yarn.lock index b37c653dcf..2c93cb0603 100644 --- a/yarn.lock +++ b/yarn.lock @@ -102,18 +102,23 @@ "@nodelib/fs.scandir" "2.1.4" fastq "^1.6.0" -"@primer/css@^19.0.0": - version "19.0.0" - resolved "https://registry.yarnpkg.com/@primer/css/-/css-19.0.0.tgz#c03e9d9365e37074f3555a98bdea0e705e1f5bf3" - integrity sha512-w3/DoQlWBjyyo0scneGkcVHyaIBFjODbmWZoJ1qYHe+eLufPOKNc5Tf/OlxqiYQxGCF8vVb3f/i30z4KfzJzzA== +"@primer/css@^19.2.0": + version "19.2.0" + resolved "https://registry.yarnpkg.com/@primer/css/-/css-19.2.0.tgz#afd1821c46da5a1c675f8e74a38fa566434c868b" + integrity sha512-20fJIGZy/s+LqDfqR1qqGS34LZTg3co4XbeL+Lv45LBqsVQ+ULwURNU6y3JBb78THLbish9V4TccPw59dj1YOQ== dependencies: - "@primer/primitives" "^7.1.0" + "@primer/primitives" "^7.4.0" "@primer/primitives@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@primer/primitives/-/primitives-7.1.0.tgz#944151afb8b0e8ffd33c1cfdc4f873b5dd46cbe5" integrity sha512-XVM535ieY+ohh82oMCZZwlebsnNtJORR5vpiUk/OLT1KX7pr2aRiSbitFwZ3umo0dw/L5tr6ebSJjRCXLu8UJg== +"@primer/primitives@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@primer/primitives/-/primitives-7.4.0.tgz#75df54a80233a432b687c0e3010e4be6bd60a82d" + integrity sha512-gD6yHXN7YKox/bdUNgxhoSS/WXZVaORK1r4dOAyTrdoPrLV/ucIfRInPyVcTF+Mqr0zcTFJtiMtuA5Y8CSyOEg== + "@rollup/plugin-node-resolve@^11.2.0": version "11.2.0" resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.0.tgz"