keystone_ui 0.29.5 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5df45efe6cf162e4a96fa164e01d6ccdf623f8a84d14c530fd5dca4b42eec941
4
- data.tar.gz: f1366496636edb0d10f9e50c4b75c4dddc21801634cce63ba67cf4bfe009dfd6
3
+ metadata.gz: a6c7e18e911657e41ed1a4eff2133400015bd7a58e0c437f6886d219173f01fa
4
+ data.tar.gz: 91b2f652bb7313d9cda361e126e969d06b64827e80118888a73cce3840471f68
5
5
  SHA512:
6
- metadata.gz: f249e58a7511397eab772a9e1c9c54c323c0583ddafe41c4da6741d54d8ee57e3590d9c400f75fbd7f5a71d4aa4b0fa30cb0f6223ee6ab817b0240ab539b976b
7
- data.tar.gz: dd617c68d26134469be161339b9c0158a2cd39f4f95ee29d9f6e3546f85b4e60cc4009a2d30d431f31792c3dea9752489df8e48032493b5dde5ab872e5a12135
6
+ metadata.gz: 466ea8fbb32ed4683db9537a52d7ba41d667f79dfa52aec27aaab85f5fc14b872fcc03b7d880136936133685fdc1af5f839aa4392c5ba698af62694e4a5832b0
7
+ data.tar.gz: 435630df203142fa669f0d93333e6b2637b2d4ec2d25b6e8fa1a7aa00e54ba5fdace3a3416905c144f706f18373ad78b8b11c067f5d1a62ee6453655358b4b77
@@ -6,9 +6,26 @@ const loadChart = async () => {
6
6
  return Chart
7
7
  }
8
8
 
