navtastic 0.0.1
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 +7 -0
- data/.circleci/config.yml +70 -0
- data/.codeclimate.yml +13 -0
- data/.gitignore +18 -0
- data/.rubocop.yml +21 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +14 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +143 -0
- data/Rakefile +19 -0
- data/lib/navtastic.rb +61 -0
- data/lib/navtastic/item.rb +49 -0
- data/lib/navtastic/menu.rb +127 -0
- data/lib/navtastic/renderer.rb +87 -0
- data/lib/navtastic/version.rb +3 -0
- data/navtastic.gemspec +25 -0
- data/spec/demo/index.rhtml +29 -0
- data/spec/demo/server.rb +54 -0
- data/spec/navtastic/item_spec.rb +14 -0
- data/spec/navtastic/menu_spec.rb +203 -0
- data/spec/navtastic/renderer_spec.rb +19 -0
- data/spec/navtastic_spec.rb +52 -0
- data/spec/spec_helper.rb +113 -0
- data/spec/support/matchers/current_item.rb +51 -0
- data/spec/support/navtastic_store.rb +6 -0
- metadata +162 -0
@@ -0,0 +1,127 @@
|
|
1
|
+
module Navtastic
|
2
|
+
# Stores items generated by a definition block
|
3
|
+
class Menu
|
4
|
+
include Enumerable
|
5
|
+
|
6
|
+
# @return [Array<Item>] the items in this menu
|
7
|
+
attr_reader :items
|
8
|
+
|
9
|
+
# @return [Menu,nil] this parent of this menu
|
10
|
+
attr_reader :parent
|
11
|
+
|
12
|
+
# Create a new empty menu
|
13
|
+
#
|
14
|
+
# @param root [Menu] the root menu of this is a submenu
|
15
|
+
def initialize(parent = nil)
|
16
|
+
@parent = parent
|
17
|
+
|
18
|
+
@current_item = nil
|
19
|
+
@items = []
|
20
|
+
@items_by_url = {}
|
21
|
+
end
|
22
|
+
|
23
|
+
# @return [true] if this menu is the root menu
|
24
|
+
# @return [false] if this menu is a submenu
|
25
|
+
def root?
|
26
|
+
@parent.nil?
|
27
|
+
end
|
28
|
+
|
29
|
+
# The depth of this menu
|
30
|
+
#
|
31
|
+
# The root menu always has depth 0.
|
32
|
+
#
|
33
|
+
# @return [Integer] the depth of this menu
|
34
|
+
def depth
|
35
|
+
if @parent
|
36
|
+
@parent.depth + 1
|
37
|
+
else
|
38
|
+
0
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Add a new item at the end of the menu
|
43
|
+
#
|
44
|
+
# @param name [String]the name to display in the menu
|
45
|
+
# @param url [String] the url to link to, if the item is a link
|
46
|
+
#
|
47
|
+
# @yield [submenu] block to generate a sub menu
|
48
|
+
# @yieldparam submenu [Menu] the menu to be initialized
|
49
|
+
def item(name, url = nil)
|
50
|
+
item = Item.new(self, name, url)
|
51
|
+
|
52
|
+
if block_given?
|
53
|
+
submenu = Menu.new(self)
|
54
|
+
yield submenu
|
55
|
+
item.submenu = submenu
|
56
|
+
end
|
57
|
+
|
58
|
+
@items << item
|
59
|
+
register_item(item)
|
60
|
+
|
61
|
+
item
|
62
|
+
end
|
63
|
+
|
64
|
+
def each
|
65
|
+
@items.each do |item|
|
66
|
+
yield item
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Find an item in this menu matching the url
|
71
|
+
#
|
72
|
+
# @param url [String] the url of the item
|
73
|
+
#
|
74
|
+
# @return [Item] if an item with that url exists
|
75
|
+
# @return [nil] if the item doens't exist
|
76
|
+
def [](url)
|
77
|
+
@items_by_url[url]
|
78
|
+
end
|
79
|
+
|
80
|
+
# Sets the current active item by url
|
81
|
+
#
|
82
|
+
# @private
|
83
|
+
#
|
84
|
+
# @param current_url [String] the url of the current page
|
85
|
+
def current_url=(current_url)
|
86
|
+
@current_item = nil
|
87
|
+
return if current_url.nil?
|
88
|
+
|
89
|
+
# Sort urls from longest to shortest and find the first matching substring
|
90
|
+
matching_item = @items_by_url
|
91
|
+
.sort_by { |url, _item| -url.length }.to_h
|
92
|
+
.find { |url, _item| current_url.start_with? url }
|
93
|
+
|
94
|
+
@current_item = matching_item[1] if matching_item
|
95
|
+
end
|
96
|
+
|
97
|
+
# @see file:README.md#Current_item documentation on how the current item is
|
98
|
+
# selected
|
99
|
+
#
|
100
|
+
# @return [Item,nil] the current active item
|
101
|
+
def current_item
|
102
|
+
if root?
|
103
|
+
@current_item
|
104
|
+
else
|
105
|
+
@parent.current_item
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
protected
|
110
|
+
|
111
|
+
# Register a newly added item
|
112
|
+
#
|
113
|
+
# @param item [Item] the new item to register
|
114
|
+
def register_item(item)
|
115
|
+
return unless item.url
|
116
|
+
|
117
|
+
@items_by_url[item.url] = item
|
118
|
+
|
119
|
+
if root?
|
120
|
+
# The first item with a url is the default current item
|
121
|
+
@current_item = item if @current_item.nil? && item.url
|
122
|
+
else
|
123
|
+
@parent.register_item(item)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'arbre'
|
2
|
+
|
3
|
+
module Navtastic
|
4
|
+
# Generate HTML based on a menu
|
5
|
+
#
|
6
|
+
# The actual HTML generation is done using the
|
7
|
+
# [Arbre](https://github.com/activeadmin/arbre) gem.
|
8
|
+
#
|
9
|
+
# Calling `#to_s` will return the HTML.
|
10
|
+
class Renderer < Arbre::Context
|
11
|
+
# Create a new renderer
|
12
|
+
#
|
13
|
+
# @param menu [Menu]
|
14
|
+
#
|
15
|
+
# @return [Renderer]
|
16
|
+
def self.render(menu)
|
17
|
+
new(menu: menu) do
|
18
|
+
menu(menu)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Starting a new root menu or submenu (e.g. `<ul>` tag)
|
23
|
+
#
|
24
|
+
# @param menu [Menu]
|
25
|
+
# @return [Arbre::HTML::Tag]
|
26
|
+
def menu(menu)
|
27
|
+
ul do
|
28
|
+
menu.each do |item|
|
29
|
+
item_container item
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# The container for every menu item (e.g. `<li>` tags)
|
35
|
+
#
|
36
|
+
# @param item [Item]
|
37
|
+
# @return [Arbre::HTML::Tag]
|
38
|
+
def item_container(item)
|
39
|
+
li(class: css_classes_string(item, :item_container)) do
|
40
|
+
item_content item
|
41
|
+
|
42
|
+
menu(item.submenu) if item.submenu?
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# The item itself (e.g. `<a>` tag for links)
|
47
|
+
#
|
48
|
+
# @param item [Item]
|
49
|
+
# @return [Arbre::HTML::Tag]
|
50
|
+
def item_content(item)
|
51
|
+
if item.url
|
52
|
+
a(href: item.url) { item.name }
|
53
|
+
else
|
54
|
+
span item.name
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Decide which css classes are needed for this item
|
59
|
+
#
|
60
|
+
# For example, the {#item_container} uses this to retrieve the css class for
|
61
|
+
# the current active item.
|
62
|
+
#
|
63
|
+
# @param item [Item] the current item that is rendered
|
64
|
+
# @param context [Symbol] which method is asking for the css classes
|
65
|
+
#
|
66
|
+
# @return [Array<String>] list of css classes to apply to the HTML element
|
67
|
+
def css_classes(item, context)
|
68
|
+
classes = []
|
69
|
+
|
70
|
+
case context
|
71
|
+
when :item_container
|
72
|
+
classes << 'current' if item.current?
|
73
|
+
end
|
74
|
+
|
75
|
+
classes
|
76
|
+
end
|
77
|
+
|
78
|
+
# Same as {css_classes} method, but joins classes together in a string
|
79
|
+
#
|
80
|
+
# @see css_classes
|
81
|
+
#
|
82
|
+
# @return [String]
|
83
|
+
def css_classes_string(item, context)
|
84
|
+
css_classes(item, context).join ' '
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
data/navtastic.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
$LOAD_PATH.push File.expand_path('../lib', __FILE__)
|
2
|
+
require 'navtastic/version'
|
3
|
+
|
4
|
+
Gem::Specification.new do |s|
|
5
|
+
s.name = 'navtastic'
|
6
|
+
s.version = Navtastic::VERSION
|
7
|
+
s.summary = "Define and render complex navigation menus"
|
8
|
+
s.authors = ["Aram Visser"]
|
9
|
+
s.email = "hello@aramvisser.com"
|
10
|
+
s.homepage = "http://github.com/aramvisser/navtastic"
|
11
|
+
s.license = 'MIT'
|
12
|
+
|
13
|
+
s.files = `git ls-files -z`.split("\x0")
|
14
|
+
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
15
|
+
s.require_paths = ['lib']
|
16
|
+
s.extra_rdoc_files = ['README.md']
|
17
|
+
|
18
|
+
s.add_dependency 'arbre', '~> 1.1'
|
19
|
+
|
20
|
+
s.add_development_dependency 'redcarpet', '~> 3.4'
|
21
|
+
s.add_development_dependency 'rspec', '~> 3.6'
|
22
|
+
s.add_development_dependency 'rubocop', '~> 0.48'
|
23
|
+
s.add_development_dependency 'rubocop-rspec', '~> 1.15'
|
24
|
+
s.add_development_dependency 'yard', '~> 0.9'
|
25
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
<%
|
2
|
+
Navtastic.define :main_menu do |menu|
|
3
|
+
menu.item "Home", '/' do |submenu|
|
4
|
+
submenu.item "Posts", '/posts'
|
5
|
+
submenu.item "About", '/about'
|
6
|
+
end
|
7
|
+
|
8
|
+
menu.item "Settings" do |submenu|
|
9
|
+
submenu.item "General", '/settings'
|
10
|
+
submenu.item "Profile", '/settings/profile'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
%>
|
14
|
+
|
15
|
+
<html>
|
16
|
+
<head>
|
17
|
+
<title>Navtastic Demo Server</title>
|
18
|
+
<style type="text/css">
|
19
|
+
.current { font-weight: bold }
|
20
|
+
.current ul { font-weight: normal }
|
21
|
+
</style>
|
22
|
+
</head>
|
23
|
+
|
24
|
+
<body>
|
25
|
+
<%= Navtastic.render :main_menu, current_url %>
|
26
|
+
|
27
|
+
<pre>current_url: <%= current_url %></pre>
|
28
|
+
</body>
|
29
|
+
</html>
|
data/spec/demo/server.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
$LOAD_PATH.push File.expand_path('../../../lib', __FILE__)
|
2
|
+
|
3
|
+
require 'navtastic'
|
4
|
+
require 'erb'
|
5
|
+
require 'webrick'
|
6
|
+
|
7
|
+
class DemoServer
|
8
|
+
def initialize(port)
|
9
|
+
@port = port
|
10
|
+
end
|
11
|
+
|
12
|
+
def start
|
13
|
+
s = WEBrick::HTTPServer.new Port: @port, DocumentRoot: 'demo/', RequestCallback: -> (req, res) do
|
14
|
+
# A poor man's hot reload. Just reload everything on every request.
|
15
|
+
Object.send(:remove_const, :Navtastic) if Object.constants.include?(:Navtastic)
|
16
|
+
|
17
|
+
Dir.glob('lib/**/*.rb').each do |file|
|
18
|
+
load file
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Add a mime type for *.rhtml files
|
23
|
+
WEBrick::HTTPUtils::DefaultMimeTypes.store('rhtml', 'text/html')
|
24
|
+
|
25
|
+
# Mount servlets
|
26
|
+
#s.mount('/', HTTPServlet::FileHandler, Dir.pwd)
|
27
|
+
s.mount '/', MenuServlet
|
28
|
+
|
29
|
+
# Trap signals so as to shutdown cleanly.
|
30
|
+
['TERM', 'INT'].each do |signal|
|
31
|
+
trap(signal){ s.shutdown }
|
32
|
+
end
|
33
|
+
|
34
|
+
# Start the server and block on input.
|
35
|
+
s.start
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class MenuServlet < WEBrick::HTTPServlet::AbstractServlet
|
40
|
+
def do_GET(request, response)
|
41
|
+
response.status = 200
|
42
|
+
response['Content-Type'] = 'text/html'
|
43
|
+
response.body = render_page request
|
44
|
+
end
|
45
|
+
|
46
|
+
def render_page(request)
|
47
|
+
template_file = File.expand_path('../index.rhtml', __FILE__)
|
48
|
+
template_string = File.read template_file
|
49
|
+
|
50
|
+
current_url = request.path
|
51
|
+
|
52
|
+
ERB.new(template_string).result(binding)
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe Navtastic::Item do
|
4
|
+
describe '#current?' do
|
5
|
+
let(:menu) { Navtastic::Menu.new }
|
6
|
+
let!(:item) { menu.item "Home", '/' }
|
7
|
+
|
8
|
+
it "calls the Menu#current_item method" do
|
9
|
+
allow(menu).to receive :current_item
|
10
|
+
item.current?
|
11
|
+
expect(menu).to have_received :current_item
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,203 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'support/matchers/current_item.rb'
|
3
|
+
|
4
|
+
RSpec.describe Navtastic::Menu do
|
5
|
+
subject(:menu) do
|
6
|
+
menu = described_class.new
|
7
|
+
|
8
|
+
menu.item "Home" do |submenu|
|
9
|
+
submenu.item "Posts", '/posts'
|
10
|
+
submenu.item "Featured", '/posts/featured'
|
11
|
+
end
|
12
|
+
|
13
|
+
menu.item "About", '/about'
|
14
|
+
|
15
|
+
menu
|
16
|
+
end
|
17
|
+
|
18
|
+
describe '.new' do
|
19
|
+
subject(:menu) { described_class.new }
|
20
|
+
|
21
|
+
context "when the menu has no parent" do
|
22
|
+
specify { expect(menu.items).to be_empty }
|
23
|
+
specify { expect(menu).not_to have_current_item }
|
24
|
+
|
25
|
+
it "has a depth of 0" do
|
26
|
+
expect(menu.depth).to eq 0
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
context "when the menu has a parent" do
|
31
|
+
subject(:submenu) { described_class.new(menu) }
|
32
|
+
|
33
|
+
specify { expect(submenu.items).to be_empty }
|
34
|
+
specify { expect(submenu).not_to have_current_item }
|
35
|
+
|
36
|
+
it "has a depth of parent.depth + 1" do
|
37
|
+
expect(submenu.depth).to eq(menu.depth + 1)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe '#item' do
|
43
|
+
specify { expect { menu.item "Test" }. to change { menu.items.size }.by 1 }
|
44
|
+
|
45
|
+
it "returns the inserted item" do
|
46
|
+
expect(menu.item("Test")).to eq menu.items.last
|
47
|
+
end
|
48
|
+
|
49
|
+
context "with 1 argument" do
|
50
|
+
before { menu.item name }
|
51
|
+
|
52
|
+
let(:name) { "Test" }
|
53
|
+
let(:item) { menu.items.last }
|
54
|
+
|
55
|
+
it "sets the name" do
|
56
|
+
expect(item.name).to eq name
|
57
|
+
end
|
58
|
+
|
59
|
+
it "leaves the url empty" do
|
60
|
+
expect(item.url).to eq nil
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
context "with 2 arguments" do
|
65
|
+
before { menu.item name, url }
|
66
|
+
|
67
|
+
let(:name) { "Test" }
|
68
|
+
let(:url) { "/url" }
|
69
|
+
let(:item) { menu.items.last }
|
70
|
+
|
71
|
+
it "sets the name" do
|
72
|
+
expect(item.name).to eq name
|
73
|
+
end
|
74
|
+
|
75
|
+
it "sets the url" do
|
76
|
+
expect(item.url).to eq url
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
context "when the item has a submenu" do
|
81
|
+
let(:item) { menu.items.first }
|
82
|
+
|
83
|
+
it "adds the menu to the itme" do
|
84
|
+
expect(item.submenu).not_to eq nil
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe '#items' do
|
90
|
+
it "includes every item defined in this menu" do
|
91
|
+
expect(menu.items.count).to eq 2
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
describe '#each' do
|
96
|
+
specify { expect { |b| menu.each(&b) }.to yield_control.exactly(2).times }
|
97
|
+
specify { expect(menu).to all be_a Navtastic::Item }
|
98
|
+
end
|
99
|
+
|
100
|
+
describe '#[]' do
|
101
|
+
it "returns the item for the given url in this menu" do
|
102
|
+
expect(menu['/about'].name).to eql 'About'
|
103
|
+
end
|
104
|
+
|
105
|
+
it "returns the item for the given url in a submenu" do
|
106
|
+
expect(menu['/posts/featured'].name).to eql 'Featured'
|
107
|
+
end
|
108
|
+
|
109
|
+
it "returns nil when the url doesn't exist" do
|
110
|
+
expect(menu['/foo']).to be nil
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
describe '#current_item' do
|
115
|
+
subject(:current_item) { menu.current_item }
|
116
|
+
|
117
|
+
context "when the menu has no items" do
|
118
|
+
let(:menu) { described_class.new }
|
119
|
+
|
120
|
+
it { is_expected.to eq nil }
|
121
|
+
end
|
122
|
+
|
123
|
+
context "when the current url was not set" do
|
124
|
+
it "points to the first item with a url" do
|
125
|
+
expect(current_item).to eq menu['/posts']
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
context "when the current url was set" do
|
130
|
+
before { menu.current_url = url }
|
131
|
+
|
132
|
+
let(:url) { '/about' }
|
133
|
+
|
134
|
+
it "points to the item matching the url" do
|
135
|
+
expect(current_item).to eq menu[url]
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
context "when the current url matches an item in a submenu" do
|
140
|
+
before { menu.current_url = url }
|
141
|
+
|
142
|
+
let(:url) { '/posts' }
|
143
|
+
|
144
|
+
it "points to the item in the submenu" do
|
145
|
+
expect(current_item).to eq menu[url]
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
context "when the current menu has a parent" do
|
150
|
+
before { menu.current_url = url }
|
151
|
+
|
152
|
+
subject(:submenu) { menu.items.first.submenu }
|
153
|
+
|
154
|
+
let(:url) { '/about' }
|
155
|
+
|
156
|
+
it "asks the parent menu for the current item" do
|
157
|
+
allow(menu).to receive :current_item
|
158
|
+
submenu.current_item
|
159
|
+
expect(menu).to have_received :current_item
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
describe '#current_url=' do
|
165
|
+
before { menu.current_url = current_url }
|
166
|
+
|
167
|
+
context "when the url matches an item directly" do
|
168
|
+
let(:current_url) { '/posts' }
|
169
|
+
|
170
|
+
it "sets the item matching the url as current" do
|
171
|
+
expect(menu).to have_current_item(menu[current_url])
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
context "when the url matches the beginning of an item" do
|
176
|
+
let(:current_url) { '/posts/featured/2' }
|
177
|
+
|
178
|
+
it "sets the item with the longest matching url as current" do
|
179
|
+
expect(menu).to have_current_item(menu['/posts/featured'])
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
context "when the url matches nothing without a root item" do
|
184
|
+
let(:current_url) { '/foo/bar' }
|
185
|
+
|
186
|
+
it { is_expected.not_to have_current_item }
|
187
|
+
end
|
188
|
+
|
189
|
+
context "when the url matches nothing with a root item" do
|
190
|
+
subject(:menu) do
|
191
|
+
menu = described_class.new
|
192
|
+
menu.item "Home", '/'
|
193
|
+
menu
|
194
|
+
end
|
195
|
+
|
196
|
+
let(:current_url) { '/foo/bar' }
|
197
|
+
|
198
|
+
it "sets the root item as current" do
|
199
|
+
expect(menu).to have_current_item(menu['/'])
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|