mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 23:01:26 +00:00
* feat(debug): add expandable view to the debug event log The /settings/debug table packs seven columns of long-form diagnostics into one row, so messages, context IDs, and metadata are all cramped and hard to read. Borrows the expand pattern from the dashboard cashflow chart (#739): hovering a log row reveals a DS::Button icon trigger (always visible below `lg`, where there is no hover state, and on keyboard focus) that opens the entry in a roomy DS::Dialog. The expanded view lays the entry out vertically — level/category as DS::Pill badges, the full message, source, each context ID labelled, and pretty-printed metadata in a scrollable block. - Extract the row into a `_log_entry` partial now that it carries the trigger and its dialog. - Add a generic `expandable` Stimulus controller that opens the <dialog> inside its scope, since the trigger sits outside the DS--dialog controller's scope. - Add `Settings::DebugsHelper` for the level-to-pill-tone mapping and the context field list, keeping the logic out of the template. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RRNRykqxBvgWwA2Wm8KiXH * refactor(debug): expand the whole log table, not individual rows The expand affordance belongs on the table card, matching the dashboard cashflow chart it is modelled on: one trigger in the card's header strip reopens the entire log in a near full-width dialog, where all seven columns finally have room. - Extract the table into a `_log_table` partial so the inline card and the expanded dialog render the same markup, and give its header `sticky top-0` — inert inline, useful once the expanded copy scrolls. - Drop the per-row trigger, its detail dialog, and the `Settings::DebugsHelper` and locale keys that only existed to support them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RRNRykqxBvgWwA2Wm8KiXH * fix(debug): reveal expand button on coarse pointers, lift width to DS Addresses review feedback on the expandable debug log table. - The expand trigger was hidden by `lg:opacity-0` and only revealed by hover, so a touch-only device wide enough to hit `lg` — a large tablet — had no way to discover it. Viewport width is the wrong proxy for hover capability; switch to the shape the dashboard insights feed already uses: hidden by default, revealed by hover, focus, or `pointer-coarse`. - The `!w-[96vw] max-w-[1650px]` expanded-dialog shape was hand-rolled at the callsite in two places. Lift it into `DS::Dialog::WIDTHS[:expanded]` and use it from both the debug log and the dashboard cashflow chart, so the arbitrary values live in the design system rather than in views. The emitted class string is unchanged in both cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RRNRykqxBvgWwA2Wm8KiXH * fix(ds): stop the expanded dialog overflowing narrow viewports `!w-[96vw]` applied at every breakpoint, but `dialog_inner_classes` only drops its `mx-3` gutter at `lg`. Below that the panel is 96vw + 24px of margins inside a dialog box that is narrower than the viewport — `vw` counts the scrollbar, the dialog's percentage-based box does not — so the panel is clipped on both sides. Flex-shrink cannot absorb it either, once nowrap content (the debug log's timestamp and context cells) raises the panel's min-content width. Scope the 96vw to `lg` and up, where the gutter is gone. Below `lg` the base `w-full` + `mx-3` already fits, which is what every other dialog width does. Measured in Chromium against Tailwind 4.1.8 output, panel clipped per side: viewport 320 375 390 768 1024 1400 !w-[96vw] 12.6 11.5 11.2 0 0 0 lg:!w-[96vw] 0 0 0 0 0 0 Widths at 768/1024/1400 are unchanged (706/978/1344px). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RRNRykqxBvgWwA2Wm8KiXH --------- Co-authored-by: Claude <noreply@anthropic.com>
170 lines
5.6 KiB
Ruby
170 lines
5.6 KiB
Ruby
class DS::Dialog < DesignSystemComponent
|
||
renders_one :header, ->(title: nil, subtitle: nil, custom_header: false, **opts, &block) do
|
||
content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
|
||
title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
|
||
# `id: title_id` lets the host `<dialog>` reference the title via
|
||
# `aria-labelledby` so AT users hear the title when focus lands
|
||
# in the dialog. `content_tag("h#{heading_level}", ...)` builds an
|
||
# h2/h3/etc based on the caller's `heading_level:`.
|
||
title = content_tag("h#{heading_level}", title, id: title_id, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
|
||
close_icon = close_button unless custom_header
|
||
safe_join([ title, close_icon ].compact)
|
||
end
|
||
|
||
subtitle = content_tag(:p, subtitle, class: "text-sm text-secondary") if subtitle
|
||
|
||
block_content = capture(&block) if block
|
||
|
||
safe_join([ title_div, subtitle, block_content ].compact)
|
||
end
|
||
end
|
||
|
||
renders_one :body
|
||
|
||
renders_many :actions, ->(cancel_action: false, **button_opts) do
|
||
merged_opts = if cancel_action
|
||
button_opts.merge(type: "button", data: { action: "DS--dialog#close" })
|
||
else
|
||
button_opts
|
||
end
|
||
|
||
render DS::Button.new(**merged_opts)
|
||
end
|
||
|
||
renders_many :sections, ->(title:, **disclosure_opts, &block) do
|
||
render DS::Disclosure.new(title: title, align: :right, **disclosure_opts) do
|
||
block.call
|
||
end
|
||
end
|
||
|
||
attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :content_class, :disable_click_outside, :opts, :responsive, :scrollable, :heading_level, :title_id
|
||
|
||
VARIANTS = %w[modal drawer].freeze
|
||
# `expanded` is the "give this cramped thing the whole screen" shape used by
|
||
# expand-on-hover affordances (the dashboard cashflow chart, the debug log
|
||
# table). It keeps a sliver of viewport margin — unlike `full` — and caps out
|
||
# so wide tables and charts don't stretch unreadably on ultrawide displays.
|
||
#
|
||
# The 96vw only applies from `lg`, where `dialog_inner_classes` drops the
|
||
# `mx-3` gutter. Below that, a viewport-relative width is the wrong tool:
|
||
# `vw` counts the scrollbar but the dialog's own box does not, so 96vw + the
|
||
# 24px gutter overflows a phone by ~11px per side (measured 320–390px), and
|
||
# flex-shrink cannot absorb it once nowrap content raises the panel's
|
||
# min-content width. Below `lg` the base `w-full` + `mx-3` already fits.
|
||
WIDTHS = {
|
||
sm: "lg:max-w-[300px]",
|
||
md: "lg:max-w-[550px]",
|
||
lg: "lg:max-w-[700px]",
|
||
full: "lg:max-w-full",
|
||
expanded: "lg:!w-[96vw] max-w-[1650px]"
|
||
}.freeze
|
||
VALID_HEADING_LEVELS = (1..6).freeze
|
||
|
||
class_attribute :defaults_provider, default: nil
|
||
|
||
def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: nil, responsive: false, scrollable: true, heading_level: 2, **opts)
|
||
unless heading_level.is_a?(Integer) && VALID_HEADING_LEVELS.cover?(heading_level)
|
||
raise ArgumentError, "heading_level must be an Integer between 1 and 6, got: #{heading_level.inspect}"
|
||
end
|
||
|
||
defaults = self.class.defaults_provider&.call || {}
|
||
|
||
@variant = variant.to_sym
|
||
@auto_open = auto_open
|
||
@reload_on_close = reload_on_close
|
||
@width = width.to_sym
|
||
@frame = frame
|
||
@disable_frame = disable_frame
|
||
@content_class = content_class
|
||
@disable_click_outside = disable_click_outside.nil? ? defaults.fetch(:disable_click_outside, false) : disable_click_outside
|
||
@responsive = responsive
|
||
@scrollable = scrollable
|
||
@heading_level = heading_level
|
||
@title_id = "dialog-title-#{SecureRandom.hex(4)}"
|
||
@opts = opts
|
||
end
|
||
|
||
def frame
|
||
@frame || variant
|
||
end
|
||
|
||
# Caller must "opt-out" of using the default turbo-frame based on the variant
|
||
def wrapper_element(&block)
|
||
if disable_frame
|
||
content_tag(:div, &block)
|
||
else
|
||
content_tag("turbo-frame", id: frame, &block)
|
||
end
|
||
end
|
||
|
||
def dialog_outer_classes
|
||
variant_classes = if responsive?
|
||
"items-center justify-center lg:items-end lg:justify-end"
|
||
elsif drawer?
|
||
"items-end justify-end"
|
||
else
|
||
"items-center justify-center"
|
||
end
|
||
|
||
class_names(
|
||
"flex h-full w-full",
|
||
variant_classes
|
||
)
|
||
end
|
||
|
||
def dialog_inner_classes
|
||
variant_classes = if responsive?
|
||
"max-h-full lg:h-full lg:w-[550px]"
|
||
elsif drawer?
|
||
"lg:w-[550px] h-full"
|
||
else
|
||
class_names(
|
||
"max-h-full",
|
||
WIDTHS[width]
|
||
)
|
||
end
|
||
|
||
class_names(
|
||
"flex flex-col bg-container rounded-xl shadow-border-xs mx-3 lg:mx-0 w-full",
|
||
variant_classes,
|
||
content_class
|
||
)
|
||
end
|
||
|
||
def merged_opts
|
||
merged_opts = opts.dup
|
||
data = merged_opts.delete(:data) || {}
|
||
|
||
data[:controller] = [ "DS--dialog", "hotkey", data[:controller] ].compact.join(" ")
|
||
data[:DS__dialog_auto_open_value] = auto_open
|
||
data[:DS__dialog_reload_on_close_value] = reload_on_close
|
||
data[:DS__dialog_disable_click_outside_value] = disable_click_outside
|
||
data[:action] = [ "click->DS--dialog#clickOutside", data[:action] ].compact.join(" ")
|
||
data[:hotkey] = "esc:DS--dialog#close"
|
||
merged_opts[:data] = data
|
||
|
||
merged_opts
|
||
end
|
||
|
||
def drawer?
|
||
variant == :drawer
|
||
end
|
||
|
||
def responsive?
|
||
@responsive
|
||
end
|
||
|
||
def close_button
|
||
classes = responsive? ? "ml-auto hidden lg:flex" : "ml-auto"
|
||
render DS::Button.new(
|
||
variant: "icon",
|
||
size: :sm,
|
||
class: classes,
|
||
icon: "x",
|
||
title: I18n.t("ds.dialog.close"),
|
||
aria_label: I18n.t("ds.dialog.close"),
|
||
data: { action: "DS--dialog#close" }
|
||
)
|
||
end
|
||
end
|