9
+ const MILLISECONDS_IN_A_DAY = 86_400_000
10
+
11
+ export const dayLabel = (day) =>
12
+ new Date(day * MILLISECONDS_IN_A_DAY).toLocaleDateString("en-US", { timeZone: "UTC", month: "short", day: "numeric", year: "numeric" })
13
+
14
+ const DATED_OPTIONS = {
15
+ scales: { x: { type: "linear", bounds: "data", ticks: { precision: 0, callback: dayLabel } } },
16
+ plugins: { tooltip: { callbacks: { title: ([point]) => dayLabel(point.parsed.x) } } }
17
+ }
18
+
19
+ export const chartOptions = (dated) => ({
20
+ responsive: true,
21
+ maintainAspectRatio: false,
22
+ interaction: { mode: "index", intersect: false },
23
+ ...(dated ? DATED_OPTIONS : {})
24
+ })
25
+
9
26
  export default class extends Controller {
10
27
  static targets = ["canvas"]
11
- static values = { data: Object }
28
+ static values = { data: Object, dated: Boolean }
12
29
 
13
30
  async connect() {
14
31
  const Chart = await loadChart()
@@ -17,11 +34,7 @@ export default class extends Controller {
17
34
  this.chart = new Chart(this.canvasTarget, {
18
35
  type: "line",
19
36
  data: this.resolveColors(this.dataValue),
20
- options: {
21
- responsive: true,
22
- maintainAspectRatio: false,
23
- interaction: { mode: "index", intersect: false }
24
- }
37
+ options: chartOptions(this.datedValue)
25
38
  })
26
39
  }
27
40
 
@@ -1,14 +1,35 @@
1
- <div class="<%= container_classes %>">
2
- <% layers.each do |layer| %>
3
- <% if layer.conversion_percent %>
4
- <div class="<%= transition_classes %>">↓ <%= layer.conversion_percent %>%</div>
5
- <% end %>
6
- <div class="<%= layer_classes %>">
7
- <div class="<%= row_classes %>">
8
- <span class="<%= label_classes %>"><%= layer.label %></span>
1
+ <% if joined? %>
2
+ <div class="<%= JOINED_CLASSES %>">
3
+ <% layers.each do |layer| %>
4
+ <% if layer.conversion_percent %>
5
+ <div></div>
6
+ <div class="<%= BAND_ROW_CLASSES %>">
7
+ <div class="<%= BAND_CLASSES %>" style="clip-path: <%= band_clip_path(layer) %>"></div>
8
+ <div class="<%= BAND_LABEL_ROW_CLASSES %>"><span class="<%= BAND_LABEL_CLASSES %>"><%= layer.conversion_percent %>%</span></div>
9
+ </div>
10
+ <% end %>
11
+ <div class="<%= WORDS_CLASSES %>">
9
12
  <span class="<%= value_classes %>"><%= layer.value %></span>
13
+ <span class="<%= label_classes %>"><%= layer.label %></span>
14
+ </div>
15
+ <div class="<%= BLOCK_CLASSES %>">
16
+ <div class="<%= BLOCK_FILL_CLASSES %> <%= layer.color_classes %>" style="width: <%= layer.width_percent %>%"></div>
10
17
  </div>
11
- <div class="<%= bar_classes %> <%= layer.color_classes %>" style="width: <%= layer.width_percent %>%"></div>
12
- </div>
13
- <% end %>
14
- </div>
18
+ <% end %>
19
+ </div>
20
+ <% else %>
21
+ <div class="<%= container_classes %>">
22
+ <% layers.each do |layer| %>
23
+ <% if layer.conversion_percent %>
24
+ <div class="<%= transition_classes %>">↓ <%= layer.conversion_percent %>%</div>
25
+ <% end %>
26
+ <div class="<%= layer_classes %>">
27
+ <div class="<%= row_classes %>">
28
+ <span class="<%= label_classes %>"><%= layer.label %></span>
29
+ <span class="<%= value_classes %>"><%= layer.value %></span>
30
+ </div>
31
+ <div class="<%= bar_classes %> <%= layer.color_classes %>" style="width: <%= layer.width_percent %>%"></div>
32
+ </div>
33
+ <% end %>
34
+ </div>
35
+ <% end %>
@@ -3,7 +3,7 @@
3
3
  module Keystone
4
4
  module Ui
5
5
  class FunnelComponent < ViewComponent::Base
6
- Layer = Struct.new(:label, :value, :width_percent, :conversion_percent, :color_classes, keyword_init: true)
6
+ Layer = Struct.new(:label, :value, :width_percent, :conversion_percent, :color_classes, :previous_width_percent, keyword_init: true)
7
7
 
8
8
  CONTAINER_CLASSES = "ks-funnel"
9
9
  LAYER_CLASSES = "ks-funnel-layer"
@@ -12,6 +12,14 @@ module Keystone
12
12
  VALUE_CLASSES = "ks-funnel-value text-sm tabular-nums"
13
13
  BAR_CLASSES = "ks-funnel-bar h-8 transition-all"
14
14
  TRANSITION_CLASSES = "ks-funnel-transition text-center text-xs"
15
+ BAND_LABEL_ROW_CLASSES = "absolute inset-0 flex items-center justify-center"
16
+ BAND_ROW_CLASSES = "relative h-7"
17
+ BLOCK_FILL_CLASSES = "h-full"
18
+ BLOCK_CLASSES = "flex justify-center h-12"
19
+ WORDS_CLASSES = "flex flex-col justify-center"
20
+ BAND_LABEL_CLASSES = "ks-funnel-band-label text-xs"
21
+ BAND_CLASSES = "ks-funnel-band absolute inset-0"
22
+ JOINED_CLASSES = "ks-funnel-joined grid grid-cols-[max-content_1fr]"
15
23
  STEP_COLOR_CLASSES = {
16
24
  accent: "ks-funnel-bar-accent",
17
25
  sky: "ks-funnel-bar-sky",
@@ -20,10 +28,24 @@ module Keystone
20
28
  rose: "ks-funnel-bar-rose"
21
29
  }.freeze
22
30
 
23
- attr_reader :steps
31
+ attr_reader :steps, :shape
32
+
33
+ def initialize(steps:, shape: :bars)
34
+ raise ArgumentError, "a funnel's shape is :bars or :joined, got #{shape.inspect}" unless %i[bars joined].include?(shape)
24
35
 
25
- def initialize(steps:)
26
36
  @steps = steps
37
+ @shape = shape
38
+ end
39
+
40
+ def joined?
41
+ shape == :joined
42
+ end
43
+
44
+ def band_clip_path(layer)
45
+ top = (100 - layer.previous_width_percent) / 2.0
46
+ bottom = (100 - layer.width_percent) / 2.0
47
+
48
+ "polygon(#{format("%g%%", top)} 0, #{format("%g%%", 100 - top)} 0, #{format("%g%%", 100 - bottom)} 100%, #{format("%g%%", bottom)} 100%)"
27
49
  end
28
50
 
29
51
  def layers
@@ -35,7 +57,8 @@ module Keystone
35
57
  value: step[:value],
36
58
  width_percent: width_percent(step[:value]),
37
59
  conversion_percent: conversion_percent(step[:value], previous),
38
- color_classes: color_classes(step[:color], index)
60
+ color_classes: color_classes(step[:color], index),
61
+ previous_width_percent: previous && width_percent(previous)
39
62
  )
40
63
  previous = step[:value]
41
64
  layer
@@ -1,3 +1,3 @@
1
- <div class="<%= container_classes %>" data-controller="line-chart" data-line-chart-data-value="<%= chart_data_json %>">
1
+ <div class="<%= container_classes %>" data-controller="line-chart" data-line-chart-data-value="<%= chart_data_json %>" data-line-chart-dated-value="<%= dated? %>">
2
2
  <canvas class="max-w-full" data-line-chart-target="canvas"></canvas>
3
3
  </div>
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "date"
3
4
  require "json"
4
5
 
5
6
  module Keystone
@@ -12,21 +13,29 @@ module Keystone
12
13
  }.freeze
13
14
 
14
15
  DASH_PATTERN = [ 6, 6 ].freeze
16
+ EPOCH = Date.new(1970, 1, 1)
17
+
18
+ def initialize(series:, labels: nil, dates: nil, height: :md)
19
+ raise ArgumentError, "a line chart takes labels: or dates:" unless labels.nil? ^ dates.nil?
15
20
 
16
- def initialize(series:, labels:, height: :md)
17
21
  @series = series
18
22
  @labels = labels
23
+ @dates = dates
19
24
  @height = height
20
25
  end
21
26
 
22
27
  def chart_data
23
- { labels: @labels, datasets: @series.map { |s| dataset_for(s) } }
28
+ { labels: @labels, datasets: @series.map { |s| dataset_for(s) } }.compact
24
29
  end
25
30
 
26
31
  def chart_data_json
27
32
  chart_data.to_json
28
33
  end
29
34
 
35
+ def dated?
36
+ !@dates.nil?
37
+ end
38
+
30
39
  def height_class
31
40
  HEIGHT_CLASSES.fetch(@height)
32
41
  end
@@ -38,11 +47,17 @@ module Keystone
38
47
  private
39
48
 
40
49
  def dataset_for(series)
41
- dataset = { label: series[:name], data: series[:data] }
50
+ dataset = { label: series[:name], data: points_for(series[:data]) }
42
51
  dataset[:borderColor] = series[:color] if series[:color]
43
52
  dataset[:borderDash] = DASH_PATTERN if series[:dashed]
44
53
  dataset
45
54
  end
55
+
56
+ def points_for(values)
57
+ return values unless @dates
58
+
59
+ @dates.zip(values).map { |date, value| { x: (date.to_date - EPOCH).to_i, y: value } }
60
+ end
46
61
  end
47
62
  end
48
63
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KeystoneUi
4
- VERSION = "0.29.5"
4
+ VERSION = "0.31.0"
5
5
  end
@@ -323,17 +323,34 @@ outer element. See Conventions before using it.
323
323
 
324
324
  - `ui_chart_card(title:, height: :md)` — takes a block holding a chart. `height:`
325
325
  `:sm` `:md` `:lg`.
326
- - `ui_line_chart(series:, labels:, height: :md)` — a line chart. `labels:` is the
327
- x-axis labels; `series:` is `[{ name:, data:, color:, dashed: }, ...]` where
328
- `color:` (a CSS color string for the line) and `dashed: true` are optional.
329
- `height:` `:sm` `:md` `:lg`.
330
- - `ui_funnel(steps:)` — a conversion funnel. `steps:` is
331
- `[{ label:, value:, color: }, ...]` in order, with `color:` optional. Bar
332
- widths are relative to the first step; the caption between two layers is the
333
- step-to-step conversion. Each bar takes the next colour in the order
334
- `:accent` `:sky` `:violet` `:amber` `:rose`, starting again after `:rose`; a
335
- step passing one of those symbols as `color:` uses it instead, and any other
336
- symbol raises. Divide-by-zero safe, no JavaScript.
326
+ - `ui_line_chart(series:, labels: nil, dates: nil, height: :md)` — a line chart
327
+ with one line per series. `series:` is
328
+ `[{ name:, data:, color:, dashed: }, ...]` where `data:` is the array of
329
+ values, and `color:` (a CSS color string for the line) and `dashed: true` are
330
+ optional. `height:` `:sm` `:md` `:lg`. Pass exactly one of `labels:` and
331
+ `dates:` for the horizontal axis: passing both, or neither, raises
332
+ `ArgumentError`. `labels:` is an array of strings, one per value, spaced
333
+ evenly and shown as written. `dates:` is an array of `Date`, `Time` or
334
+ `DateTime` values, one per value, matched to each series' `data:` by
335
+ position. A dated chart places each point by its day, so a gap of a week is
336
+ seven times as wide as a gap of a day. Its axis runs from the first day to
337
+ the last, marks only whole days, and reads each day as a date such as
338
+ "Oct 2, 2026", which is also the heading a hovered point shows. The time of
339
+ day is dropped, so pass one value per day.
340
+ - `ui_funnel(steps:, shape: :bars)` — a conversion funnel. `steps:` is
341
+ `[{ label:, value:, color: }, ...]` in order, with `color:` optional. Each
342
+ step's width is its value as a share of the first step's value, and the
343
+ percent shown between two steps is the second step's value divided by the
344
+ first's. Each step takes the next colour in the order `:accent` `:sky`
345
+ `:violet` `:amber` `:rose`, starting again after `:rose`; a step passing one
346
+ of those symbols as `color:` uses it instead, and any other symbol raises.
347
+ Values are printed as given. Divide-by-zero safe, no JavaScript. `shape:`
348
+ `:bars` draws one bar per step, left-aligned, with the label and value on a
349
+ row above it and a `↓ N%` caption between two bars. `shape: :joined` draws
350
+ one shape: each step's value over its label in a column on the left, each
351
+ step's block centred at its width, and between two blocks a neutral band
352
+ that narrows from the block above to the block below, with the percent on it.
353
+ Any other `shape:` raises `ArgumentError`.
337
354
  - `ui_bucket(goal:, actual:, label: nil, over: :success)` — an upright container
338
355
  for one target, filled from the bottom toward `goal:`. It shows the optional
339
356
  `label` on top, then `goal`, the container, `actual`, and the percent reached.
@@ -443,6 +460,18 @@ outer element. See Conventions before using it.
443
460
  On a radio card or a checkbox row, text every reader needs to choose goes in `hint:` and
444
461
  text only some will want goes in `info:`. Which is which is the app's own
445
462
  wording, so ask the developer rather than pick.
463
+ For a line chart, pass `dates:` when each value belongs to a calendar day
464
+ and `labels:` when the horizontal axis is anything else, such as week names
465
+ or categories. With `dates:`, a day with no value leaves a wider gap between
466
+ its neighbours rather than a point at zero. Whether a missing day is left
467
+ out or passed as a zero is the app's own rule, so ask the developer rather
468
+ than pick. Never format dates into strings and pass them as `labels:`.
469
+ For a funnel, whether it is drawn as separate bars or as one joined shape
470
+ is the developer's choice, so ask before passing `shape:`. If a joined
471
+ funnel shows its blocks but no band between them, the app's
472
+ keystone_ui-styles version does not define the `ks-funnel-band` classes, so
473
+ stop and hand that part to `keystone_ui-install`, and add no classes to fix
474
+ it.
446
475
  Put the actions on a record, such as Edit and Delete, in an action menu
447
476
  rather than in a row of buttons: `menu:` on the `ui_section` that shows the
448
477
  record, `table.actions` for a table row, or `ui_action_menu` anywhere else.
@@ -129,6 +129,23 @@ holds the catalog.
129
129
  - **Figures are numbers.** Components that measure or compare values, such as
130
130
  progress bars, funnels and buckets, do arithmetic on them. Pass a number, not
131
131
  a formatted string such as `"9,000"`. A bucket raises on one.
132
+ - **A funnel has two shapes.** Both size each step against the first step and
133
+ show the percent carried from one step to the next. The default draws one bar
134
+ per step, each with its label and value on a row above it. The joined shape
135
+ draws the steps as one figure: each step's value and label sit in a column on
136
+ the left, each step is a block centred at its width, and a neutral band
137
+ between two blocks narrows from one width to the next and holds the percent.
138
+ Any other shape raises `ArgumentError`. Neither shape needs JavaScript.
139
+ - **A line chart is labelled or dated.** A line chart draws one line per
140
+ series, and each series is a name and its values, with an optional colour
141
+ and an optional dashed line. Its horizontal axis is given one of two ways,
142
+ never both and never neither, and the wrong combination raises
143
+ `ArgumentError`. Labels are text, one per value, spaced evenly and shown as
144
+ written. Dates are one day per value, and a dated chart places each point by
145
+ its day, so a gap of a week is seven times as wide as a gap of a day. A dated
146
+ axis runs from the first day to the last, marks only whole days, and reads
147
+ each day as a date such as "Oct 2, 2026", which is also what a hovered point
148
+ shows. A time of day is dropped, so a dated chart has one position per day.
132
149
  - **A calculation is text.** The calculation behind a figure is the opposite
133
150
  case. It is a list of groups, each with an optional title and its lines, and
134
151
  each line is a label, the working and the result. They are passed as
@@ -40,7 +40,7 @@ built on ViewComponent; hook it in before building any screen with those helpers
40
40
  ## How to use it
41
41
 
42
42
  1. Confirm the prerequisites: Ruby >= 3.2 and **tailwindcss-rails v4+** in the
43
- host app. The gem brings ViewComponent and keystone_ui-styles 0.8.0 or later with it.
43
+ host app. The gem brings ViewComponent and keystone_ui-styles 0.9.0 or later with it.
44
44
  Tailwind does not have to be initialized first, because the generator creates
45
45
  the stylesheet if it is missing.
46
46
 
@@ -98,6 +98,7 @@ sources:
98
98
  - app/components/keystone/ui/info_component.rb
99
99
  - app/components/keystone/ui/info_component.html.erb
100
100
  - app/assets/javascripts/keystone_ui/info_controller.js
101
+ - app/assets/javascripts/keystone_ui/line_chart_controller.js
101
102
  - app/components/keystone/ui/breakdown_component.rb
102
103
  - app/components/keystone/ui/breakdown_component.html.erb
103
104
  - app/components/keystone/ui/feature_grid_component.rb
@@ -111,6 +112,7 @@ sources:
111
112
  - app/components/keystone/ui/breadcrumbs_component.rb
112
113
  - app/components/keystone/ui/breadcrumbs_component.html.erb
113
114
  - app/components/keystone/ui/funnel_component.rb
115
+ - app/components/keystone/ui/funnel_component.html.erb
114
116
  - app/components/keystone/ui/bucket_component.rb
115
117
  - app/components/keystone/ui/bucket_series_component.rb
116
118
  - app/components/keystone/ui/grid_component.rb
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keystone_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.5
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.9.0
18
+ version: 0.10.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.9.0
25
+ version: 0.10.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: view_component
28
28
  requirement: !ruby/object:Gem::Requirement